From 4fa607d53f648c4efbed8b50519f8d22ac5201a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 19:49:04 +0000 Subject: [PATCH 1/2] Wire up document scanner for file upload interception, bump to 0.9.23 - Inject document-scanner.js into page world via injector.js (as module, sets globalThis.DocumentScanner) - Add FormData interception to fetch hook: scans File/Blob entries through DocumentScanner.processUpload(), also substitutes string fields - Add document-scanner.js to web_accessible_resources in both manifests - Bump version to 0.9.23 in package.json, manifest.json, manifest.firefox.json https://claude.ai/code/session_01NNBEPuXMFGWezJb1f958nL --- manifest.firefox.json | 4 +- manifest.json | 4 +- package.json | 2 +- src/content/content.js | 110 ++++++++++++++++++++++++++++++++-------- src/content/injector.js | 8 +++ 5 files changed, 101 insertions(+), 27 deletions(-) diff --git a/manifest.firefox.json b/manifest.firefox.json index 47aa3ec..2142180 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "0.9.21", + "version": "0.9.23", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "browser_specific_settings": { "gecko": { @@ -114,7 +114,7 @@ ], "web_accessible_resources": [ { - "resources": ["src/content/content.js", "src/content/early-hook.js"], + "resources": ["src/content/content.js", "src/content/early-hook.js", "src/lib/document-scanner.js"], "matches": [""] } ] diff --git a/manifest.json b/manifest.json index 12de1b0..c146340 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Silent Send", - "version": "0.9.21", + "version": "0.9.23", "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", "permissions": [ "storage", @@ -102,7 +102,7 @@ ], "web_accessible_resources": [ { - "resources": ["src/content/content.js", "src/content/early-hook.js"], + "resources": ["src/content/content.js", "src/content/early-hook.js", "src/lib/document-scanner.js"], "matches": [""] } ] diff --git a/package.json b/package.json index 83f8c94..97b8b2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silent-send", - "version": "0.9.21", + "version": "0.9.23", "private": true, "license": "MIT", "description": "Browser extension that substitutes personal data before sending to AI services", diff --git a/src/content/content.js b/src/content/content.js index e53436e..7f35276 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -737,6 +737,52 @@ return isConfigured(); } + // ============================================================ + // Document Scanner — process file uploads in FormData + // ============================================================ + async function scanFormData(formData) { + const newForm = new FormData(); + const allReplacements = []; + let anyModified = false; + + for (const [key, value] of formData.entries()) { + if (value instanceof File || value instanceof Blob) { + const filename = value instanceof File ? value.name : (key || 'file'); + try { + const result = await globalThis.DocumentScanner.processUpload( + value, filename, substituteAll + ); + if (result.replacements.length > 0) { + anyModified = true; + allReplacements.push(...result.replacements); + const newFile = new File([result.file], result.filename, { type: result.file.type }); + newForm.append(key, newFile, result.filename); + } else { + newForm.append(key, value, filename); + } + } catch (e) { + console.warn('[Silent Send] Document scan failed for', filename, e); + newForm.append(key, value, filename); + } + } else if (typeof value === 'string' && value.length >= MIN_STRING_LENGTH) { + // Also substitute string fields in the FormData + const result = substituteAll(value); + if (result.modified) { + anyModified = true; + allReplacements.push(...result.replacements); + newForm.append(key, result.text); + } else { + newForm.append(key, value); + } + } else { + newForm.append(key, value); + } + } + + if (!anyModified) return null; + return { formData: newForm, replacements: allReplacements }; + } + // ============================================================ // Fetch Interception — scans ALL POST requests with a body. // Service-agnostic: doesn't depend on URL patterns. @@ -765,35 +811,55 @@ const urlStr = typeof url === 'string' ? url : url?.url || ''; const method = (options?.method || 'GET').toUpperCase(); - // Only intercept POST/PUT/PATCH with a string body + // Only intercept POST/PUT/PATCH if ( (method === 'POST' || method === 'PUT' || method === 'PATCH') && - options?.body && typeof options.body === 'string' && - !shouldSkipUrl(urlStr) + options?.body && !shouldSkipUrl(urlStr) ) { - try { - // Try JSON - const body = JSON.parse(options.body); - const { modified, replacements } = processBody(body); - - if (modified) { - options = { ...options, body: JSON.stringify(body) }; - notifySubstitutions(replacements); - console.log( - `[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}` - ); + // FormData body — scan file uploads via DocumentScanner + if (options.body instanceof FormData && typeof globalThis.DocumentScanner !== 'undefined') { + try { + const scannedForm = await scanFormData(options.body); + if (scannedForm) { + options = { ...options, body: scannedForm.formData }; + if (scannedForm.replacements.length > 0) { + notifySubstitutions(scannedForm.replacements); + console.log( + `[Silent Send] Substituted ${scannedForm.replacements.length} value(s) in file upload to ${urlStr}` + ); + } + } + } catch (e) { + console.warn('[Silent Send] FormData scan failed:', e); } - } catch (e) { - // Not JSON — try raw string substitution (form data, etc.) - if (options.body.length > MIN_STRING_LENGTH) { - const result = substituteAll(options.body); - if (result.modified) { - options = { ...options, body: result.text }; - notifySubstitutions(result.replacements); + } + + // String body — JSON or raw text + if (typeof options.body === 'string') { + try { + // Try JSON + const body = JSON.parse(options.body); + const { modified, replacements } = processBody(body); + + if (modified) { + options = { ...options, body: JSON.stringify(body) }; + notifySubstitutions(replacements); console.log( - `[Silent Send] Substituted ${result.replacements.length} value(s) in form body` + `[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}` ); } + } catch (e) { + // Not JSON — try raw string substitution (form data, etc.) + if (options.body.length > MIN_STRING_LENGTH) { + const result = substituteAll(options.body); + if (result.modified) { + options = { ...options, body: result.text }; + notifySubstitutions(result.replacements); + console.log( + `[Silent Send] Substituted ${result.replacements.length} value(s) in form body` + ); + } + } } } } diff --git a/src/content/injector.js b/src/content/injector.js index 7bf78c4..03c9dc7 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -70,6 +70,14 @@ // Merge active profiles into a flat identity object for the content script const identity = mergeProfiles(identityData); + // Inject the document scanner into the page's world first (sets globalThis.DocumentScanner) + const docScannerScript = document.createElement('script'); + docScannerScript.type = 'module'; + docScannerScript.src = api.runtime.getURL('src/lib/document-scanner.js'); + (document.head || document.documentElement).appendChild(docScannerScript); + await new Promise(resolve => { docScannerScript.onload = resolve; docScannerScript.onerror = resolve; }); + docScannerScript.remove(); + // Inject the main interception script into the page's world const script = document.createElement('script'); script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); From 490b2ebf33758c1d726b34f32657c9773840a28f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 19:56:57 +0000 Subject: [PATCH 2/2] Fix reveal mode not restoring substitute values on toggle off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old unrevealInElement relied on a WeakMap (originalTexts) to restore text nodes to their pre-reveal state. This broke when SPA frameworks (React) re-rendered the DOM while reveal was active — new text nodes containing real values had no WeakMap entry to restore from, so real data stayed visible after turning reveal off. Fix: unrevealInElement now actively reverse-replaces real values back to their substitute counterparts using the reveal pairs, matching the same approach revealText uses in the forward direction. This works regardless of DOM re-renders or streaming content changes. https://claude.ai/code/session_01NNBEPuXMFGWezJb1f958nL --- src/content/content.js | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/content/content.js b/src/content/content.js index 7f35276..407e6cf 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -1035,15 +1035,37 @@ } } + function unrevealText(text) { + const pairs = getRevealPairs(); + let result = text; + for (const p of pairs) { + const escaped = esc(p.to); // p.to is the real value + const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi'); + result = result.replace(regex, p.from); // p.from is the substitute + } + return result; + } + function unrevealInElement(el) { if (SKIP_REVEAL_TAGS.has(el.tagName)) return; - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; + if (parent?.closest?.('.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); let textNode; while ((textNode = walker.nextNode())) { - const original = originalTexts.get(textNode); - if (original && textNode.textContent !== original) { - textNode.textContent = original; + const text = textNode.textContent; + if (!text || text.length < MIN_STRING_LENGTH) continue; + const unrevealed = unrevealText(text); + if (unrevealed !== text) { + textNode.textContent = unrevealed; + // Update saved original so future reveals start from the right state + originalTexts.set(textNode, unrevealed); } } }