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
35 changes: 35 additions & 0 deletions supabase/functions/_shared/bounded-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,38 @@ Deno.test("readBoundedBody counts bytes received, not the declared Content-Lengt
await assertRejects(() => readBoundedBody(streamOf(8000, { "content-length": "10" }), 4096), BodyTooLargeError);
await assertRejects(() => readBoundedBody(new Response("tiny", { headers: { "content-length": "4097" } }), 4096), BodyTooLargeError);
});

// The hosted runtime holds a response until the request body has been read, so
// an oversized request must be drained, while an upstream response is cancelled.
function trackedStream(bytes: number) {
const state = { sent: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.sent >= bytes) return controller.close();
const size = Math.min(1000, bytes - state.sent);
state.sent += size;
controller.enqueue(new Uint8Array(size).fill(0x61));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}

Deno.test("readBoundedBody reads an oversized request to the end before refusing it", async () => {
for (const contentLength of [undefined, "20000"]) {
const { body, state } = trackedStream(20_000);
const headers: HeadersInit = contentLength ? { "content-length": contentLength } : {};
const request = new Request("http://127.0.0.1/", { method: "POST", body, headers, duplex: "half" } as RequestInit);
await assertRejects(() => readBoundedBody(request, 4096), BodyTooLargeError);
assertEquals(state, { sent: 20_000, cancelled: false }, `content-length ${contentLength}`);
}
});

Deno.test("readBoundedBody cancels an oversized upstream response instead of reading it", async () => {
const { body, state } = trackedStream(20_000);
await assertRejects(() => readBoundedBody(new Response(body), 4096), BodyTooLargeError);
assertEquals(state.cancelled, true);
if (state.sent >= 20_000) throw new Error("the whole response was read");
});
42 changes: 38 additions & 4 deletions supabase/functions/_shared/bounded-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,53 @@ export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Prom
});
}

// The hosted Edge runtime does not deliver a response until the function has
// read the request body. An oversized request is therefore read to the end and
// discarded, for at most this long, before the caller answers 413. An upstream
// response is cancelled instead.
const REQUEST_DRAIN_MS = 10_000;

async function discardRest(reader: ReadableStreamDefaultReader<Uint8Array>, signal?: AbortSignal) {
const limit = AbortSignal.timeout(REQUEST_DRAIN_MS);
const until = signal ? AbortSignal.any([signal, limit]) : limit;
try {
while (!(await untilAborted(reader.read(), until)).done) {
// Discard.
}
} catch {
// Out of time or the sender went away; answer anyway.
}
}

/** Reads and drops a request body the caller is refusing, so the refusal reaches the client. */
export async function discardRequestBody(req: Request, signal?: AbortSignal): Promise<void> {
if (!req.body || req.bodyUsed) return;
const reader = req.body.getReader();
try {
await discardRest(reader, signal);
} finally {
reader.cancel().catch(() => undefined);
}
}

export async function readBoundedBody(
source: Request | Response,
maxBytes: number,
signal?: AbortSignal,
): Promise<string> {
const isRequest = source instanceof Request;
const declaredLength = Number(source.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await source.body?.cancel().catch(() => undefined);
throw new BodyTooLargeError(maxBytes);
const declaredTooLarge = Number.isFinite(declaredLength) && declaredLength > maxBytes;
if (!source.body) {
if (declaredTooLarge) throw new BodyTooLargeError(maxBytes);
return "";
}
if (!source.body) return "";
const reader = source.body.getReader();
const decoder = new TextDecoder();
let received = 0;
let text = "";
try {
if (declaredTooLarge) throw new BodyTooLargeError(maxBytes);
while (true) {
// A pending read is not guaranteed to reject when the fetch signal
// aborts, so race each read against the signal directly.
Expand All @@ -45,6 +76,9 @@ export async function readBoundedBody(
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
} catch (error) {
if (isRequest && error instanceof BodyTooLargeError) await discardRest(reader, signal);
throw error;
} finally {
reader.cancel().catch(() => undefined);
}
Expand Down
3 changes: 2 additions & 1 deletion supabase/functions/import-source/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2";
import { isPrivateIpv4, isPrivateIpv6, validatePublicHttpUrl, validateResolvedPublicHost } from "./url-validation.ts";
import { readBoundedText } from "./resource-limits.ts";
import { BodyTooLargeError, readBoundedBody, untilAborted } from "../_shared/bounded-body.ts";
import { BodyTooLargeError, discardRequestBody, readBoundedBody, untilAborted } from "../_shared/bounded-body.ts";
import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
import { getCorsHeaders } from "../_shared/cors.ts";

Expand Down Expand Up @@ -248,6 +248,7 @@ export async function handleRequest(req: Request): Promise<Response> {
} catch (error) {
if (!(error instanceof HttpError)) throw error;
console.warn("[IMPORT_SOURCE] Authentication rejected:", error.message);
await discardRequestBody(req);
return jsonError(error, corsHeaders);
}

Expand Down
3 changes: 2 additions & 1 deletion supabase/functions/share/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
import { PUBLIC_WEB_APP_ORIGIN, getCorsHeaders } from "../_shared/cors.ts";
import { BodyTooLargeError, readBoundedBody } from "../_shared/bounded-body.ts";
import { BodyTooLargeError, discardRequestBody, readBoundedBody } from "../_shared/bounded-body.ts";

// Shared items are saved text, so 512 KB of JSON leaves room for long cases and
// chats while keeping one request from filling the table.
Expand Down Expand Up @@ -70,6 +70,7 @@ export async function handleRequest(req: Request): Promise<Response> {
} catch (error) {
if (!(error instanceof HttpError)) throw error;
console.warn("[SHARE] Authentication rejected:", error.message);
await discardRequestBody(req);
return jsonError(error, corsHeaders);
}

Expand Down
Loading