Skip to content

Commit 72af855

Browse files
Programcodeclaude
andcommitted
fix(edge): bound the import request body and block the dummy IPv6 prefix
import-source now reads its own request body with a 16 KB cap under the same deadline, so a stalled upload gets a 408 instead of holding the function open and then reporting a misleading source timeout. Adds 100:0:0:1::/64 to the blocked IPv6 ranges. Replaces the importer source-string assertions with behavior tests for the byte cap at its exact boundary and the one-day share expiry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 717f41d commit 72af855

7 files changed

Lines changed: 109 additions & 15 deletions

File tree

‎src/test/security-hardening.test.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,9 @@ describe('security hardening regressions', () => {
6969
expect(promo).toContain('userClient.rpc("redeem_promo_code_atomic", { p_code: code })');
7070
});
7171

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

7876
it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => {
7977
expect(modelCatalog).toContain('id: "z-ai/glm-5.3-flash"');
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19";
2+
import { BodyTooLargeError, readBoundedBody } from "./bounded-body.ts";
3+
4+
// A streamed body with no Content-Length, sent in 1000-byte chunks.
5+
function streamOf(bytes: number, headers: HeadersInit = {}) {
6+
let sent = 0;
7+
const body = new ReadableStream<Uint8Array>({
8+
pull(controller) {
9+
if (sent >= bytes) return controller.close();
10+
const size = Math.min(1000, bytes - sent);
11+
sent += size;
12+
controller.enqueue(new Uint8Array(size).fill(0x61));
13+
},
14+
});
15+
return new Response(body, { headers });
16+
}
17+
18+
Deno.test("readBoundedBody accepts a body of exactly the cap and rejects one byte more", async () => {
19+
assertEquals((await readBoundedBody(streamOf(4096), 4096)).length, 4096);
20+
await assertRejects(() => readBoundedBody(streamOf(4097), 4096), BodyTooLargeError);
21+
});
22+
23+
Deno.test("readBoundedBody counts bytes received, not the declared Content-Length", async () => {
24+
await assertRejects(() => readBoundedBody(streamOf(8000, { "content-length": "10" }), 4096), BodyTooLargeError);
25+
await assertRejects(() => readBoundedBody(new Response("tiny", { headers: { "content-length": "4097" } }), 4096), BodyTooLargeError);
26+
});

‎supabase/functions/_shared/url-validation.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// SSRF-safe URL validation for outbound fetches to user- or AI-supplied URLs.
22
// Rejects every IPv4 and IPv6 range in the IANA special-purpose registries that
3-
// is not globally reachable, plus multicast, reserved and NAT64 space.
3+
// is not globally reachable, plus multicast, reserved and NAT64 space. Whole
4+
// blocks are rejected even where the registry marks a few anycast or ORCHID
5+
// addresses inside them as global (192.0.0.9, 2001:3::/32 and the like): none
6+
// of them serve web pages, so failing closed costs nothing.
47

58
type Cidr = readonly [bytes: readonly number[], prefixLength: number];
69

@@ -32,6 +35,7 @@ const BLOCKED_IPV6: readonly Cidr[] = [
3235
[[0, 0x64, 0xff, 0x9b], 96], // NAT64 well-known prefix
3336
[[0, 0x64, 0xff, 0x9b, 0, 1], 48], // local-use NAT64
3437
[[0x01, 0x00], 64], // discard-only
38+
[[0x01, 0x00, 0, 0, 0, 0, 0, 0x01], 64], // dummy prefix
3539
[[0x20, 0x01, 0x00, 0x00], 23], // IETF protocol assignments, including Teredo
3640
[[0x20, 0x01, 0x0d, 0xb8], 32], // documentation
3741
[[0x20, 0x02], 16], // 6to4

‎supabase/functions/import-source/deadline_test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,53 @@ Deno.test("import-source stops reading a body that drips slower than the deadlin
8181
Deno.test("import-source stops waiting for a body that never arrives", async () => {
8282
await importAgainstSlowServer("stall");
8383
});
84+
85+
Deno.test("import-source stops waiting for a request body that never finishes", async () => {
86+
const time = new FakeTime();
87+
const originalFetch = globalThis.fetch;
88+
const sourceRequests: string[] = [];
89+
globalThis.fetch = (async (input: Request | URL | string) => {
90+
const url = new URL(input instanceof Request ? input.url : String(input));
91+
if (url.pathname === "/auth/v1/user") return Response.json({ id: "user-1", aud: "authenticated" });
92+
if (url.pathname.includes("/rest/v1/user_roles")) return Response.json({ role: "user" });
93+
if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]);
94+
if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 });
95+
sourceRequests.push(url.toString());
96+
throw new Error(`Unexpected request: ${url}`);
97+
}) as typeof fetch;
98+
99+
let requestCancelled = false;
100+
const stalledUpload = new ReadableStream<Uint8Array>({
101+
start(controller) {
102+
controller.enqueue(new TextEncoder().encode('{"url": "https://exam'));
103+
},
104+
pull: () => new Promise<void>(() => {}),
105+
cancel() {
106+
requestCancelled = true;
107+
},
108+
});
109+
110+
try {
111+
let settled: Response | undefined;
112+
const pending = handleRequest(new Request("http://127.0.0.1/functions/v1/import-source", {
113+
method: "POST",
114+
headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" },
115+
body: stalledUpload,
116+
duplex: "half",
117+
} as RequestInit)).then((response) => (settled = response));
118+
119+
for (let elapsed = 0; elapsed < 11_000 && !settled; elapsed += 250) {
120+
await time.tickAsync(250);
121+
}
122+
if (!settled) throw new Error("import-source was still reading the request body after 11 s");
123+
await pending;
124+
125+
const body = await settled.json();
126+
if (settled.status !== 408) throw new Error(`expected 408, got ${settled.status}: ${JSON.stringify(body)}`);
127+
if (!requestCancelled) throw new Error("the request body was not cancelled at the deadline");
128+
if (sourceRequests.length) throw new Error(`a source was fetched: ${sourceRequests}`);
129+
} finally {
130+
globalThis.fetch = originalFetch;
131+
time.restore();
132+
}
133+
});

‎supabase/functions/import-source/index.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2";
22
import { isPrivateIpv4, isPrivateIpv6, validatePublicHttpUrl, validateResolvedPublicHost } from "./url-validation.ts";
33
import { readBoundedText } from "./resource-limits.ts";
4-
import { untilAborted } from "../_shared/bounded-body.ts";
4+
import { BodyTooLargeError, readBoundedBody, untilAborted } from "../_shared/bounded-body.ts";
55
import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
66
import { getCorsHeaders } from "../_shared/cors.ts";
77

@@ -10,9 +10,10 @@ export { isPrivateIpv4, isPrivateIpv6, validatePublicHttpUrl };
1010

1111

1212
const MAX_CONTENT_CHARS = 50_000;
13-
// Total time for DNS, redirects and the whole body, not just the headers.
13+
// Total time for the request body, DNS, redirects and the whole source body.
1414
const FETCH_TIMEOUT_MS = 10_000;
1515
const MAX_REDIRECTS = 5;
16+
const MAX_REQUEST_BYTES = 16_384;
1617
const DAILY_IMPORT_LIMIT = 50;
1718

1819

@@ -274,7 +275,19 @@ export async function handleRequest(req: Request): Promise<Response> {
274275
const deadline = new AbortController();
275276
const deadlineTimer = setTimeout(() => deadline.abort(new SourceTimeoutError()), FETCH_TIMEOUT_MS);
276277
try {
277-
const body = await req.json().catch(() => null);
278+
let body: { url?: unknown } | null = null;
279+
try {
280+
body = JSON.parse(await readBoundedBody(req, MAX_REQUEST_BYTES, deadline.signal));
281+
} catch (error) {
282+
if (deadline.signal.aborted) {
283+
await logImport(user.id, "error", "request_timeout", Date.now() - startedAt);
284+
return jsonResponse({ error: "The request took too long to arrive." }, 408, corsHeaders);
285+
}
286+
if (error instanceof BodyTooLargeError) {
287+
await logImport(user.id, "error", "bad_request", Date.now() - startedAt);
288+
return jsonResponse({ error: "Request body is too large." }, 413, corsHeaders);
289+
}
290+
}
278291
if (!body || typeof body !== "object") {
279292
await logImport(user.id, "error", "bad_request", Date.now() - startedAt);
280293
return jsonResponse({ error: "Invalid JSON body." }, 400, corsHeaders);

‎supabase/functions/import-source/url-validation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Deno.test("isPrivateIpv6 rejects every IANA special-purpose range that is not gl
8383
"64:ff9b::c000:201", "64:ff9b::808:808", // NAT64 well-known prefix 64:ff9b::/96
8484
"64:ff9b:1::1", // local-use NAT64 64:ff9b:1::/48
8585
"100::1", // discard-only 100::/64
86+
"100:0:0:1::1", // dummy prefix 100:0:0:1::/64
8687
"2001::1", "2001:1ff::1", // IETF protocol assignments 2001::/23 (Teredo included)
8788
"2001:db8::1", // documentation 2001:db8::/32
8889
"2002:c0a8:101::1", // 6to4 2002::/16

‎supabase/functions/share/limits_test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,14 @@ Deno.test("share rejects expiries that are not 1 to 90 whole days", async () =>
7575
}
7676
});
7777

78-
Deno.test("share honours an expiry at the 90-day limit", async () => {
79-
const before = Date.now();
80-
const result = await sendShare(shareBody({ expiresInDays: 90 }));
81-
assertEquals(result.status, 200);
82-
assertEquals(result.calls.inserts.length, 1);
83-
assertExpiresInDays(result.calls.inserts[0].expires_at, 90, before);
78+
Deno.test("share honours expiries at both ends of the 1 to 90 day range", async () => {
79+
for (const days of [1, 90]) {
80+
const before = Date.now();
81+
const result = await sendShare(shareBody({ expiresInDays: days }));
82+
assertEquals(result.status, 200, `expiresInDays=${days}`);
83+
assertEquals(result.calls.inserts.length, 1);
84+
assertExpiresInDays(result.calls.inserts[0].expires_at, days, before);
85+
}
8486
});
8587

8688
Deno.test("share expires a link without an expiry after 30 days and reserves the share quota", async () => {

0 commit comments

Comments
 (0)