diff --git a/detailed_alerts_cowork.csv b/detailed_alerts_cowork.csv new file mode 100644 index 000000000..e835bbd85 --- /dev/null +++ b/detailed_alerts_cowork.csv @@ -0,0 +1,67 @@ +"File Path","Line Number","Description","Link" +"src/electron/agent/executor.ts",12180,"Use of a broken or weak cryptographic algorithm","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/66" +"src/electron/gateway/security.ts",427,"Creating biased random numbers from a cryptographically secure source","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/65" +"src/electron/agent/tools/batch-image-tools.ts",353,"Shell command built from environment values","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/64" +"src/electron/memory/MemoryObservationService.ts",87,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/63" +"src/electron/database/SecureSettingsRepository.ts",593,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/62" +"src/electron/agent/tools/registry.ts",8374,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/61" +"src/electron/agent/executor.ts",12180,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/60" +"src/electron/agent/llm/prompt-cache.ts",186,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/59" +"src/electron/agent/llm/prompt-cache.ts",154,"Use of password hash with insufficient computational effort","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/58" +"src/electron/hooks/server.ts",933,"Information exposure through a stack trace","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/57" +"src/electron/extensions/pack-registry.ts",93,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/56" +"src/electron/extensions/pack-registry.ts",92,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/55" +"src/electron/agent/tools/x-tools.ts",1143,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/54" +"src/electron/agent/tools/x-tools.ts",1143,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/53" +"src/electron/agent/tools/x-tools.ts",181,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/52" +"src/electron/agent/tools/x-tools.ts",181,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/51" +"src/electron/agent/tools/browser-tools.ts",1575,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/50" +"src/electron/agent/tools/browser-tools.ts",1535,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/49" +"src/electron/agent/skill-registry.ts",328,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/48" +"src/electron/agent/skill-registry.ts",327,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/47" +"scripts/collect-public-adoption-stats.mjs",68,"Incomplete URL substring sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/46" +"src/electron/agent/tools/canvas-tools.ts",316,"Incomplete URL scheme check","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/45" +"src/renderer/components/BrowserWorkbenchView.tsx",1523,"DOM text reinterpreted as HTML","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/44" +"src/renderer/components/BrowserView.tsx",156,"DOM text reinterpreted as HTML","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/43" +"src/renderer/utils/email-html-sanitize.ts",27,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/42" +"src/renderer/components/WebArtifactViewer.tsx",97,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/41" +"src/electron/mailbox/MailboxService.ts",981,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/40" +"src/electron/gateway/channels/email-client.ts",187,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/39" +"src/electron/agent/tools/web-fetch-tools.ts",513,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/38" +"src/electron/agent/tools/gmail-tools.ts",123,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/37" +"resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs",157,"Bad HTML filtering regexp","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/36" +"src/renderer/components/MainContent/markdown-normalization.ts",269,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/35" +"src/electron/utils/pptx-extractor.ts",502,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/34" +"src/electron/utils/json-utils.ts",175,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/33" +"src/electron/memory/DurableContextService.ts",73,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/32" +"src/electron/mailbox/MailboxService.ts",3521,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/31" +"src/electron/computer-use/window-isolation.ts",99,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/30" +"src/electron/computer-use/window-isolation.ts",74,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/29" +"src/electron/browser/browser-workbench-service.ts",106,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/28" +"src/electron/agent/executor-completion-utils.ts",42,"Incomplete string escaping or encoding","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/27" +"src/renderer/components/InboxAgentPanel.tsx",261,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/26" +"src/renderer/components/AssistantMessageContent.tsx",172,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/25" +"src/electron/utils/pptx-extractor.ts",338,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/24" +"src/electron/utils/pptx-extractor.ts",168,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/23" +"src/electron/mailbox/MailboxService.ts",979,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/22" +"src/electron/gateway/channels/email-client.ts",185,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/21" +"src/electron/gateway/channels/email.ts",528,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/20" +"src/electron/agent/tools/gmail-tools.ts",121,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/19" +"src/electron/agent/search/duckduckgo-provider.ts",158,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/18" +"resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs",156,"Double escaping or unescaping","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/17" +"src/renderer/utils/email-html-sanitize.ts",25,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/16" +"src/renderer/utils/email-html-sanitize.ts",25,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/15" +"src/renderer/utils/email-html-sanitize.ts",25,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/14" +"src/renderer/components/utils/attachment-content.ts",30,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/13" +"src/electron/utils/pptx-extractor.ts",338,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/12" +"src/electron/gateway/channels/email.ts",528,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/11" +"src/electron/agent/tools/web-fetch-tools.ts",582,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/10" +"src/electron/agent/tools/web-fetch-tools.ts",511,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/9" +"src/electron/agent/tools/web-fetch-tools.ts",511,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/8" +"src/electron/agent/tools/web-fetch-tools.ts",511,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/7" +"src/electron/agent/skills/document.ts",1070,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/6" +"src/electron/agent/search/duckduckgo-provider.ts",158,"Incomplete multi-character sanitization","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/5" +"src/electron/utils/webhook-auth.ts",29,"Polynomial regular expression used on uncontrolled data","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/4" +"src/electron/tunnels/relay.ts",324,"Polynomial regular expression used on uncontrolled data","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/3" +"src/shared/tool-call-text-sanitizer.ts",64,"Inefficient regular expression","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/2" +"src/renderer/utils/markdown-autolink.ts",11,"Inefficient regular expression","https://github.com/raid-ppcoe/CoWork-OS/security/code-scanning/1" diff --git a/resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs b/resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs index d99af51f8..420b9a0ee 100644 --- a/resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs +++ b/resources/skills/llm-wiki/scripts/wiki-workbench-lib.mjs @@ -153,15 +153,22 @@ export function normalizeRelativePath(filePath) { } export function stripHtml(html) { - return String(html || "") - .replace(/)<[^<]*)*<\/script>/gi, " ") - .replace(/)<[^<]*)*<\/style>/gi, " ") - .replace(/)<[^<]*)*<\/svg>/gi, " ") + // Remove script/style/svg blocks with their content. Loop until stable + + // allow trailing whitespace in the closing tag so split tags can't survive + // and `` can't bypass the filter. + let text = String(html || ""); + let prev; + do { + prev = text; + text = text.replace(/<(script|style|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " "); + } while (text !== prev); + return text .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") + // Unescape ampersands last so a decoded `&` can't form another entity. + .replace(/&/gi, "&") .replace(/\s+/g, " ") .trim(); } diff --git a/scripts/collect-public-adoption-stats.mjs b/scripts/collect-public-adoption-stats.mjs index 162f02a5d..336a50652 100644 --- a/scripts/collect-public-adoption-stats.mjs +++ b/scripts/collect-public-adoption-stats.mjs @@ -65,7 +65,13 @@ async function fetchJson(url, options = {}) { Accept: "application/vnd.github+json", ...options.headers, }; - if (token && url.includes("api.github.com")) { + let host = ""; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + // non-absolute URL: never attach the token + } + if (token && host === "api.github.com") { headers.Authorization = `Bearer ${token}`; headers["X-GitHub-Api-Version"] = "2022-11-28"; } diff --git a/src/electron/agent/executor-completion-utils.ts b/src/electron/agent/executor-completion-utils.ts index 4254c939a..28bfd2dc5 100644 --- a/src/electron/agent/executor-completion-utils.ts +++ b/src/electron/agent/executor-completion-utils.ts @@ -39,7 +39,7 @@ export function normalizePromptForContracts(taskPrompt: string): string { ); const withoutWorkflow = withoutAdditionalContext.replace( new RegExp( - `\\n{2}${WORKFLOW_DECOMPOSITION_HEADER.replace(/[()]/g, "\\$&")}\\n[\\s\\S]*?(?=\\n{2}${USER_UPDATE_HEADER}|$)`, + `\\n{2}${WORKFLOW_DECOMPOSITION_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n[\\s\\S]*?(?=\\n{2}${USER_UPDATE_HEADER}|$)`, "g", ), "", diff --git a/src/electron/agent/executor.ts b/src/electron/agent/executor.ts index efdef90b3..a155ba1c2 100644 --- a/src/electron/agent/executor.ts +++ b/src/electron/agent/executor.ts @@ -12177,7 +12177,8 @@ ${transcript} artifactKind: contract.artifactKind, }, bestCandidateLength: bestCandidate.length, - bestCandidateHash: createHash("sha1").update(bestCandidate).digest("hex").slice(0, 12), + // ponytail: non-security fingerprint for diagnostics, not a password; sha256 to silence weak-algo scan + bestCandidateHash: createHash("sha256").update(bestCandidate).digest("hex").slice(0, 12), directAnswer: { bestCandidatePasses: this.responseDirectlyAddressesPrompt(bestCandidate, contract), fallbackPasses: fallbackHasDirectAnswer, diff --git a/src/electron/agent/search/duckduckgo-provider.ts b/src/electron/agent/search/duckduckgo-provider.ts index cebdb9b5d..68e3a8915 100644 --- a/src/electron/agent/search/duckduckgo-provider.ts +++ b/src/electron/agent/search/duckduckgo-provider.ts @@ -155,17 +155,23 @@ export class DuckDuckGoProvider implements SearchProvider { } private stripHtml(html: string): string { - return html - .replace(//g, "") - .replace(/<\/b>/g, "") - .replace(/<[^>]+>/g, "") - .replace(/&/g, "&") + // Strip tags; loop until stable so a partial tag can't leave a working tag + // behind after a single pass. + let text = html.replace(//g, "").replace(/<\/b>/g, ""); + let prev: string; + do { + prev = text; + text = text.replace(/<[^>]+>/g, ""); + } while (text !== prev); + return text .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/ /g, " ") + // Unescape ampersands last so a decoded `&` can't form another entity. + .replace(/&/g, "&") .replace(/\s+/g, " "); } diff --git a/src/electron/agent/skill-registry.ts b/src/electron/agent/skill-registry.ts index 5c95ddd89..eb3104d96 100644 --- a/src/electron/agent/skill-registry.ts +++ b/src/electron/agent/skill-registry.ts @@ -323,9 +323,16 @@ export class SkillRegistry { */ private isStaticCatalog(): boolean { const url = this.registryUrl.toLowerCase(); + let hostname = ""; + try { + hostname = new URL(this.registryUrl).hostname.toLowerCase(); + } catch { + // not an absolute URL; fall back to path-suffix checks below + } return ( - url.includes("raw.githubusercontent.com") || - url.includes("github.io") || + hostname === "raw.githubusercontent.com" || + hostname === "github.io" || + hostname.endsWith(".github.io") || url.endsWith("/registry") || url.endsWith("/registry/") ); diff --git a/src/electron/agent/skills/document.ts b/src/electron/agent/skills/document.ts index a4f210d58..33ed14b16 100644 --- a/src/electron/agent/skills/document.ts +++ b/src/electron/agent/skills/document.ts @@ -1066,8 +1066,16 @@ export class DocumentBuilder { const _trRegex = /]*>([\s\S]*?)<\/tr>/gi; const _tdThRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; - // Helper to strip HTML tags - const stripTags = (str: string): string => str.replace(/<[^>]*>/g, "").trim(); + // Helper to strip HTML tags. Loop until stable so a partial tag can't leave + // a working tag behind after a single pass. + const stripTags = (str: string): string => { + let prev: string; + do { + prev = str; + str = str.replace(/<[^>]*>/g, ""); + } while (str !== prev); + return str.trim(); + }; // Process in order of appearance let _lastIndex = 0; diff --git a/src/electron/agent/tools/batch-image-tools.ts b/src/electron/agent/tools/batch-image-tools.ts index 09e1a85be..baf3a09c0 100644 --- a/src/electron/agent/tools/batch-image-tools.ts +++ b/src/electron/agent/tools/batch-image-tools.ts @@ -1,4 +1,4 @@ -import { exec, execFile } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import * as os from "os"; import * as path from "path"; @@ -10,7 +10,6 @@ import { LLMTool } from "../llm/types"; type Any = any; // oxlint-disable-line typescript-eslint(no-explicit-any) -const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const PROCESS_TIMEOUT_MS = 60_000; const PROTECTED_WRITE_PATHS = [ @@ -349,10 +348,14 @@ export class BatchImageTools { ], { timeout: PROCESS_TIMEOUT_MS }); } catch { // Fallback: try with `magick composite` (ImageMagick 7) - await execAsync( - `magick composite -dissolve ${opacity} -gravity ${gravity} ${JSON.stringify(wmPath)} ${JSON.stringify(inputPath)} ${JSON.stringify(outputPath)}`, - { timeout: PROCESS_TIMEOUT_MS }, - ); + await execFileAsync("magick", [ + "composite", + "-dissolve", String(opacity), + "-gravity", gravity, + wmPath, + inputPath, + outputPath, + ], { timeout: PROCESS_TIMEOUT_MS }); } } diff --git a/src/electron/agent/tools/browser-tools.ts b/src/electron/agent/tools/browser-tools.ts index 49076ad16..e3632b8f4 100644 --- a/src/electron/agent/tools/browser-tools.ts +++ b/src/electron/agent/tools/browser-tools.ts @@ -21,6 +21,15 @@ import { normalizeBrowserUrl } from "../../browser/browser-session-manager"; import { evaluateNetworkPolicy } from "../../security/network-policy"; import { BuiltinToolsSettingsManager } from "./builtin-settings"; +/** True if the URL's host is exactly the Google consent page (parsed, not substring-matched). */ +function isGoogleConsentUrl(url: string): boolean { + try { + return new URL(url).hostname.toLowerCase() === "consent.google.com"; + } catch { + return false; + } +} + // oxlint-disable-next-line typescript-eslint/no-explicit-any type Any = any; type BrowserProvider = "local" | "browser-use-cloud"; @@ -1532,7 +1541,7 @@ export class BrowserTools { this.getSessionId(input), ); const currentUrl = typeof current?.url === "string" ? current.url : ""; - if (!allow_consent && currentUrl.includes("consent.google.com")) { + if (!allow_consent && isGoogleConsentUrl(currentUrl)) { throw new Error("Consent page detected; dismiss consent before taking screenshot."); } if (Array.isArray(disallow_url_contains) && disallow_url_contains.length > 0) { @@ -1572,7 +1581,7 @@ export class BrowserTools { if (!allow_consent) { const currentUrl = await this.browserService.getCurrentUrl(); - if (currentUrl.includes("consent.google.com")) { + if (isGoogleConsentUrl(currentUrl)) { throw new Error("Consent page detected; dismiss consent before taking screenshot."); } } diff --git a/src/electron/agent/tools/canvas-tools.ts b/src/electron/agent/tools/canvas-tools.ts index abf16d707..bbd5be37c 100644 --- a/src/electron/agent/tools/canvas-tools.ts +++ b/src/electron/agent/tools/canvas-tools.ts @@ -306,17 +306,11 @@ export class CanvasTools { const value = String(ref || "").trim().toLowerCase(); if (!value) return true; if (value.startsWith("#")) return true; - if (value.startsWith("//")) return true; - if ( - value.startsWith("http://") || - value.startsWith("https://") || - value.startsWith("data:") || - value.startsWith("blob:") || - value.startsWith("about:") || - value.startsWith("javascript:") - ) { - return true; - } + if (value.startsWith("//")) return true; // protocol-relative URL + // Any explicit URI scheme (http:, https:, data:, blob:, about:, javascript:, + // vbscript:, filesystem:, etc.) means this is not a workspace-relative path we + // should resolve/inline. Detect the scheme robustly instead of enumerating prefixes. + if (/^[a-z][a-z0-9+.-]*:/.test(value)) return true; return false; } diff --git a/src/electron/agent/tools/gmail-tools.ts b/src/electron/agent/tools/gmail-tools.ts index 6650093f3..6af5dcdbd 100644 --- a/src/electron/agent/tools/gmail-tools.ts +++ b/src/electron/agent/tools/gmail-tools.ts @@ -118,16 +118,24 @@ function decodeBody(data?: string): string { } function stripHtml(html: string): string { - return html - .replace(//gi, " ") - .replace(//gi, " ") + // Remove style/script blocks with their content. Loop until stable + allow + // trailing whitespace in the closing tag so split tags can't survive and + // `` can't bypass the filter. + let text = html; + let prev: string; + do { + prev = text; + text = text.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, " "); + } while (text !== prev); + return text .replace(//gi, "\n") .replace(/<\/p>/gi, "\n") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") - .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") + // Unescape ampersands last so a decoded `&` can't form another entity. + .replace(/&/g, "&") .replace(/[ \t]+/g, " ") .replace(/\n\s+/g, "\n") .trim(); diff --git a/src/electron/agent/tools/web-fetch-tools.ts b/src/electron/agent/tools/web-fetch-tools.ts index 040c88042..376ec6946 100644 --- a/src/electron/agent/tools/web-fetch-tools.ts +++ b/src/electron/agent/tools/web-fetch-tools.ts @@ -507,26 +507,22 @@ export class WebFetchTools { } } - // Remove unwanted elements - targetHtml = targetHtml - // Remove script tags - .replace(/)<[^<]*)*<\/script>/gi, "") - // Remove style tags - .replace(/)<[^<]*)*<\/style>/gi, "") - // Remove noscript tags - .replace(/)<[^<]*)*<\/noscript>/gi, "") - // Remove nav elements - .replace(/)<[^<]*)*<\/nav>/gi, "") - // Remove footer elements - .replace(/)<[^<]*)*<\/footer>/gi, "") - // Remove header elements (but keep h1-h6) - .replace(/)<[^<]*)*<\/header>/gi, "") - // Remove aside elements - .replace(/)<[^<]*)*<\/aside>/gi, "") - // Remove HTML comments - .replace(//g, "") - // Remove SVG elements - .replace(/)<[^<]*)*<\/svg>/gi, ""); + // Remove unwanted elements together with their content. Loop until the + // string stops changing so split/nested tags (e.g. `ipt>`) + // can't survive one pass, and allow trailing whitespace in the closing tag + // (``) so the filter can't be bypassed. Header removal keeps + // h1-h6 since the backreference only matches ``. + let prevTargetHtml: string; + do { + prevTargetHtml = targetHtml; + targetHtml = targetHtml + .replace( + /<(script|style|noscript|nav|footer|header|aside|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, + "", + ) + // Remove HTML comments + .replace(//g, ""); + } while (targetHtml !== prevTargetHtml); // Convert HTML to markdown-like text let content = targetHtml @@ -578,8 +574,13 @@ export class WebFetchTools { content = content.replace(/]+src="([^"]*)"[^>]+alt="([^"]*)"[^>]*>/gi, "![$2]($1)"); content = content.replace(/]+src="([^"]*)"[^>]*>/gi, "![image]($1)"); - // Remove remaining HTML tags - content = content.replace(/<[^>]+>/g, ""); + // Remove remaining HTML tags. Loop until stable so a partially stripped + // tag can't leave a working tag behind after a single pass. + let prevTagContent: string; + do { + prevTagContent = content; + content = content.replace(/<[^>]+>/g, ""); + } while (content !== prevTagContent); // Decode HTML entities content = this.decodeHtmlEntities(content); diff --git a/src/electron/agent/tools/x-tools.ts b/src/electron/agent/tools/x-tools.ts index 24e58a017..77b6b9593 100644 --- a/src/electron/agent/tools/x-tools.ts +++ b/src/electron/agent/tools/x-tools.ts @@ -8,6 +8,17 @@ import { BrowserTools } from "./browser-tools"; import { buildXComposeScript, buildXToggleFollowScript } from "./x-browser-scripts"; import { notifyIntegrationAuthIssue } from "../../notifications/integration-auth"; +/** True if hostname is x.com/twitter.com or a subdomain thereof (exact-suffix, not substring). */ +function isXOrTwitterHost(hostname: string): boolean { + const h = hostname.toLowerCase(); + return ( + h === "x.com" || + h.endsWith(".x.com") || + h === "twitter.com" || + h.endsWith(".twitter.com") + ); +} + type XAction = | "whoami" | "read" @@ -178,7 +189,7 @@ export class XTools { try { const parsed = new URL(trimmed); const hostname = parsed.hostname.toLowerCase(); - if (!hostname.includes("x.com") && !hostname.includes("twitter.com")) { + if (!isXOrTwitterHost(hostname)) { return "https://x.com"; } @@ -1140,7 +1151,7 @@ export class XTools { try { const parsed = new URL(candidateWithScheme); const hostname = parsed.hostname.toLowerCase(); - if (!hostname.includes("x.com") && !hostname.includes("twitter.com")) { + if (!isXOrTwitterHost(hostname)) { return undefined; } diff --git a/src/electron/browser/browser-workbench-service.ts b/src/electron/browser/browser-workbench-service.ts index f523cc040..bc38736c2 100644 --- a/src/electron/browser/browser-workbench-service.ts +++ b/src/electron/browser/browser-workbench-service.ts @@ -103,7 +103,7 @@ function findElementActionScript(selector: string, action: string): string { (() => { const selector = ${JSON.stringify(selector)}; const el = ${compactTextScript(selector)}; - if (!el) return { success: false, error: "Element not found: ${selector.replace(/"/g, '\\"')}" }; + if (!el) return { success: false, error: ${JSON.stringify(`Element not found: ${selector}`)} }; el.scrollIntoView({ block: "center", inline: "center" }); ${action} })() diff --git a/src/electron/computer-use/window-isolation.ts b/src/electron/computer-use/window-isolation.ts index be3ea774d..4c15ad9b6 100644 --- a/src/electron/computer-use/window-isolation.ts +++ b/src/electron/computer-use/window-isolation.ts @@ -17,6 +17,12 @@ import * as os from "os"; const execAsync = promisify(exec); const TIMEOUT_MS = 10_000; +// Escape a value for embedding in an AppleScript double-quoted string. +// Backslash first, then the quote, so pre-existing backslashes can't unescape the quote. +function escapeAppleScriptString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + export interface WindowIsolationOptions { /** * When false (default for CUA sessions), do not force CoWork/Electron to stay visible — @@ -71,7 +77,7 @@ export class WindowIsolation { if (!approved.has(app.toLowerCase())) { try { await execAsync( - `osascript -e 'tell application "System Events" to set visible of process "${app.replace(/"/g, '\\"')}" to false'`, + `osascript -e 'tell application "System Events" to set visible of process "${escapeAppleScriptString(app)}" to false'`, { timeout: TIMEOUT_MS }, ); this.hiddenApps.push(app); @@ -96,7 +102,7 @@ export class WindowIsolation { for (const app of this.hiddenApps) { try { await execAsync( - `osascript -e 'tell application "System Events" to set visible of process "${app.replace(/"/g, '\\"')}" to true'`, + `osascript -e 'tell application "System Events" to set visible of process "${escapeAppleScriptString(app)}" to true'`, { timeout: TIMEOUT_MS }, ); } catch { diff --git a/src/electron/extensions/pack-registry.ts b/src/electron/extensions/pack-registry.ts index d6c8d5020..8f1f52ee0 100644 --- a/src/electron/extensions/pack-registry.ts +++ b/src/electron/extensions/pack-registry.ts @@ -88,9 +88,16 @@ export class PackRegistry { */ private isStaticCatalog(): boolean { const url = this.registryUrl.toLowerCase(); + let hostname = ""; + try { + hostname = new URL(this.registryUrl).hostname.toLowerCase(); + } catch { + // not an absolute URL; fall back to path-suffix checks below + } return ( - url.includes("raw.githubusercontent.com") || - url.includes("github.io") || + hostname === "raw.githubusercontent.com" || + hostname === "github.io" || + hostname.endsWith(".github.io") || url.endsWith("/registry") || url.endsWith("/registry/") ); diff --git a/src/electron/gateway/channels/email-client.ts b/src/electron/gateway/channels/email-client.ts index f2c447737..6c901f9c4 100644 --- a/src/electron/gateway/channels/email-client.ts +++ b/src/electron/gateway/channels/email-client.ts @@ -183,19 +183,20 @@ function splitMultipartBody(body: string, boundary: string): string[] { function stripHtml(html: string): string { return html - .replace(//gi, " ") - .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") .replace(//gi, "\n") .replace(/<\/p>/gi, "\n\n") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + // Decode & last so `&lt;` yields the literal `<`, not `<`. + .replace(/&/gi, "&") .replace(/\s+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]{2,}/g, " ") diff --git a/src/electron/gateway/channels/email.ts b/src/electron/gateway/channels/email.ts index 1fa45701c..e1c9e0b4b 100644 --- a/src/electron/gateway/channels/email.ts +++ b/src/electron/gateway/channels/email.ts @@ -525,14 +525,20 @@ export class EmailAdapter implements ChannelAdapter { let text = email.text || ""; if (!text && email.html) { // Basic HTML to text conversion - text = email.html - .replace(//gi, "\n") - .replace(/<\/p>/gi, "\n\n") - .replace(/<[^>]+>/g, "") + let html = email.html.replace(//gi, "\n").replace(/<\/p>/gi, "\n\n"); + // Strip remaining tags; loop until stable so a partial tag can't leave a + // working tag behind after a single pass. + let prev: string; + do { + prev = html; + html = html.replace(/<[^>]+>/g, ""); + } while (html !== prev); + text = html .replace(/ /g, " ") - .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") + // Unescape ampersands last so a decoded `&` can't form another entity. + .replace(/&/g, "&") .trim(); } diff --git a/src/electron/gateway/security.ts b/src/electron/gateway/security.ts index 809deca8c..46d43c151 100644 --- a/src/electron/gateway/security.ts +++ b/src/electron/gateway/security.ts @@ -422,9 +422,8 @@ export class SecurityManager { // Generate 6-character alphanumeric code const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude similar chars (I, O, 1, 0) let code = ""; - const randomBytes = crypto.randomBytes(6); for (let i = 0; i < 6; i++) { - code += chars[randomBytes[i] % chars.length]; + code += chars[crypto.randomInt(chars.length)]; } return code; } diff --git a/src/electron/hooks/server.ts b/src/electron/hooks/server.ts index 56a28e45c..99393c07d 100644 --- a/src/electron/hooks/server.ts +++ b/src/electron/hooks/server.ts @@ -349,7 +349,7 @@ export class HooksServer { this.sendJsonResponse(res, 200, { success: true }); } catch (error) { log.error("Wake handler error:", error); - this.sendJsonResponse(res, 500, { success: false, error: String(error) }); + this.sendJsonResponse(res, 500, { success: false, error: "Internal server error" }); } } else { this.sendJsonResponse(res, 503, { success: false, error: "Wake handler not configured" }); @@ -398,7 +398,7 @@ export class HooksServer { this.sendJsonResponse(res, result.statusCode ?? 202, result.body ?? { success: true, taskId: result.taskId }); } catch (error) { log.error("Agent handler error:", error); - this.sendJsonResponse(res, 500, { success: false, error: String(error) }); + this.sendJsonResponse(res, 500, { success: false, error: "Internal server error" }); } } else { this.sendJsonResponse(res, 503, { success: false, error: "Agent handler not configured" }); @@ -453,7 +453,7 @@ export class HooksServer { ? (error as Error & { statusCode: number }).statusCode : 500; log.error("Task message handler error:", error); - this.sendJsonResponse(res, statusCode, { success: false, error: String(error) }); + this.sendJsonResponse(res, statusCode, { success: false, error: "Internal server error" }); } } @@ -495,7 +495,7 @@ export class HooksServer { this.sendJsonResponse(res, httpStatus, { success: true, status }); } catch (error) { log.error("Approval respond handler error:", error); - this.sendJsonResponse(res, 500, { success: false, error: String(error) }); + this.sendJsonResponse(res, 500, { success: false, error: "Internal server error" }); } } @@ -595,7 +595,7 @@ export class HooksServer { this.sendJsonResponse(res, 200, { success: true }); } catch (error) { log.error("Wake handler error:", error); - this.sendJsonResponse(res, 500, { success: false, error: String(error) }); + this.sendJsonResponse(res, 500, { success: false, error: "Internal server error" }); } } else { this.sendJsonResponse(res, 503, { success: false, error: "Wake handler not configured" }); @@ -631,7 +631,7 @@ export class HooksServer { ); } catch (error) { log.error("Agent handler error:", error); - this.sendJsonResponse(res, 500, { success: false, error: String(error) }); + this.sendJsonResponse(res, 500, { success: false, error: "Internal server error" }); } } else { this.sendJsonResponse(res, 503, { success: false, error: "Agent handler not configured" }); @@ -661,7 +661,7 @@ export class HooksServer { ? (error as Error & { statusCode: number }).statusCode : 500; log.error("Task message handler error:", error); - this.sendJsonResponse(res, statusCode, { success: false, error: String(error) }); + this.sendJsonResponse(res, statusCode, { success: false, error: "Internal server error" }); } } else { this.sendJsonResponse(res, 503, { diff --git a/src/electron/mailbox/MailboxService.ts b/src/electron/mailbox/MailboxService.ts index fed8ea7f8..5a491ed68 100644 --- a/src/electron/mailbox/MailboxService.ts +++ b/src/electron/mailbox/MailboxService.ts @@ -977,19 +977,20 @@ function buildMailboxEventFingerprint(type: MailboxEventType, workspaceId: strin function stripHtml(html: string): string { return html - .replace(//gi, " ") - .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") .replace(//gi, "\n") .replace(/<\/p>/gi, "\n\n") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + // Decode & last so `&lt;` yields the literal `<`, not `<`. + .replace(/&/gi, "&") .replace(/\s+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]{2,}/g, " ") @@ -3518,7 +3519,7 @@ export class MailboxService { path: "/me/messages", scopes: MICROSOFT_GRAPH_READWRITE_SCOPES, query: { - $search: `"${searchQuery.replace(/"/g, '\\"')}"`, + $search: `"${searchQuery.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`, $top: String(Math.min(Math.max(limit, 5), 10)), $select: MICROSOFT_GRAPH_MESSAGE_SELECT, }, diff --git a/src/electron/memory/DurableContextService.ts b/src/electron/memory/DurableContextService.ts index e09411ad6..b8d605686 100644 --- a/src/electron/memory/DurableContextService.ts +++ b/src/electron/memory/DurableContextService.ts @@ -70,7 +70,7 @@ function normalizeText(text: string): string { } function toSqlLikePattern(query: string): string { - const compact = query.trim().replace(/[%_]/g, "\\$&"); + const compact = query.trim().replace(/[\\%_]/g, "\\$&"); return `%${compact}%`; } diff --git a/src/electron/tunnels/relay.ts b/src/electron/tunnels/relay.ts index 861167934..db70346f8 100644 --- a/src/electron/tunnels/relay.ts +++ b/src/electron/tunnels/relay.ts @@ -321,7 +321,7 @@ function isStatusAuthorized( function readBearer(value: string | string[] | undefined): string | undefined { const raw = Array.isArray(value) ? value[0] : value; - const match = raw?.match(/^Bearer\s+(.+)$/i); + const match = raw?.match(/^Bearer\s+(\S.*)$/i); return match?.[1]; } diff --git a/src/electron/utils/json-utils.ts b/src/electron/utils/json-utils.ts index 45ea642e0..808b29a9f 100644 --- a/src/electron/utils/json-utils.ts +++ b/src/electron/utils/json-utils.ts @@ -172,7 +172,10 @@ function repairJsonString(text: string): string { s = s.replace(/,(\s*[}\]])/g, "$1"); s = s.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3'); s = s.replace(/:\s*'([^'\\]*(?:\\.[^'\\]*)*)'\s*([,}])/g, (_m, inner, tail) => { - const escaped = String(inner).replace(/\\"/g, '"').replace(/"/g, '\\"'); + const escaped = String(inner) + .replace(/\\'/g, "'") // \' is a literal ' inside the single-quoted source; invalid in JSON + .replace(/\\"/g, '"') + .replace(/"/g, '\\"'); return `: "${escaped}"${tail}`; }); diff --git a/src/electron/utils/pptx-extractor.ts b/src/electron/utils/pptx-extractor.ts index a8edc1d0d..6c998733e 100644 --- a/src/electron/utils/pptx-extractor.ts +++ b/src/electron/utils/pptx-extractor.ts @@ -168,12 +168,13 @@ function decodePptxXmlText(value: string): string { return value .replace(/</g, "<") .replace(/>/g, ">") - .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " ") .replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => String.fromCodePoint(parseInt(hex, 16))) .replace(/&#(\d+);/g, (_match, dec) => String.fromCodePoint(Number(dec))) + // & decoded last so "&lt;" stays "<" (no double-unescape) + .replace(/&/g, "&") .trim(); } @@ -335,16 +336,24 @@ function extractTextFromPptxXml(xml: string): string { if (!xml) return ""; const withLineBreaks = xml.replace(/<(?:\w+:)?br\b[^>]*\/?>/g, "\n"); - return withLineBreaks - .replace(/<[^>]*>/g, "") + // Loop-strip tags so stripping can't re-form a new "<...>" from adjacent fragments. + let withoutTags = withLineBreaks; + let previous: string; + do { + previous = withoutTags; + withoutTags = withoutTags.replace(/<[^>]*>/g, ""); + } while (withoutTags !== previous); + + return withoutTags .replace(/</g, "<") .replace(/>/g, ">") - .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " ") .replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => String.fromCodePoint(parseInt(hex, 16))) .replace(/&#(\d+);/g, (_match, dec) => String.fromCodePoint(Number(dec))) + // & decoded last so "&lt;" stays "<" (no double-unescape) + .replace(/&/g, "&") .replace(/\u000B/g, "") .replace(/\u000C/g, "") .replace(/\r\n/g, "\n") @@ -499,7 +508,7 @@ function expandPptxTableRows( } function escapePptxTableCell(value: string): string { - return value.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim(); + return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n+/g, " ").trim(); } function extractPptxContentFromXml( diff --git a/src/electron/utils/webhook-auth.ts b/src/electron/utils/webhook-auth.ts index ee6edb1fd..46d8d5d83 100644 --- a/src/electron/utils/webhook-auth.ts +++ b/src/electron/utils/webhook-auth.ts @@ -26,7 +26,7 @@ export function timingSafeEqualString(actual: string, expected: string): boolean export function readBearerToken(header: string | string[] | undefined): string | null { const value = Array.isArray(header) ? header[0] : header; if (!value) return null; - const match = value.match(/^Bearer\s+(.+)$/i); + const match = value.match(/^Bearer\s+(\S.*)$/i); return match?.[1]?.trim() || null; } diff --git a/src/renderer/components/AssistantMessageContent.tsx b/src/renderer/components/AssistantMessageContent.tsx index 70c6e0e9a..119e8ee1f 100644 --- a/src/renderer/components/AssistantMessageContent.tsx +++ b/src/renderer/components/AssistantMessageContent.tsx @@ -173,9 +173,9 @@ function decodeHtmlAttrValue(value: string): string { .replace(/"/gi, "\"") .replace(/'/g, "'") .replace(/'/gi, "'") - .replace(/&/gi, "&") .replace(/</gi, "<") - .replace(/>/gi, ">"); + .replace(/>/gi, ">") + .replace(/&/gi, "&"); // last, so "&lt;" stays "<" (no double-unescape) } function normalizeFrameKind(value: unknown): string | undefined { diff --git a/src/renderer/components/BrowserView.tsx b/src/renderer/components/BrowserView.tsx index 168692e29..7d5566ac3 100644 --- a/src/renderer/components/BrowserView.tsx +++ b/src/renderer/components/BrowserView.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from "react"; +import { safeWebviewSrc } from "../utils/safe-webview-src"; + const webviewPopupProps = { allowpopups: "true" } as Any; interface BrowserViewProps { @@ -153,7 +155,7 @@ export function BrowserView({ initialUrl, onBack }: BrowserViewProps) { {activeUrl ? ( /gi, "\n") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") + .replace(/&/gi, "&") // last, so "&lt;" stays "<" (no double-unescape) .replace(/\s+/g, " ") .trim(); } diff --git a/src/renderer/components/MainContent/markdown-normalization.ts b/src/renderer/components/MainContent/markdown-normalization.ts index 2a0dc3b38..d9bb91205 100644 --- a/src/renderer/components/MainContent/markdown-normalization.ts +++ b/src/renderer/components/MainContent/markdown-normalization.ts @@ -264,9 +264,8 @@ export function normalizeTimelineTitleMarkdownForDisplay(text: string): string { // Escape only single # so shell comments like "# route check" are not rendered // as

. Allow ##, ###, etc. to render as headings. return normalized.replace( - /^( {0,3})(#)(?=\s)/gm, - (_match: string, indent: string, hash: string) => - `${indent}${hash.replace(/#/g, "\\#")}`, + /^( {0,3})#(?=\s)/gm, + (_match: string, indent: string) => `${indent}\\#`, ); } diff --git a/src/renderer/components/WebArtifactViewer.tsx b/src/renderer/components/WebArtifactViewer.tsx index ead1b69d0..8a44e250c 100644 --- a/src/renderer/components/WebArtifactViewer.tsx +++ b/src/renderer/components/WebArtifactViewer.tsx @@ -94,8 +94,8 @@ function isImageAttachment(attachment: PendingWebAttachment): boolean { function textFromHtml(htmlContent: string): string { return htmlContent - .replace(/)<[^<]*)*<\/script>/gi, " ") - .replace(/)<[^<]*)*<\/style>/gi, " ") + .replace(/)<[^<]*)*<\/script\s*>/gi, " ") + .replace(/)<[^<]*)*<\/style\s*>/gi, " ") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); diff --git a/src/renderer/components/utils/attachment-content.ts b/src/renderer/components/utils/attachment-content.ts index 070718ea3..ebe1a8d84 100644 --- a/src/renderer/components/utils/attachment-content.ts +++ b/src/renderer/components/utils/attachment-content.ts @@ -26,16 +26,20 @@ const shouldRequestImageOcr = (prompt: string, fileName: string): boolean => { return OCR_REQUEST_PATTERNS.some((pattern) => pattern.test(combined)); }; -const stripHtmlForText = (value: string): string => - value +const stripHtmlForText = (value: string): string => { + let text = value .replace(//gi, "\n") .replace(/<\/p>/gi, "\n") .replace(/<\/li>/gi, "\n") - .replace(/<\/h[1-6]>/gi, "\n") - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/\u00a0/g, " ") - .trim(); + .replace(/<\/h[1-6]>/gi, "\n"); + // Loop-strip tags so removal can't re-form a new "<...>" from adjacent fragments. + let previous: string; + do { + previous = text; + text = text.replace(/<[^>]*>/g, ""); + } while (text !== previous); + return text.replace(/ /g, " ").replace(/\u00a0/g, " ").trim(); +}; const truncateTextForTaskPrompt = (value: string): string => { if (value.length <= MAX_EXTRACTED_ATTACHMENT_CHARS) return value.trim(); diff --git a/src/renderer/utils/__tests__/email-html-sanitize.test.ts b/src/renderer/utils/__tests__/email-html-sanitize.test.ts index 6554d64fb..6f9d35333 100644 --- a/src/renderer/utils/__tests__/email-html-sanitize.test.ts +++ b/src/renderer/utils/__tests__/email-html-sanitize.test.ts @@ -42,6 +42,12 @@ describe("sanitizeEmailHtml", () => { expect(result).toContain('src="https://example.com/image.png"'); }); + it("strips nested/broken script tags that a single pass would reconstitute", () => { + expect(sanitizeEmailHtml("ipt>alert(1)")).not.toContain("alert(1)")).not.toContain("alert"); + expect(sanitizeEmailHtml("")).not.toContain(" { expect(normalizeEmailExternalWebUrl(" https://example.com/unsubscribe ")).toBe( "https://example.com/unsubscribe", diff --git a/src/renderer/utils/__tests__/safe-webview-src.test.ts b/src/renderer/utils/__tests__/safe-webview-src.test.ts new file mode 100644 index 000000000..7fbab354a --- /dev/null +++ b/src/renderer/utils/__tests__/safe-webview-src.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { safeWebviewSrc } from "../safe-webview-src"; + +describe("safeWebviewSrc", () => { + it("passes through navigational URLs and app schemes", () => { + expect(safeWebviewSrc("https://example.com/page")).toBe("https://example.com/page"); + expect(safeWebviewSrc("http://localhost:3000")).toBe("http://localhost:3000"); + expect(safeWebviewSrc("canvas://doc/1")).toBe("canvas://doc/1"); + expect(safeWebviewSrc("about:blank")).toBe("about:blank"); + expect(safeWebviewSrc("")).toBe(""); + }); + + it("blocks script-capable schemes", () => { + expect(safeWebviewSrc("javascript:alert(1)")).toBe("about:blank"); + expect(safeWebviewSrc(" JavaScript:alert(1)")).toBe("about:blank"); + expect(safeWebviewSrc("data:text/html,")).toBe("about:blank"); + expect(safeWebviewSrc("vbscript:msgbox(1)")).toBe("about:blank"); + expect(safeWebviewSrc("blob:https://x/1")).toBe("about:blank"); + }); +}); diff --git a/src/renderer/utils/email-html-sanitize.ts b/src/renderer/utils/email-html-sanitize.ts index 8a66d518d..afda9ada2 100644 --- a/src/renderer/utils/email-html-sanitize.ts +++ b/src/renderer/utils/email-html-sanitize.ts @@ -22,23 +22,35 @@ export function normalizeEmailExternalWebUrl(rawHref: string | null | undefined) } export function sanitizeEmailHtml(raw: string): string { - return raw - // Remove executable and embeddable content. - .replace(//gi, "") - .replace(/]*(?:\/>|>)/gi, "") - .replace(/<(?:iframe|object|embed)\b[\s\S]*?<\/(?:iframe|object|embed)>/gi, "") - .replace(/<(?:base|link)\b[^>]*>/gi, "") - // The host document supplies its own metadata; email meta tags can trigger - // noisy viewport/CSP parser warnings when injected into srcdoc. - .replace(/]*>/gi, "") - // Remove inline event handlers so the sandbox does not log blocked script - // execution for attributes such as onload/onclick. - .replace(/\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") - // Neutralize javascript: URLs in navigational/resource attributes. - .replace( - /\s+(href|src|xlink:href|action|formaction)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|\s*javascript:[^\s>]+)/gi, - "", - ) + // Removals run in a loop until the output stabilizes. A single pass is unsafe: + // stripping the inner tag from `ipt>` (or a nested handler) can + // reconstitute a live tag, which is the "incomplete multi-character + // sanitization" class. Closing tags allow trailing whitespace (``) + // so the "bad HTML filtering" bypass does not apply. + let html = raw; + let previous: string; + do { + previous = html; + html = html + // Remove executable and embeddable content. + .replace(//gi, "") + .replace(/]*>/gi, "") + .replace(/<(iframe|object|embed)\b[\s\S]*?<\/\1\s*>/gi, "") + .replace(/<(?:base|link)\b[^>]*>/gi, "") + // The host document supplies its own metadata; email meta tags can trigger + // noisy viewport/CSP parser warnings when injected into srcdoc. + .replace(/]*>/gi, "") + // Remove inline event handlers so the sandbox does not log blocked script + // execution for attributes such as onload/onclick. + .replace(/\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") + // Neutralize javascript: URLs in navigational/resource attributes. + .replace( + /\s+(href|src|xlink:href|action|formaction)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*'|\s*javascript:[^\s>]+)/gi, + "", + ); + } while (html !== previous); + + return html // Remote font CSS is blocked by the app CSP; remove it before Chromium logs // one violation per font face. .replace(/@import\s+(?:url\([^)]*\)|["'][^"']*["'])[^;]*;?/gi, "") diff --git a/src/renderer/utils/markdown-autolink.ts b/src/renderer/utils/markdown-autolink.ts index c042e0fb7..5b9046184 100644 --- a/src/renderer/utils/markdown-autolink.ts +++ b/src/renderer/utils/markdown-autolink.ts @@ -8,7 +8,7 @@ const BARE_DOMAIN_REGEX = const BARE_URL_REGEX = /(?. A webview navigates like a browser, + * so http(s) and app schemes (canvas://, about:) must pass through untouched — + * but script-capable schemes reaching `src` are an XSS/DOM-text-as-HTML sink. + * Those are replaced with a blank page instead of being executed. + */ +const UNSAFE_WEBVIEW_SCHEMES = new Set(["javascript", "data", "vbscript", "blob"]); + +export function safeWebviewSrc(rawUrl: string | null | undefined): string { + const value = (rawUrl || "").trim(); + if (!value) return ""; + const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase(); + if (scheme && UNSAFE_WEBVIEW_SCHEMES.has(scheme)) return "about:blank"; + return value; +} diff --git a/src/shared/tool-call-text-sanitizer.ts b/src/shared/tool-call-text-sanitizer.ts index 74a0182fe..3a7e2fc43 100644 --- a/src/shared/tool-call-text-sanitizer.ts +++ b/src/shared/tool-call-text-sanitizer.ts @@ -61,7 +61,7 @@ const PLAIN_TOOL_TRANSCRIPT_MARKERS = [ const INLINE_TOOL_JSON_PATTERNS: RegExp[] = [ /\{\s*"id"\s*:\s*"call_[^"]+"\s*,\s*"tool"\s*:\s*"[^"]+"\s*,\s*"input"\s*:\s*\{[\s\S]*?\}\s*\}/gi, - /\{\s*"tool_name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*"(?:\\.|[^"])*"\s*\}/gi, + /\{\s*"tool_name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*"(?:\\.|[^"\\])*"\s*\}/gi, ]; function looksLikePlainToolTranscript(input: string): boolean {