diff --git a/supabase/functions/_shared/bounded-body.test.ts b/supabase/functions/_shared/bounded-body.test.ts index 2fd69120c..ba5574d61 100644 --- a/supabase/functions/_shared/bounded-body.test.ts +++ b/supabase/functions/_shared/bounded-body.test.ts @@ -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({ + 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"); +}); diff --git a/supabase/functions/_shared/bounded-body.ts b/supabase/functions/_shared/bounded-body.ts index 3ab787567..49dfcc7bb 100644 --- a/supabase/functions/_shared/bounded-body.ts +++ b/supabase/functions/_shared/bounded-body.ts @@ -19,22 +19,53 @@ export function untilAborted(promise: Promise, 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, 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 { + 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 { + 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. @@ -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); } diff --git a/supabase/functions/import-source/index.ts b/supabase/functions/import-source/index.ts index d4996834b..c15370773 100644 --- a/supabase/functions/import-source/index.ts +++ b/supabase/functions/import-source/index.ts @@ -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"; @@ -248,6 +248,7 @@ export async function handleRequest(req: Request): Promise { } catch (error) { if (!(error instanceof HttpError)) throw error; console.warn("[IMPORT_SOURCE] Authentication rejected:", error.message); + await discardRequestBody(req); return jsonError(error, corsHeaders); } diff --git a/supabase/functions/share/index.ts b/supabase/functions/share/index.ts index 7de934906..e179a790c 100644 --- a/supabase/functions/share/index.ts +++ b/supabase/functions/share/index.ts @@ -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. @@ -70,6 +70,7 @@ export async function handleRequest(req: Request): Promise { } catch (error) { if (!(error instanceof HttpError)) throw error; console.warn("[SHARE] Authentication rejected:", error.message); + await discardRequestBody(req); return jsonError(error, corsHeaders); }