diff --git a/apps/desktop/src/browser/embeddedBrowserSecurity.test.ts b/apps/desktop/src/browser/embeddedBrowserSecurity.test.ts new file mode 100644 index 00000000..94a5156d --- /dev/null +++ b/apps/desktop/src/browser/embeddedBrowserSecurity.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { + canTypeSensitiveValue, + embeddedBrowserDisplayUrl, + normalizeEmbeddedBrowserUrl, + redactEmbeddedBrowserText, +} from "./embeddedBrowserSecurity.ts"; + +describe("embedded browser address policy", () => { + it.each([ + [" example.test/path ", "https://example.test/path"], + [ + "HTTPS://EXAMPLE.TEST:443/path?q=visible#section", + "https://example.test/path?q=visible#section", + ], + ["http://localhost:3000", "http://localhost:3000/"], + ["about:blank", "about:blank"], + ])("normalizes %s", (input, expected) => { + expect(normalizeEmbeddedBrowserUrl(input)).toBe(expected); + }); + + it.each([ + "", + " ", + "https://", + "https://example.test:99999", + "https://user:synthetic@example.test/", + "https://user@example.test/", + "https://example.test@other.test/", + "javascript:alert(1)", + "data:text/html,synthetic", + "file:///tmp/example.html", + "ftp://example.test/", + "about:config", + ])("rejects invalid, credential-bearing, or unsupported addresses: %s", (input) => { + expect(normalizeEmbeddedBrowserUrl(input)).toBeNull(); + }); + + it("removes credentials, query, and fragment from a display URL", () => { + expect( + embeddedBrowserDisplayUrl("https://reader:synthetic@example.test/path?key=example#fragment"), + ).toBe("https://example.test/path"); + }); + + it.each(["", "not a URL", "about:blank", "javascript:synthetic", "data:text/plain,synthetic"])( + "uses a blank display for unsupported input: %s", + (input) => { + expect(embeddedBrowserDisplayUrl(input)).toBe("about:blank"); + }, + ); + + it("keeps encoded path components intact; display is not whole-URL secret redaction", () => { + expect(embeddedBrowserDisplayUrl("https://example.test/a%2Fb?q=example#part")).toBe( + "https://example.test/a%2Fb", + ); + }); +}); + +describe("sensitive entry transport eligibility", () => { + it.each([ + "https://example.test/login", + "http://localhost:3000/login", + "http://LOCALHOST/login", + "http://127.0.0.1/login", + "http://127.255.255.254/login", + "http://[::1]:3000/login", + "http://127.1/login", + ])("permits HTTPS or canonical loopback HTTP: %s", (input) => { + expect(canTypeSensitiveValue(input)).toBe(true); + }); + + it.each([ + "http://example.test/login", + "http://localhost.example.test/login", + "http://127.0.0.1.example.test/login", + "http://127.0.0.256/login", + "http://192.168.1.1/login", + "http://[::2]/login", + "http://[::ffff:127.0.0.1]/login", + "https://reader:synthetic@example.test/login", + "http://reader@localhost/login", + "file:///tmp/example.html", + "about:blank", + "not a URL", + ])("refuses unsupported transport and loopback lookalikes: %s", (input) => { + expect(canTypeSensitiveValue(input)).toBe(false); + }); +}); + +describe("bounded heuristic page-text redaction", () => { + it("preserves ordinary English and Japanese text", () => { + const text = "Welcome to the project. プロジェクトへようこそ。"; + expect(redactEmbeddedBrowserText(text, 100)).toBe(text); + }); + + it("redacts synthetic token shapes, labelled secrets, and numeric codes", () => { + const token = `${"a".repeat(24)}.${"b".repeat(12)}.${"c".repeat(12)}`; + const text = `Bearer ${token}\napi-${"d".repeat(16)}\npassword=synthetic\n123456`; + expect(redactEmbeddedBrowserText(text, 300)).toBe( + "[redacted token]\n[redacted token]\npassword=[redacted secret]\n[redacted numeric code]", + ); + }); + + it.each(["passwd", "pwd", "client-secret", "recovery-code"])( + "redacts the synthetic value of %s", + (label) => { + expect(redactEmbeddedBrowserText(`${label}: synthetic-example`, 100)).toBe( + `${label}: [redacted secret]`, + ); + }, + ); + + it("redacts before clipping, including a token that crosses the output limit", () => { + expect(redactEmbeddedBrowserText(`api-${"x".repeat(20)}`, 8)).toBe("[redacte"); + }); + + it("redacts the whole labelled secret even when its value is short or exceeds 128 characters", () => { + for (const value of ["x", "x".repeat(129), "x".repeat(2_000)]) { + expect(redactEmbeddedBrowserText(`password=${value}\nNext line`, 3_000)).toBe( + "password=[redacted secret]\nNext line", + ); + } + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "returns no text for an invalid or empty output limit: %s", + (limit) => { + expect(redactEmbeddedBrowserText("ordinary content", limit)).toBe(""); + }, + ); +}); diff --git a/apps/desktop/src/browser/embeddedBrowserSecurity.ts b/apps/desktop/src/browser/embeddedBrowserSecurity.ts new file mode 100644 index 00000000..8b0340dc --- /dev/null +++ b/apps/desktop/src/browser/embeddedBrowserSecurity.ts @@ -0,0 +1,92 @@ +const BLANK_URL = "about:blank"; + +function isAllowedRemoteUrl(rawUrl: string): boolean { + if (rawUrl === BLANK_URL) return true; + try { + const url = new URL(rawUrl); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.username === "" && + url.password === "" + ); + } catch { + return false; + } +} + +/** Normalize address input before navigation. This does not authorize an origin or request. */ +export function normalizeEmbeddedBrowserUrl(rawUrl: string): string | null { + const candidate = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawUrl.trim()) + ? rawUrl.trim() + : `https://${rawUrl.trim()}`; + if (!isAllowedRemoteUrl(candidate) || candidate === BLANK_URL) { + return candidate === BLANK_URL ? BLANK_URL : null; + } + return new URL(candidate).href; +} + +/** For display only: paths remain visible and may contain sensitive site-specific data. */ +export function embeddedBrowserDisplayUrl(rawUrl: string): string { + if (rawUrl === BLANK_URL || rawUrl.length === 0) return BLANK_URL; + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return BLANK_URL; + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.href; + } catch { + return BLANK_URL; + } +} + +/** Heuristic redaction is not a guarantee that arbitrary page text contains no secrets. */ +export function redactEmbeddedBrowserText(rawText: string, maxLength: number): string { + // Invalid limits fail closed. Redact before clipping so a truncated token is not exposed. + if (!Number.isSafeInteger(maxLength) || maxLength <= 0) return ""; + return rawText + .replace( + /\b(?:bearer\s+)?[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{12,}(?:\.[A-Za-z0-9_-]{12,})?\b/gi, + "[redacted token]", + ) + .replace( + /\b(?:AKIA[A-Z0-9]{16}|(?:sk|api|key|token)[-_][A-Za-z0-9_-]{12,})\b/gi, + "[redacted token]", + ) + .replace( + /\b(password|passwd|pwd|client[- ]secret|recovery[- ]code)(\s*[:=]\s*)\S+/gi, + "$1$2[redacted secret]", + ) + .replace(/\b\d{4,8}\b/g, "[redacted numeric code]") + .replace( + /((?:verification|security|one[- ]time|otp|2fa|passcode)[^\r\n]{0,32})\b[A-Z0-9-]{4,16}\b/gi, + "$1[redacted code]", + ) + .slice(0, maxLength); +} + +function isIpv4LoopbackHostname(hostname: string): boolean { + const parts = hostname.split("."); + return ( + parts.length === 4 && + parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && + Number(parts[0]) === 127 + ); +} + +/** Transport eligibility only. The caller must still require explicit user authorization. */ +export function canTypeSensitiveValue(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + if (url.username !== "" || url.password !== "") return false; + if (url.protocol === "https:") return true; + const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + url.protocol === "http:" && + (hostname === "localhost" || hostname === "::1" || isIpv4LoopbackHostname(hostname)) + ); + } catch { + return false; + } +} diff --git a/docs/embedded-browser-security-helpers.md b/docs/embedded-browser-security-helpers.md new file mode 100644 index 00000000..c248c084 --- /dev/null +++ b/docs/embedded-browser-security-helpers.md @@ -0,0 +1,30 @@ +# Embedded browser security helpers + +This foundation contains four pure functions in +`apps/desktop/src/browser/embeddedBrowserSecurity.ts`. It has no Electron imports, +network access, storage, or application integration. It does not enable an embedded +browser or grant an agent access to a page. + +- `normalizeEmbeddedBrowserUrl` adds HTTPS to an address without a scheme and + accepts only HTTP, HTTPS, or `about:blank`. URL credentials are rejected. +- `embeddedBrowserDisplayUrl` removes credentials, query data, and fragments from + HTTP/HTTPS URLs. Paths remain visible; use an origin alone when paths must not + enter model context or diagnostics. +- `redactEmbeddedBrowserText` replaces common synthetic-token shapes, labelled + secrets, and likely one-time codes before clipping the output. This heuristic + can omit ordinary numbers and cannot identify every secret. Callers must bound + input text before this function; an output limit is not an input memory limit. +- `canTypeSensitiveValue` checks transport eligibility: HTTPS or loopback HTTP, + without URL credentials. It does not verify a certificate, resolve DNS, or grant + permission to enter a value. It is not a network-request or SSRF allowlist. + +Future browser integration must separately enforce isolated sessions, exact +renderer ownership, explicit origin sharing, fresh snapshot targets, revocation, +and operator-only credential entry. These helpers cannot replace those controls. + +The functions originate in Club Code's desktop browser. This extraction also +rejects unsupported display schemes, rejects credential-bearing sensitive-entry +URLs independently, and makes invalid text limits return no content. The tests +cover these boundaries without loading Electron or reading real credentials. +Recognized labelled secrets are removed in full, including values longer than +128 characters; the redactor does not leave a matching value's suffix visible.