From e684ea4bfb55e2c127dbcc564ed42181fc266aac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 00:58:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20aggressive=20mode=20=E2=80=94=20service?= =?UTF-8?q?-agnostic=20interception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace URL-pattern and body-shape matching with universal approach: Outbound (substitution): - Hooks ALL POST/PUT/PATCH fetch and XHR requests, not just known API endpoints. Skips static assets and analytics. - Deep-walks any JSON structure recursively to find and substitute all string values. Skips metadata keys (model, id, token, etc.). - Falls back to raw string substitution for non-JSON bodies. Inbound (reveal): - Walks ALL text nodes in document.body, not just specific CSS selectors. Skips SCRIPT, STYLE, INPUT, TEXTAREA tags. - Handles streaming by observing characterData mutations globally. This makes Silent Send survive any API restructuring — the only thing that could break it is a site encrypting request bodies in JS before fetch, which would also break their own dev tools. Performance: reveal pairs are cached and only rebuilt on config change. Deep walk skips known non-content keys to avoid touching auth tokens or request metadata. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.js | 334 +++++++++++++++++++---------------------- 1 file changed, 153 insertions(+), 181 deletions(-) diff --git a/src/content/content.js b/src/content/content.js index f9561ad..14bded1 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -285,8 +285,21 @@ } // ============================================================ - // Process a JSON body — handles all known AI service API shapes + // Deep JSON scanner — finds and substitutes ALL strings in + // any JSON structure. Service-agnostic. Survives API changes. // ============================================================ + const SKIP_KEYS = new Set([ + // Keys that should never be modified (auth, metadata, IDs) + 'model', 'id', 'parent_message_id', 'conversation_id', + 'organization_id', 'uuid', 'token', 'api_key', 'key', + 'authorization', 'cookie', 'csrf', 'nonce', 'hash', + 'Content-Type', 'content-type', 'Accept', 'accept', + 'User-Agent', 'user-agent', 'x-request-id', + ]); + + // Min length for a string to be worth scanning + const MIN_STRING_LENGTH = 2; + function processBody(body) { let modified = false; const allReplacements = []; @@ -300,94 +313,44 @@ return r; } - // Walk any string values that look like user content - function walkAndSubstitute(obj, key) { - if (typeof obj[key] === 'string' && obj[key].length > 0) { - const r = processText(obj[key]); - if (r.modified) obj[key] = r.text; - } - } - - // --- Claude: { prompt: "..." } --- - if (typeof body.prompt === 'string') { - walkAndSubstitute(body, 'prompt'); - } - - // --- Claude / OpenAI / OpenWebUI: { messages: [{ role, content }] } --- - if (Array.isArray(body.messages)) { - for (const msg of body.messages) { - if (msg.role !== 'user' && msg.role !== 'human') continue; - - if (typeof msg.content === 'string') { - walkAndSubstitute(msg, 'content'); + // Recursively walk any JSON structure and substitute all strings + function deepWalk(obj, parentKey) { + if (typeof obj === 'string') { + if (obj.length >= MIN_STRING_LENGTH) { + return processText(obj); } + return { text: obj, modified: false }; + } - if (Array.isArray(msg.content)) { - for (let j = 0; j < msg.content.length; j++) { - const part = msg.content[j]; - if (typeof part === 'string') { - const r = processText(part); - if (r.modified) msg.content[j] = r.text; - } else if (part?.type === 'text' && typeof part.text === 'string') { - walkAndSubstitute(part, 'text'); - } + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + if (typeof obj[i] === 'string' && obj[i].length >= MIN_STRING_LENGTH) { + const r = processText(obj[i]); + if (r.modified) obj[i] = r.text; + } else if (typeof obj[i] === 'object' && obj[i] !== null) { + deepWalk(obj[i], null); + } + } + return; + } + + if (typeof obj === 'object' && obj !== null) { + for (const key of Object.keys(obj)) { + // Skip metadata/auth keys + if (SKIP_KEYS.has(key)) continue; + + const val = obj[key]; + if (typeof val === 'string' && val.length >= MIN_STRING_LENGTH) { + const r = processText(val); + if (r.modified) obj[key] = r.text; + } else if (typeof val === 'object' && val !== null) { + deepWalk(val, key); } } } } - // --- Claude: { content: [{ type: "text", text }] } --- - if (Array.isArray(body.content) && !Array.isArray(body.messages)) { - for (let i = 0; i < body.content.length; i++) { - const item = body.content[i]; - if (item.type === 'text' && typeof item.text === 'string') { - walkAndSubstitute(item, 'text'); - } - } - } - - // --- ChatGPT: { action: "next", messages: [{ content: { parts: ["..."] } }] } --- - if (Array.isArray(body.messages)) { - for (const msg of body.messages) { - if (msg.content?.parts && Array.isArray(msg.content.parts)) { - for (let i = 0; i < msg.content.parts.length; i++) { - if (typeof msg.content.parts[i] === 'string') { - const r = processText(msg.content.parts[i]); - if (r.modified) msg.content.parts[i] = r.text; - } - } - } - } - } - - // --- Gemini: nested prompts in f.req batch RPC (text strings in arrays) --- - // Gemini uses a complex nested array format. We recursively find strings. - if (Array.isArray(body) || body?.fReq) { - function walkArray(arr) { - for (let i = 0; i < arr.length; i++) { - if (typeof arr[i] === 'string' && arr[i].length > 2) { - const r = processText(arr[i]); - if (r.modified) arr[i] = r.text; - } else if (Array.isArray(arr[i])) { - walkArray(arr[i]); - } - } - } - if (Array.isArray(body)) walkArray(body); - } - - // --- Grok: { message: "...", messages: [...] } --- - if (typeof body.message === 'string') { - walkAndSubstitute(body, 'message'); - } - - // --- OpenWebUI: { prompt: "...", messages: [...], model: "..." } --- - // Already handled by 'prompt' and 'messages' above - - // --- Generic: { query: "..." } or { input: "..." } --- - if (typeof body.query === 'string') walkAndSubstitute(body, 'query'); - if (typeof body.input === 'string') walkAndSubstitute(body, 'input'); - + deepWalk(body, null); return { modified, replacements: allReplacements }; } @@ -403,48 +366,41 @@ } // ============================================================ - // API URL Detection — matches known AI service endpoints - // ============================================================ - const API_PATTERNS = [ - // Claude - /\/api\/organizations\/.*\/(chat_conversations|completion|messages)/, - // ChatGPT / OpenAI - /\/backend-api\/conversation/, - /\/api\/conversation/, - /\/v1\/chat\/completions/, - // Grok - /\/i\/api\/graphql.*grok/i, - /grok.*\/api\//, - /\/2\/grok\/add_response/, - // Gemini - /\/_\/BardChatUi\/data\//, - /\/google\.internal\.gemini/, - /generativelanguage.*generateContent/, - // OpenWebUI (self-hosted, various paths) - /\/api\/chat\/?/, - /\/ollama\/api\/chat/, - /\/api\/v1\/chat\/completions/, - ]; - - function isTargetApiUrl(url) { - return API_PATTERNS.some(pattern => pattern.test(url)); - } - - // ============================================================ - // Fetch Interception + // Fetch Interception — scans ALL POST requests with a body. + // Service-agnostic: doesn't depend on URL patterns. // ============================================================ const originalFetch = window.fetch; + // URLs to never touch (static assets, analytics, etc.) + const SKIP_URL_PATTERNS = [ + /\.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|ico)(\?|$)/i, + /\/analytics\//i, + /\/telemetry\//i, + /\/log\//i, + /google-analytics/i, + /sentry/i, + ]; + + function shouldSkipUrl(url) { + return SKIP_URL_PATTERNS.some(p => p.test(url)); + } + window.fetch = async function (url, options) { if (!settings.enabled || !hasSubstitutions()) { return originalFetch.call(this, url, options); } const urlStr = typeof url === 'string' ? url : url?.url || ''; + const method = (options?.method || 'GET').toUpperCase(); - if (isTargetApiUrl(urlStr) && options?.body && typeof options.body === 'string') { + // Only intercept POST/PUT/PATCH with a string body + if ( + (method === 'POST' || method === 'PUT' || method === 'PATCH') && + options?.body && typeof options.body === 'string' && + !shouldSkipUrl(urlStr) + ) { try { - // Try JSON first (most services) + // Try JSON const body = JSON.parse(options.body); const { modified, replacements } = processBody(body); @@ -452,23 +408,21 @@ options = { ...options, body: JSON.stringify(body) }; notifySubstitutions(replacements); console.log( - `[Silent Send] Substituted ${replacements.length} value(s) in fetch request` + `[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}` ); } } catch (e) { - // Not JSON — try form-encoded (Gemini uses f.req param) - try { - if (options.body.includes('f.req=') || options.body.includes('at=')) { - 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 request` - ); - } + // 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` + ); } - } catch (e2) { /* pass through */ } + } } } @@ -476,21 +430,24 @@ }; // ============================================================ - // XMLHttpRequest Interception (fallback) + // XMLHttpRequest Interception — same aggressive approach // ============================================================ const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._ssUrl = url; + this._ssMethod = method; return origOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (body) { + const method = (this._ssMethod || 'GET').toUpperCase(); if ( settings.enabled && hasSubstitutions() && - typeof body === 'string' && this._ssUrl && - isTargetApiUrl(this._ssUrl) + (method === 'POST' || method === 'PUT' || method === 'PATCH') && + typeof body === 'string' && body.length > MIN_STRING_LENGTH && + !shouldSkipUrl(this._ssUrl || '') ) { try { const parsed = JSON.parse(body); @@ -499,7 +456,14 @@ body = JSON.stringify(parsed); notifySubstitutions(replacements); } - } catch (e) { /* pass through */ } + } catch (e) { + // Not JSON — raw string + const result = substituteAll(body); + if (result.modified) { + body = result.text; + notifySubstitutions(result.replacements); + } + } } return origSend.call(this, body); }; @@ -508,24 +472,6 @@ // Response Reveal — swaps fake data back to real in the page // ============================================================ - // All response/output selectors across supported services - const RESPONSE_SELECTORS = [ - // Claude - '[data-is-streaming]', '.font-claude-message', - // Claude artifacts / code - '.code-block__code', '.artifact-content', 'pre code', 'pre', - // ChatGPT - '[data-message-author-role="assistant"]', '.markdown', - // Grok - '[class*="message-bubble"]', '[class*="response"]', - // Gemini - '.model-response-text', '.response-content', 'message-content', - // Generic / OpenWebUI - '.prose', '[class*="Message"]', '[class*="assistant"]', - // Code and artifacts everywhere - 'code', '.hljs', '.highlight', - ].join(', '); - // Build reverse mapping pairs from identity + explicit mappings function buildRevealPairs() { const pairs = []; @@ -564,8 +510,15 @@ return pairs; } + // Cache reveal pairs — rebuild when config changes + let _revealPairsCache = null; + window.addEventListener('message', (event) => { + if (event.data?.type === 'ss:config-updated') _revealPairsCache = null; + }); + function revealText(text) { - const pairs = buildRevealPairs(); + if (!_revealPairsCache) _revealPairsCache = buildRevealPairs(); + const pairs = _revealPairsCache; let result = text; for (const p of pairs) { const escaped = esc(p.from); @@ -579,13 +532,24 @@ const originalTexts = new WeakMap(); function revealInElement(el) { - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + if (SKIP_REVEAL_TAGS.has(el.tagName)) return; + // Skip our own badge + if (el.classList?.contains('ss-reveal-badge')) return; + + 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?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + let textNode; while ((textNode = walker.nextNode())) { const text = textNode.textContent; - if (!text || text.trim().length === 0) continue; + if (!text || text.length < MIN_STRING_LENGTH) continue; - // Save original if not already saved if (!originalTexts.has(textNode)) { originalTexts.set(textNode, text); } @@ -598,6 +562,8 @@ } function unrevealInElement(el) { + if (SKIP_REVEAL_TAGS.has(el.tagName)) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let textNode; while ((textNode = walker.nextNode())) { @@ -608,54 +574,60 @@ } } - // Reveal ALL existing responses on the page + // Elements to skip when revealing (inputs, scripts, styles, extension UI) + const SKIP_REVEAL_TAGS = new Set([ + 'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT', + ]); + + // Reveal ALL text on the page (not just specific selectors) function revealAllResponses() { - const elements = document.querySelectorAll(RESPONSE_SELECTORS); - for (const el of elements) { - revealInElement(el); - } + revealInElement(document.body); } - // Un-reveal ALL responses (restore originals) + // Un-reveal ALL text on the page function unrevealAllResponses() { - const elements = document.querySelectorAll(RESPONSE_SELECTORS); - for (const el of elements) { - unrevealInElement(el); - } + unrevealInElement(document.body); } - // Watch for new content (streaming responses, new messages) + // Watch for ANY new content on the page function observeResponses() { const observer = new MutationObserver((mutations) => { if (!settings.revealMode || !hasSubstitutions()) return; for (const mutation of mutations) { - // Handle new nodes + // Handle new nodes — reveal all text in them for (const node of mutation.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - - const responseEls = node.querySelectorAll - ? node.querySelectorAll(RESPONSE_SELECTORS) - : []; - for (const el of responseEls) revealInElement(el); - - if (node.matches?.(RESPONSE_SELECTORS)) { - revealInElement(node); + if (node.nodeType === Node.ELEMENT_NODE) { + if (!SKIP_REVEAL_TAGS.has(node.tagName)) { + revealInElement(node); + } + } else if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent; + if (text && text.length >= MIN_STRING_LENGTH) { + if (!originalTexts.has(node)) { + originalTexts.set(node, text); + } + const revealed = revealText(text); + if (revealed !== text) { + node.textContent = revealed; + } + } } } - // Handle text changes in existing nodes (streaming) + // Handle text changes in existing nodes (streaming responses) if (mutation.type === 'characterData' && settings.revealMode) { - const parentEl = mutation.target.parentElement; - if (parentEl?.closest?.(RESPONSE_SELECTORS)) { - const text = mutation.target.textContent; - const revealed = revealText(text); - if (revealed !== text) { - // Save original before overwriting + const text = mutation.target.textContent; + if (text && text.length >= MIN_STRING_LENGTH) { + const parent = mutation.target.parentElement; + if (parent && !SKIP_REVEAL_TAGS.has(parent.tagName)) { if (!originalTexts.has(mutation.target)) { originalTexts.set(mutation.target, text); } - mutation.target.textContent = revealed; + const revealed = revealText(text); + if (revealed !== text) { + mutation.target.textContent = revealed; + } } } }