From 14a3b013a2bba0be321dc7ec30dc1d17ec342663 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:35:35 -0700 Subject: [PATCH] fix(selfhost): cap setup token form parsing --- src/selfhost/setup-wizard.ts | 20 +++++++++++++++++++ src/server.ts | 3 +++ test/unit/selfhost-setup-wizard.test.ts | 26 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/selfhost/setup-wizard.ts b/src/selfhost/setup-wizard.ts index 9f231c3e81..81d09f9045 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 eec227a28b..18527f3997 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import { renderSetupPage, renderTokenEntryPage, setupAuthCookieValue, + setupTokenFormRejection, timingSafeStrEqual, } from "./selfhost/setup-wizard"; import { exportOrbBatch } from "./selfhost/orb-collector"; @@ -268,6 +269,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 e259f40926..53cbe167bd 100644 --- a/test/unit/selfhost-setup-wizard.test.ts +++ b/test/unit/selfhost-setup-wizard.test.ts @@ -7,7 +7,9 @@ import { isValidSetupAuthCookie, renderSetupPage, renderTokenEntryPage, + SETUP_TOKEN_FORM_MAX_BYTES, setupAuthCookieValue, + setupTokenFormRejection, timingSafeStrEqual, } from "../../src/selfhost/setup-wizard"; @@ -83,6 +85,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(`
`);