diff --git a/src/selfhost/setup-wizard.ts b/src/selfhost/setup-wizard.ts index 2ee0c6ad03..4e67961b89 100644 --- a/src/selfhost/setup-wizard.ts +++ b/src/selfhost/setup-wizard.ts @@ -5,6 +5,26 @@ // disabled once an App is configured (server.ts gates on GITHUB_APP_ID), so this can't rebind a live install. import { createHmac, timingSafeEqual } from "node:crypto"; +export const SETUP_TOKEN_FORM_MAX_BYTES = 4096; + +export function setupTokenFormRejection(headers: Headers): Response | undefined { + const contentLength = headers.get("content-length"); + if (!contentLength) return new Response("setup token form requires Content-Length", { status: 411 }); + const byteLength = Number(contentLength); + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + return new Response("invalid setup token form length", { status: 400 }); + } + if (byteLength > SETUP_TOKEN_FORM_MAX_BYTES) { + return new Response("setup token form is too large", { status: 413 }); + } + + const mediaType = headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/x-www-form-urlencoded" && mediaType !== "multipart/form-data") { + return new Response("unsupported setup token form content type", { status: 415 }); + } + return undefined; +} + export interface AppCredentials { id: number; slug: string; diff --git a/src/server.ts b/src/server.ts index fdfcb0faae..4b6bf2b19f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,7 @@ import { renderSetupPage, renderTokenEntryPage, setupAuthCookieValue, + setupTokenFormRejection, timingSafeStrEqual, } from "./selfhost/setup-wizard"; import { isOrbBrokerMode, registerOrbRelayTarget } from "./orb/broker-client"; @@ -290,6 +291,8 @@ async function main(): Promise { request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? ""; if (!suppliedToken && request.method === "POST") { + const rejection = setupTokenFormRejection(request.headers); + if (rejection) return rejection; const form = await request.formData().catch(() => null); const field = form?.get("token"); suppliedToken = typeof field === "string" ? field : ""; diff --git a/test/unit/selfhost-setup-wizard.test.ts b/test/unit/selfhost-setup-wizard.test.ts index 4b627ecc04..3e33470de8 100644 --- a/test/unit/selfhost-setup-wizard.test.ts +++ b/test/unit/selfhost-setup-wizard.test.ts @@ -8,7 +8,9 @@ import { renderBrokeredSetupPage, renderSetupPage, renderTokenEntryPage, + SETUP_TOKEN_FORM_MAX_BYTES, setupAuthCookieValue, + setupTokenFormRejection, timingSafeStrEqual, } from "../../src/selfhost/setup-wizard"; @@ -91,6 +93,30 @@ describe("setup-wizard (#981 GitHub App Manifest)", () => { expect(timingSafeStrEqual("", "")).toBe(true); }); + it("rejects unsafe setup token form uploads before parsing the body", async () => { + const oversized = setupTokenFormRejection( + new Headers({ "content-length": String(SETUP_TOKEN_FORM_MAX_BYTES + 1), "content-type": "application/x-www-form-urlencoded" }), + ); + expect(oversized?.status).toBe(413); + await expect(oversized?.text()).resolves.toContain("too large"); + + expect(setupTokenFormRejection(new Headers({ "content-type": "application/x-www-form-urlencoded" }))?.status).toBe(411); + expect( + setupTokenFormRejection(new Headers({ "content-length": "nope", "content-type": "application/x-www-form-urlencoded" }))?.status, + ).toBe(400); + expect( + setupTokenFormRejection(new Headers({ "content-length": "-1", "content-type": "application/x-www-form-urlencoded" }))?.status, + ).toBe(400); + expect(setupTokenFormRejection(new Headers({ "content-length": "5", "content-type": "text/plain" }))?.status).toBe(415); + + expect( + setupTokenFormRejection(new Headers({ "content-length": "12", "content-type": "application/x-www-form-urlencoded; charset=UTF-8" })), + ).toBeUndefined(); + expect( + setupTokenFormRejection(new Headers({ "content-length": "12", "content-type": "multipart/form-data; boundary=x" })), + ).toBeUndefined(); + }); + it("renderTokenEntryPage renders a POST form (token in the body, not the URL) + an error variant", () => { const page = renderTokenEntryPage(); expect(page).toContain(`
`);