Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/standards/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ These were raised in review, judged valid, and accepted on purpose. Changing the
- The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)).
- Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)).
- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#per-pr-previews) describes the current setup. This one has no recorded decision yet.
- `import-source` checks every resolved A and AAAA address against the IANA special-purpose ranges before each fetch, but the Edge runtime cannot pin the connection to the checked address. A hostname whose DNS answer changes between the check and the connection can still reach a blocked range. Citation verification avoids the same gap by not fetching at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closing it needs an egress proxy outside the runtime. Raised as finding [7] of the 2026-08-19 security review; this needs Adam's decision.

## Telemetry and error reports

Expand Down
8 changes: 3 additions & 5 deletions src/test/security-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,9 @@ describe('security hardening regressions', () => {
expect(promo).toContain('userClient.rpc("redeem_promo_code_atomic", { p_code: code })');
});

it('resolves DNS and bounds importer bodies before processing', () => {
expect(importer).toContain('validateResolvedPublicHost(currentUrl)');
expect(importer).toContain('readBoundedText(response)');
expect(read('supabase/functions/import-source/resource-limits.ts')).toContain('MAX_RESPONSE_BYTES');
});
// Importer DNS checks, body caps and the deadline are covered by behavior in
// import-source/url-validation.test.ts, import-source/deadline_test.ts and
// _shared/bounded-body.test.ts.

it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => {
expect(modelCatalog).toContain('id: "z-ai/glm-5.3-flash"');
Expand Down
4 changes: 2 additions & 2 deletions supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh

### Content and sharing

- `import-source`: authenticated public-URL import with SSRF protection on redirects, timeout, content-type and size limits, readable-text extraction, quota checks, and logging.
- `share`: authenticated creation of expiring share codes for supported saved content.
- `import-source`: authenticated public-URL import with SSRF protection (resolved addresses checked against the IANA special-purpose ranges on every redirect), one 10-second deadline covering DNS, redirects and the body, content-type and 1 MB size limits, readable-text extraction, a daily quota, and logging.
- `share`: authenticated creation of share codes for supported saved content, with a 512 KB payload cap, expiry from 1 to 90 days (30 when omitted), and a daily quota of 50 per user.
- `workspace-invites`: part of the hidden Workspace. Authenticated workspace code and email invitation creation, rotation, revocation, and redemption. The handler keeps raw invite values and email addresses out of database calls and uses service-role-only transactional RPCs for limits and membership changes.
- `export-gdoc`: authenticated Google Docs export using a short-lived Google access token. It rejects refresh tokens.

Expand Down
26 changes: 26 additions & 0 deletions supabase/functions/_shared/bounded-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19";
import { BodyTooLargeError, readBoundedBody } from "./bounded-body.ts";

// A streamed body with no Content-Length, sent in 1000-byte chunks.
function streamOf(bytes: number, headers: HeadersInit = {}) {
let sent = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= bytes) return controller.close();
const size = Math.min(1000, bytes - sent);
sent += size;
controller.enqueue(new Uint8Array(size).fill(0x61));
},
});
return new Response(body, { headers });
}

Deno.test("readBoundedBody accepts a body of exactly the cap and rejects one byte more", async () => {
assertEquals((await readBoundedBody(streamOf(4096), 4096)).length, 4096);
await assertRejects(() => readBoundedBody(streamOf(4097), 4096), BodyTooLargeError);
});

Deno.test("readBoundedBody counts bytes received, not the declared Content-Length", async () => {
await assertRejects(() => readBoundedBody(streamOf(8000, { "content-length": "10" }), 4096), BodyTooLargeError);
await assertRejects(() => readBoundedBody(new Response("tiny", { headers: { "content-length": "4097" } }), 4096), BodyTooLargeError);
});
51 changes: 51 additions & 0 deletions supabase/functions/_shared/bounded-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Reads untrusted request and response bodies with a byte cap and, optionally,
// a deadline. A slow sender is cut off when the signal aborts, not when the
// platform's wall-clock limit ends the invocation.

export class BodyTooLargeError extends Error {
constructor(readonly maxBytes: number) {
super(`Body exceeds ${maxBytes} bytes.`);
}
}

/** Settles with `promise`, or rejects with the signal's reason once it aborts. */
export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
});
}

export async function readBoundedBody(
source: Request | Response,
maxBytes: number,
signal?: AbortSignal,
): Promise<string> {
const declaredLength = Number(source.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await source.body?.cancel().catch(() => undefined);
throw new BodyTooLargeError(maxBytes);
}
if (!source.body) return "";
const reader = source.body.getReader();
const decoder = new TextDecoder();
let received = 0;
let text = "";
try {
while (true) {
// A pending read is not guaranteed to reject when the fetch signal
// aborts, so race each read against the signal directly.
const { done, value } = await untilAborted(reader.read(), signal);
if (done) break;
received += value.byteLength;
if (received > maxBytes) throw new BodyTooLargeError(maxBytes);
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
} finally {
reader.cancel().catch(() => undefined);
}
}
125 changes: 98 additions & 27 deletions supabase/functions/_shared/url-validation.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,113 @@
// SSRF-safe URL validation for outbound fetches to AI/search-supplied URLs
// (citation verification, content fetching). Rejects loopback, link-local,
// RFC1918, and IPv4-mapped-IPv6 private targets by literal hostname.
// SSRF-safe URL validation for outbound fetches to user- or AI-supplied URLs.
// Rejects every IPv4 and IPv6 range in the IANA special-purpose registries that
// is not globally reachable, plus multicast, reserved and NAT64 space. Whole
// blocks are rejected even where the registry marks a few anycast or ORCHID
// addresses inside them as global (192.0.0.9, 2001:3::/32 and the like): none
// of them serve web pages, so failing closed costs nothing.

export function isPrivateIpv4(hostname: string) {
type Cidr = readonly [bytes: readonly number[], prefixLength: number];

// https://www.iana.org/assignments/iana-ipv4-special-registry
const BLOCKED_IPV4: readonly Cidr[] = [
[[0, 0, 0, 0], 8], // "this network"
[[10, 0, 0, 0], 8], // private-use
[[100, 64, 0, 0], 10], // shared address space (CGNAT)
[[127, 0, 0, 0], 8], // loopback
[[169, 254, 0, 0], 16], // link-local, including cloud metadata
[[172, 16, 0, 0], 12], // private-use
[[192, 0, 0, 0], 24], // IETF protocol assignments
[[192, 0, 2, 0], 24], // documentation (TEST-NET-1)
[[192, 88, 99, 0], 24], // deprecated 6to4 relay anycast
[[192, 168, 0, 0], 16], // private-use
[[198, 18, 0, 0], 15], // benchmarking
[[198, 51, 100, 0], 24], // documentation (TEST-NET-2)
[[203, 0, 113, 0], 24], // documentation (TEST-NET-3)
[[224, 0, 0, 0], 4], // multicast
[[240, 0, 0, 0], 4], // reserved, including limited broadcast
];

// ::ffff:0:0/96 carries an IPv4 address, which is checked against the IPv4 list.
const IPV4_MAPPED: Cidr = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff], 96];

// https://www.iana.org/assignments/iana-ipv6-special-registry
const BLOCKED_IPV6: readonly Cidr[] = [
[[], 96], // ::/96: unspecified, loopback and deprecated IPv4-compatible
[[0, 0x64, 0xff, 0x9b], 96], // NAT64 well-known prefix
[[0, 0x64, 0xff, 0x9b, 0, 1], 48], // local-use NAT64
[[0x01, 0x00], 64], // discard-only
[[0x01, 0x00, 0, 0, 0, 0, 0, 0x01], 64], // dummy prefix
[[0x20, 0x01, 0x00, 0x00], 23], // IETF protocol assignments, including Teredo
[[0x20, 0x01, 0x0d, 0xb8], 32], // documentation
[[0x20, 0x02], 16], // 6to4
[[0x3f, 0xff], 20], // documentation
[[0x5f, 0x00], 16], // segment routing (SRv6) SIDs
[[0xfc], 7], // unique local
[[0xfe, 0x80], 10], // link-local
[[0xfe, 0xc0], 10], // deprecated site-local
[[0xff], 8], // multicast
];

function inCidr(address: readonly number[], [network, prefixLength]: Cidr): boolean {
for (let bit = 0; bit < prefixLength; bit += 8) {
const index = bit / 8;
const bits = Math.min(8, prefixLength - bit);
const mask = (0xff << (8 - bits)) & 0xff;
if (((address[index] ?? 0) & mask) !== ((network[index] ?? 0) & mask)) return false;
}
return true;
}

function parseIpv4(hostname: string): number[] | null {
// Canonicalize numeric/octal/hex/short IPv4 forms (e.g. "127.1", "0x7f000001",
// "2130706433") to dotted-decimal via the same WHATWG host parser `new URL()`
// uses, so an obfuscated loopback/private address can't slip past the range
// checks below just because it wasn't written as four decimal octets.
// uses, so an obfuscated address can't slip past the range checks.
let canonical = hostname;
try {
canonical = new URL(`http://${hostname}`).hostname;
} catch {
// Not parseable as a host on its own; fall through and let the range
// checks below reject it via the length/range guard.
// Not parseable as a host on its own; the octet check below rejects it.
}
const parts = canonical.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
a === 0 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return parts;
}

function parseIpv6(hostname: string): number[] | null {
const literal = hostname.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
if (!literal.includes(":")) return null;
let canonical: string;
try {
// The WHATWG parser validates the literal and rewrites any embedded
// dotted IPv4 tail as two hex groups.
canonical = new URL(`http://[${literal}]`).hostname.slice(1, -1);
} catch {
return null;
}
const [head, tail = ""] = canonical.split("::");
const headGroups = head ? head.split(":") : [];
const tailGroups = tail ? tail.split(":") : [];
const groups = canonical.includes("::")
? [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill("0"), ...tailGroups]
: headGroups;
if (groups.length !== 8) return null;
return groups.flatMap((group) => {
const value = parseInt(group, 16);
return [value >> 8, value & 0xff];
});
}

export function isPrivateIpv4(hostname: string) {
const address = parseIpv4(hostname);
return address !== null && BLOCKED_IPV4.some((cidr) => inCidr(address, cidr));
}

export function isPrivateIpv6(hostname: string) {
const h = hostname.toLowerCase().replace(/%.*$/, "");
if (!h.includes(":")) return false;
if (h === "::1" || h === "::" || h === "0:0:0:0:0:0:0:1" || h === "0:0:0:0:0:0:0:0") return true;
if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;
if (/^fe[89ab][0-9a-f]:/.test(h)) return true;
const v4MappedMatch = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch && isPrivateIpv4(v4MappedMatch[1])) return true;
return false;
const address = parseIpv6(hostname.toLowerCase());
if (address === null) return false;
if (inCidr(address, IPV4_MAPPED)) {
return isPrivateIpv4(address.slice(12).join("."));
}
return BLOCKED_IPV6.some((cidr) => inCidr(address, cidr));
}

export function validatePublicHttpUrl(url: string): URL {
Expand Down
133 changes: 133 additions & 0 deletions supabase/functions/import-source/deadline_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { FakeTime } from "jsr:@std/testing@1.0.17/time";

for (const [key, value] of Object.entries({
SUPABASE_URL: "https://supabase.test",
SUPABASE_ANON_KEY: "test-anon-key",
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
})) Deno.env.set(key, value);

const { handleRequest } = await import("./index.ts");

// A body that sends one small chunk every 500 ms forever, or never sends at all.
function slowBody(mode: "drip" | "stall", onCancel: () => void) {
let timer: ReturnType<typeof setTimeout> | undefined;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (mode === "stall") return new Promise<void>(() => {});
return new Promise<void>((resolve) => {
timer = setTimeout(() => {
controller.enqueue(new TextEncoder().encode("<p>slow words arriving </p>"));
resolve();
}, 500);
});
},
cancel() {
clearTimeout(timer);
onCancel();
},
});
}

async function importAgainstSlowServer(mode: "drip" | "stall") {
const time = new FakeTime();
const originalFetch = globalThis.fetch;
const originalResolveDns = Deno.resolveDns;
let bodyCancelled = false;

(Deno as unknown as { resolveDns: unknown }).resolveDns = (_host: string, type: string) => Promise.resolve(type === "A" ? ["93.184.216.34"] : []);
globalThis.fetch = (async (input: Request | URL | string) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === "https://slow.example") {
return new Response(slowBody(mode, () => (bodyCancelled = true)), { headers: { "content-type": "text/html" } });
}
if (url.pathname === "/auth/v1/user") return Response.json({ id: "user-1", aud: "authenticated" });
if (url.pathname.includes("/rest/v1/user_roles")) return Response.json({ role: "user" });
if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]);
if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 });
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;

try {
let settled: Response | undefined;
const pending = handleRequest(
new Request("http://127.0.0.1/functions/v1/import-source", {
method: "POST",
headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://slow.example/article" }),
}),
).then((response) => (settled = response));

// Past the 10 s deadline, plus a margin, in 250 ms steps so drips interleave.
for (let elapsed = 0; elapsed < 11_000 && !settled; elapsed += 250) {
await time.tickAsync(250);
}
if (!settled) throw new Error(`import-source was still reading the ${mode} body after 11 s`);
await pending;

const body = await settled.json();
if (settled.status !== 504) throw new Error(`expected 504, got ${settled.status}: ${JSON.stringify(body)}`);
if (!bodyCancelled) throw new Error("the upstream body was not cancelled at the deadline");
} finally {
globalThis.fetch = originalFetch;
(Deno as unknown as { resolveDns: unknown }).resolveDns = originalResolveDns;
time.restore();
}
}

Deno.test("import-source stops reading a body that drips slower than the deadline", async () => {
await importAgainstSlowServer("drip");
});

Deno.test("import-source stops waiting for a body that never arrives", async () => {
await importAgainstSlowServer("stall");
});

Deno.test("import-source stops waiting for a request body that never finishes", async () => {
const time = new FakeTime();
const originalFetch = globalThis.fetch;
const sourceRequests: string[] = [];
globalThis.fetch = (async (input: Request | URL | string) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.pathname === "/auth/v1/user") return Response.json({ id: "user-1", aud: "authenticated" });
if (url.pathname.includes("/rest/v1/user_roles")) return Response.json({ role: "user" });
if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]);
if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 });
sourceRequests.push(url.toString());
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;

let requestCancelled = false;
const stalledUpload = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"url": "https://exam'));
},
pull: () => new Promise<void>(() => {}),
cancel() {
requestCancelled = true;
},
});

try {
let settled: Response | undefined;
const pending = handleRequest(new Request("http://127.0.0.1/functions/v1/import-source", {
method: "POST",
headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" },
body: stalledUpload,
duplex: "half",
} as RequestInit)).then((response) => (settled = response));

for (let elapsed = 0; elapsed < 11_000 && !settled; elapsed += 250) {
await time.tickAsync(250);
}
if (!settled) throw new Error("import-source was still reading the request body after 11 s");
await pending;

const body = await settled.json();
if (settled.status !== 408) throw new Error(`expected 408, got ${settled.status}: ${JSON.stringify(body)}`);
if (!requestCancelled) throw new Error("the request body was not cancelled at the deadline");
if (sourceRequests.length) throw new Error(`a source was fetched: ${sourceRequests}`);
} finally {
globalThis.fetch = originalFetch;
time.restore();
}
});
Loading
Loading