Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/desktop/src/browser/embeddedBrowserSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
},
);
});
92 changes: 92 additions & 0 deletions apps/desktop/src/browser/embeddedBrowserSecurity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
30 changes: 30 additions & 0 deletions docs/embedded-browser-security-helpers.md
Original file line number Diff line number Diff line change
@@ -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.
Loading