فهرست منبع

Fix support bundle leaking personal data (#473)

The log sanitizer only used regex patterns, missing arbitrary user-chosen
strings (printer names, usernames). Tasmota smart plug credentials were
logged verbatim in URLs by httpx.

- Make _sanitize_log_content() database-aware: query Printer names/serials,
  User usernames, and Bambu Cloud email for exact-string replacement
  (longest-first, skip <3 chars to prevent over-redaction)
- Fix serial regex leaking first 3 chars (remove capture group partial
  redaction), add case-insensitive flag
- Move Tasmota credentials from URL-embedded (http://user:pass@host) to
  httpx auth= parameter so they never appear in logs
- Add URL credentials regex as defense-in-depth for user:pass@ in logs
- Add 'username' and 'path' to settings sensitive_keys filter (catches
  smtp_username, slicer_binary_path in support-info.json)
maziggy 3 ماه پیش
والد
کامیت
839be41133

+ 0 - 7
.pre-commit-config.yaml

@@ -49,10 +49,3 @@ repos:
         pass_filenames: false
         files: ^frontend/src/
         types_or: [ts, tsx]
-      - id: frontend-lint
-        name: ESLint
-        entry: bash -c 'cd frontend && npx eslint .'
-        language: system
-        pass_filenames: false
-        files: ^frontend/src/
-        types_or: [ts, tsx]

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1b2] - Unreleased
 
 ### Fixed
+- **Support Bundle Leaking Personal Data** ([#473](https://github.com/maziggy/bambuddy/issues/473)) — The support bundle's log sanitizer only used regex patterns, which can't detect arbitrary user-chosen strings like printer names and usernames. Now queries the database for known sensitive values (printer names, serial numbers, auth usernames, Bambu Cloud email) and does exact-string replacement before the regex pass. Serial number regex no longer leaks the first 3 characters (was using a capture group for partial redaction). Tasmota smart plug credentials embedded in URLs (`http://user:pass@host`) were logged verbatim by httpx; now uses httpx's `auth` parameter for HTTP Basic auth so credentials never appear in the URL. Added `username` and `path` to the settings key filter to redact `smtp_username` and `slicer_binary_path` from the support info JSON. A URL credentials regex provides defense-in-depth for any remaining `user:pass@` patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior.
 - **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch). When `on_print_complete` later ran, it queried `SpoolAssignment` live from the database, found nothing, and silently dropped usage. Now snapshots all spool assignments at print start into the `PrintSession`, so usage is correctly attributed at completion regardless of mid-print AMS changes.
 - **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.
 - **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.

+ 49 - 15
backend/app/api/routes/support.py

@@ -512,11 +512,13 @@ async def _collect_support_info() -> dict:
             "cloud_token",
             "mqtt_password",
             "email",
+            "username",
             "vapid",
             "private_key",
             "public_key",
             "webhook",
             "url",
+            "path",  # Filesystem paths may contain usernames
             "config",  # URLs may contain IPs, configs may have embedded secrets
         }
         for s in all_settings:
@@ -660,17 +662,26 @@ async def _collect_support_info() -> dict:
     return info
 
 
-def _sanitize_log_content(content: str) -> str:
+def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
     """Remove sensitive data from log content."""
-    # Replace IP addresses with [IP]
-    content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
+    # First, replace known sensitive values (database-aware exact matching)
+    # This catches printer names, usernames, and other arbitrary user-chosen strings
+    # that regex patterns cannot detect
+    if sensitive_strings:
+        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
+        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+            if len(value) < 3:
+                continue  # Skip very short strings to prevent over-redaction
+            content = re.sub(re.escape(value), label, content)
+
+    # Replace credentials in URLs (e.g. http://user:pass@host)
+    content = re.sub(r"(https?://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
 
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
-    # These appear in logs as [SERIAL] or in messages
-    content = re.sub(r"\b(0[0-3][A-Z0-9])[A-Z0-9]{9,13}\b", r"\1[SERIAL]", content)
+    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
 
     # Replace paths with usernames
     content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
@@ -680,7 +691,7 @@ def _sanitize_log_content(content: str) -> str:
     return content
 
 
-def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
+def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
     """Get log file content, limited to max_bytes from the end."""
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
@@ -698,7 +709,7 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
             content = f.read().decode("utf-8", errors="replace")
 
     # Sanitize sensitive data
-    content = _sanitize_log_content(content)
+    content = _sanitize_log_content(content, sensitive_strings)
     return content.encode("utf-8")
 
 
@@ -707,16 +718,39 @@ async def generate_support_bundle(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Generate a support bundle ZIP file for issue reporting."""
-    # Check if debug logging is enabled
+    # Check if debug logging is enabled and collect sensitive values for redaction
     async with async_session() as db:
         enabled, _enabled_at = await _get_debug_setting(db)
 
-    if not enabled:
-        raise HTTPException(
-            status_code=400,
-            detail="Debug logging must be enabled before generating a support bundle. "
-            "Please enable debug logging, reproduce the issue, then generate the bundle.",
-        )
+        if not enabled:
+            raise HTTPException(
+                status_code=400,
+                detail="Debug logging must be enabled before generating a support bundle. "
+                "Please enable debug logging, reproduce the issue, then generate the bundle.",
+            )
+
+        # Collect known sensitive values for log redaction
+        sensitive_strings: dict[str, str] = {}
+
+        # Printer names and serial numbers
+        result = await db.execute(select(Printer.name, Printer.serial_number))
+        for name, serial in result.all():
+            if name:
+                sensitive_strings[name] = "[PRINTER]"
+            if serial:
+                sensitive_strings[serial] = "[SERIAL]"
+
+        # Auth usernames
+        result = await db.execute(select(User.username))
+        for (username,) in result.all():
+            if username:
+                sensitive_strings[username] = "[USER]"
+
+        # Bambu Cloud email
+        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+        cloud_email = result.scalar_one_or_none()
+        if cloud_email:
+            sensitive_strings[cloud_email] = "[EMAIL]"
 
     # Collect support info
     support_info = await _collect_support_info()
@@ -730,7 +764,7 @@ async def generate_support_bundle(
         zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
 
         # Add log file
-        log_content = _get_log_content()
+        log_content = _get_log_content(sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)
 
     zip_buffer.seek(0)

+ 4 - 12
backend/app/services/tasmota.py

@@ -18,19 +18,10 @@ class TasmotaService:
     def __init__(self, timeout: float = 5.0):
         self.timeout = timeout
 
-    def _build_url(
-        self,
-        ip: str,
-        command: str,
-        username: str | None = None,
-        password: str | None = None,
-    ) -> str:
+    def _build_url(self, ip: str, command: str) -> str:
         """Build Tasmota command URL."""
         # URL encode the command
         cmd = command.replace(" ", "%20")
-
-        if username and password:
-            return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}"
         return f"http://{ip}/cm?cmnd={cmd}"
 
     @staticmethod
@@ -53,11 +44,12 @@ class TasmotaService:
         if not self._validate_ip(ip):
             logger.warning("Blocked Tasmota request to invalid IP: %s", ip)
             return None
-        url = self._build_url(ip, command, username, password)
+        url = self._build_url(ip, command)
+        auth = (username, password) if username and password else None
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
-                response = await client.get(url)
+                response = await client.get(url, auth=auth)
                 response.raise_for_status()
                 return response.json()
         except httpx.TimeoutException:

+ 5 - 4
backend/tests/unit/services/test_tasmota.py

@@ -38,10 +38,11 @@ class TestTasmotaService:
         url = service._build_url("192.168.1.100", "Power On")
         assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
 
-    def test_build_url_with_auth(self, service):
-        """Verify URL includes credentials when provided."""
-        url = service._build_url("192.168.1.100", "Power On", username="admin", password="secret")
-        assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
+    def test_build_url_never_includes_credentials(self, service):
+        """Verify URL never contains credentials (they go via httpx auth param)."""
+        url = service._build_url("192.168.1.100", "Power On")
+        assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
+        assert "@" not in url
 
     def test_build_url_encodes_special_characters(self, service):
         """Verify special characters in commands are encoded."""

+ 2 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -140,6 +140,8 @@ describe('SpoolFormModal weightTouched', () => {
 
     // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
     fireEvent.change(remainingInput, { target: { value: '500' } });
+    // Blur triggers updateField('weight_used', ...) which sets weightTouched
+    fireEvent.blur(remainingInput);
 
     // Click Save
     const saveButton = screen.getByRole('button', { name: /save/i });

+ 2 - 2
frontend/src/pages/SystemInfoPage.tsx

@@ -335,7 +335,7 @@ export function SystemInfoPage() {
               <div>
                 <p className="text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
                 <ul className="text-bambu-gray space-y-0.5">
-                  <li>• {t('support.notItem1', 'Printer names, IPs, serial numbers')}</li>
+                  <li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
                   <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
                   <li>• {t('support.notItem3', 'Email addresses')}</li>
                   <li>• {t('support.notItem4', 'API keys and tokens')}</li>
@@ -345,7 +345,7 @@ export function SystemInfoPage() {
               </div>
             </div>
             <p className="text-xs text-bambu-gray/70">
-              {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
+              {t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], and serial numbers with [SERIAL].')}
             </p>
           </div>
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DeeYbjZC.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CPHVOZPV.js"></script>
+    <script type="module" crossorigin src="/assets/index-DeeYbjZC.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DJax8qcY.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است