From 432181e4f166e68b599d4517cc1507a805abaf00 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:54:35 -0700 Subject: [PATCH 1/2] fix(selfhost): require setup token for app wizard --- .env.example | 4 ++++ docs/self-hosting.md | 10 +++++---- src/selfhost/setup-wizard.ts | 21 +++++++++++++++++++ src/server.ts | 28 ++++++++++++++++++++----- test/unit/selfhost-setup-wizard.test.ts | 25 +++++++++++++++++++++- 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index d558c9ebc5..ed05f3a31e 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,10 @@ GITTENSORY_REVIEW_DRAFT=false # # Deriving it from the request Host header would let an attacker # # redirect the App-creation callback, so it must be set explicitly. # # Not needed once the App credentials are configured. +# SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it +# # /setup returns 400; with it, /setup needs ?token= (or an +# # x-setup-token / Bearer header) so a freshly-booted, not-yet-configured +# # instance can't be driven through App creation by a random visitor. # PORT=8787 # DATABASE_PATH=/data/gittensory.sqlite # SQLite file on the mounted data volume; all migrations auto-apply # DATABASE_URL= # set to postgres://user:pw@host:5432/db to use Postgres instead of diff --git a/docs/self-hosting.md b/docs/self-hosting.md index cb10e47b3a..a4436c5f9e 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -48,10 +48,12 @@ GitHub Release. ## 2. Create the GitHub App -**One-click (recommended):** before setting any GitHub secrets, boot the container and visit **`/setup`**. It -creates the App for you via GitHub's App-manifest flow (correct permissions/events + webhook URL), then writes -the credentials to `/data/gittensory-app.env`. Add those to your `.env`, install the App on your repos, and -restart. `/setup` is disabled once `GITHUB_APP_ID` is set, so it can't rebind a live install. +**One-click (recommended):** before setting any GitHub secrets, set `PUBLIC_API_ORIGIN` and a long random +`SELFHOST_SETUP_TOKEN`, boot the container, then visit **`/setup?token=`**. It creates +the App for you via GitHub's App-manifest flow (correct permissions/events + webhook URL), then writes the +credentials to `/data/gittensory-app.env`. Add those to your `.env`, install the App on your repos, and +restart. `/setup` requires the setup token and is disabled once `GITHUB_APP_ID` is set, so it can't rebind a +live install. **Or manually**, create a GitHub App (the hosted gittensory[bot] is separate) with: diff --git a/src/selfhost/setup-wizard.ts b/src/selfhost/setup-wizard.ts index 3c775f43a9..b60896f6dc 100644 --- a/src/selfhost/setup-wizard.ts +++ b/src/selfhost/setup-wizard.ts @@ -3,6 +3,7 @@ // right permissions/events + webhook URL and redirects back to /setup/callback?code=…, which exchanges the // code for the App's credentials and writes them to a file the operator loads (then restarts). The routes are // 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 interface AppCredentials { id: number; @@ -50,6 +51,26 @@ which are written to a file for you to load — then restart the container.

`; } +/** Signed cookie value proving the setup flow was started by someone who knows the operator token. */ +export function setupAuthCookieValue(secret: string, state: string): string { + const mac = createHmac("sha256", secret).update(state).digest("base64url"); + return `${state}.${mac}`; +} + +/** Extract a named cookie value from the Cookie header. */ +export function cookieValue(cookieHeader: string, name: string): string | undefined { + return cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1); +} + +/** Validate the signed setup cookie without trusting a client-supplied state alone. */ +export function isValidSetupAuthCookie(secret: string, state: string, cookie: string | undefined): boolean { + if (!cookie) return false; + const expected = setupAuthCookieValue(secret, state); + const actualBytes = Buffer.from(cookie); + const expectedBytes = Buffer.from(expected); + return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); +} + /** Exchange the temporary manifest code for the App's credentials (id, slug, webhook secret, private key). */ export async function exchangeManifestCode(code: string, fetchImpl: typeof fetch = fetch): Promise { const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { diff --git a/src/server.ts b/src/server.ts index d04c6f9c0c..edc8cf9252 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,14 @@ import { serve } from "@hono/node-server"; import worker from "./index"; import { processJob } from "./queue/processors"; import { createSelfHostAi } from "./selfhost/ai"; -import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard"; +import { + cookieValue, + credentialsToEnv, + exchangeManifestCode, + isValidSetupAuthCookie, + renderSetupPage, + setupAuthCookieValue, +} from "./selfhost/setup-wizard"; import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; import { readiness } from "./selfhost/health"; @@ -237,6 +244,10 @@ async function main(): Promise { if (path === "/metrics") return new Response(await renderMetrics(), { headers: { "content-type": "text/plain; version=0.0.4" } }); // First-run GitHub App setup wizard — only while no App is configured (can't rebind a live install). if ((path === "/setup" || path === "/setup/callback") && !process.env.GITHUB_APP_ID) { + const setupToken = process.env.SELFHOST_SETUP_TOKEN; + if (!setupToken) { + return new Response("SELFHOST_SETUP_TOKEN must be set before using the setup wizard", { status: 400 }); + } // PUBLIC_API_ORIGIN is required: falling back to request.url.origin would let an attacker spoof // the Host header and redirect the App-creation callback to an attacker-controlled domain, where // they could exchange the one-time code for the App private key and webhook secret. @@ -248,13 +259,20 @@ async function main(): Promise { ); } if (path === "/setup") { + const suppliedToken = + new URL(request.url).searchParams.get("token") ?? + request.headers.get("x-setup-token") ?? + request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + if (suppliedToken !== setupToken) return new Response("invalid setup token", { status: 403 }); // Generate a per-visit CSRF nonce, embed it in the manifest's redirect_url, and bind it to - // this browser session via an HttpOnly cookie so the callback can validate it. + // this browser session via an HttpOnly signed cookie so the callback can validate it came + // from an operator-authorized setup visit, not just any unauthenticated browser. const state = randomUUID(); return new Response(renderSetupPage(origin, state), { headers: { "content-type": "text/html; charset=utf-8", - "Set-Cookie": `setup_state=${state}; Path=/setup; HttpOnly; SameSite=Lax; Max-Age=3600`, + "Referrer-Policy": "no-referrer", + "Set-Cookie": `setup_auth=${setupAuthCookieValue(setupToken, state)}; Path=/setup; HttpOnly; SameSite=Lax; Max-Age=3600`, }, }); } @@ -264,8 +282,8 @@ async function main(): Promise { // Validate the CSRF state: must match the cookie set when /setup was served. const stateParam = params.get("state"); const cookieHeader = request.headers.get("cookie") ?? ""; - const cookieState = cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith("setup_state="))?.slice("setup_state=".length); - if (!stateParam || !cookieState || stateParam !== cookieState) { + const setupAuth = cookieValue(cookieHeader, "setup_auth"); + if (!stateParam || !isValidSetupAuthCookie(setupToken, stateParam, setupAuth)) { return new Response("invalid state parameter", { status: 403 }); } try { diff --git a/test/unit/selfhost-setup-wizard.test.ts b/test/unit/selfhost-setup-wizard.test.ts index 2e24c197e7..c413272139 100644 --- a/test/unit/selfhost-setup-wizard.test.ts +++ b/test/unit/selfhost-setup-wizard.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { buildManifest, credentialsToEnv, exchangeManifestCode, renderSetupPage } from "../../src/selfhost/setup-wizard"; +import { + buildManifest, + cookieValue, + credentialsToEnv, + exchangeManifestCode, + isValidSetupAuthCookie, + renderSetupPage, + setupAuthCookieValue, +} from "../../src/selfhost/setup-wizard"; describe("setup-wizard (#981 GitHub App Manifest)", () => { it("builds a manifest with the webhook + redirect URLs (including CSRF state), permissions, events", () => { @@ -24,6 +32,21 @@ describe("setup-wizard (#981 GitHub App Manifest)", () => { expect(html).toContain("nonce-abc"); // state is baked into the manifest value }); + it("signs the setup cookie so only token-authorized setup visits can finish the callback", () => { + const cookie = setupAuthCookieValue("operator-token", "nonce-abc"); + expect(isValidSetupAuthCookie("operator-token", "nonce-abc", cookie)).toBe(true); + expect(isValidSetupAuthCookie("operator-token", "other-nonce", cookie)).toBe(false); + expect(isValidSetupAuthCookie("wrong-token", "nonce-abc", cookie)).toBe(false); + expect(isValidSetupAuthCookie("operator-token", "nonce-abc", "bad-cookie")).toBe(false); + expect(isValidSetupAuthCookie("operator-token", "nonce-abc", undefined)).toBe(false); + }); + + it("extracts setup cookies from a multi-cookie header", () => { + const cookie = setupAuthCookieValue("operator-token", "nonce-abc"); + expect(cookieValue(`theme=dark; setup_auth=${cookie}; session=xyz`, "setup_auth")).toBe(cookie); + expect(cookieValue("theme=dark", "setup_auth")).toBeUndefined(); + }); + it("exchanges the code and serializes credentials to .env lines", async () => { const fakeFetch = vi.fn( async () => From 3c51484de8ad51a34cf7478277ba17dee05e690b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:31:12 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(selfhost):=20harden=20setup-token=20aut?= =?UTF-8?q?h=20=E2=80=94=20constant-time=20compare=20+=20token=20via=20POS?= =?UTF-8?q?T=20form,=20not=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the Superagent findings on the first-run setup wizard: - Compare the setup token with a constant-time timingSafeStrEqual instead of `!==`, closing a timing side-channel; reuse it for the signed setup_auth cookie check (DRY). - Stop reading the token from the URL query string (it leaked to access logs, proxies, and browser history). The browser flow now uses a token-entry form that POSTs the token in the request body (renderTokenEntryPage); scripted setups still use the x-setup-token / Authorization: Bearer header. Docs updated. --- docs/self-hosting.md | 11 +++++----- src/selfhost/setup-wizard.ts | 27 +++++++++++++++++++++---- src/server.ts | 24 ++++++++++++++++++---- test/unit/selfhost-setup-wizard.test.ts | 18 +++++++++++++++++ 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a4436c5f9e..9a3000b7fa 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -49,11 +49,12 @@ GitHub Release. ## 2. Create the GitHub App **One-click (recommended):** before setting any GitHub secrets, set `PUBLIC_API_ORIGIN` and a long random -`SELFHOST_SETUP_TOKEN`, boot the container, then visit **`/setup?token=`**. It creates -the App for you via GitHub's App-manifest flow (correct permissions/events + webhook URL), then writes the -credentials to `/data/gittensory-app.env`. Add those to your `.env`, install the App on your repos, and -restart. `/setup` requires the setup token and is disabled once `GITHUB_APP_ID` is set, so it can't rebind a -live install. +`SELFHOST_SETUP_TOKEN`, boot the container, then visit **`/setup`** and enter your `SELFHOST_SETUP_TOKEN` +in the form (the token is sent in the POST body, never the URL, so it can't leak to logs or browser history). +It creates the App for you via GitHub's App-manifest flow (correct permissions/events + webhook URL), then +writes the credentials to `/data/gittensory-app.env`. Add those to your `.env`, install the App on your repos, +and restart. `/setup` requires the setup token and is disabled once `GITHUB_APP_ID` is set, so it can't rebind +a live install. (Scripted setups can pass the token via an `x-setup-token` header instead.) **Or manually**, create a GitHub App (the hosted gittensory[bot] is separate) with: diff --git a/src/selfhost/setup-wizard.ts b/src/selfhost/setup-wizard.ts index b60896f6dc..9f231c3e81 100644 --- a/src/selfhost/setup-wizard.ts +++ b/src/selfhost/setup-wizard.ts @@ -62,13 +62,32 @@ export function cookieValue(cookieHeader: string, name: string): string | undefi return cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1); } +/** Constant-time string equality (avoids timing side-channels when comparing secrets/tokens). */ +export function timingSafeStrEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + /** Validate the signed setup cookie without trusting a client-supplied state alone. */ export function isValidSetupAuthCookie(secret: string, state: string, cookie: string | undefined): boolean { if (!cookie) return false; - const expected = setupAuthCookieValue(secret, state); - const actualBytes = Buffer.from(cookie); - const expectedBytes = Buffer.from(expected); - return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); + return timingSafeStrEqual(cookie, setupAuthCookieValue(secret, state)); +} + +/** First step of the browser setup flow: a form that POSTs the operator's setup token in the request BODY. + * The token is never put in the URL — a query-string secret leaks to access logs, proxies, and history. */ +export function renderTokenEntryPage(invalid = false): string { + const error = invalid ? `

Invalid setup token.

\n` : ""; + return `Gittensory self-host setup + +

Gittensory self-host setup

+

Enter your SELFHOST_SETUP_TOKEN to continue.

+${error}
+ + +
+`; } /** Exchange the temporary manifest code for the App's credentials (id, slug, webhook secret, private key). */ diff --git a/src/server.ts b/src/server.ts index ece51967f4..eec227a28b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,7 +19,9 @@ import { exchangeManifestCode, isValidSetupAuthCookie, renderSetupPage, + renderTokenEntryPage, setupAuthCookieValue, + timingSafeStrEqual, } from "./selfhost/setup-wizard"; import { exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; @@ -259,11 +261,25 @@ async function main(): Promise { ); } if (path === "/setup") { - const suppliedToken = - new URL(request.url).searchParams.get("token") ?? + // Token via header (programmatic) or the POST form body (browser) — NEVER the URL query string, + // which would leak the secret to access logs, proxies, and browser history. + let suppliedToken = request.headers.get("x-setup-token") ?? - request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); - if (suppliedToken !== setupToken) return new Response("invalid setup token", { status: 403 }); + request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? + ""; + if (!suppliedToken && request.method === "POST") { + const form = await request.formData().catch(() => null); + const field = form?.get("token"); + suppliedToken = typeof field === "string" ? field : ""; + } + if (!timingSafeStrEqual(suppliedToken, setupToken)) { + // Not authenticated → show the token-entry form (token submitted via POST body, not the URL). + // First visit (no token) is 200; a wrong submission is 403. + return new Response(renderTokenEntryPage(suppliedToken.length > 0), { + status: suppliedToken.length > 0 ? 403 : 200, + headers: { "content-type": "text/html; charset=utf-8", "Referrer-Policy": "no-referrer" }, + }); + } // Generate a per-visit CSRF nonce, embed it in the manifest's redirect_url, and bind it to // this browser session via an HttpOnly signed cookie so the callback can validate it came // from an operator-authorized setup visit, not just any unauthenticated browser. diff --git a/test/unit/selfhost-setup-wizard.test.ts b/test/unit/selfhost-setup-wizard.test.ts index c413272139..e259f40926 100644 --- a/test/unit/selfhost-setup-wizard.test.ts +++ b/test/unit/selfhost-setup-wizard.test.ts @@ -6,7 +6,9 @@ import { exchangeManifestCode, isValidSetupAuthCookie, renderSetupPage, + renderTokenEntryPage, setupAuthCookieValue, + timingSafeStrEqual, } from "../../src/selfhost/setup-wizard"; describe("setup-wizard (#981 GitHub App Manifest)", () => { @@ -73,4 +75,20 @@ describe("setup-wizard (#981 GitHub App Manifest)", () => { expect(env).not.toContain("GITHUB_OAUTH_CLIENT_ID"); expect(env).not.toContain("GITHUB_OAUTH_CLIENT_SECRET"); }); + + it("timingSafeStrEqual compares constant-time: equal vs differing-value vs differing-length", () => { + expect(timingSafeStrEqual("s3cret-token", "s3cret-token")).toBe(true); + expect(timingSafeStrEqual("s3cret-token", "s3cret-toker")).toBe(false); // same length, different bytes + expect(timingSafeStrEqual("short", "longer-token")).toBe(false); // length mismatch must not throw + expect(timingSafeStrEqual("", "")).toBe(true); + }); + + it("renderTokenEntryPage renders a POST form (token in the body, not the URL) + an error variant", () => { + const page = renderTokenEntryPage(); + expect(page).toContain(`
`); + expect(page).toContain(`name="token"`); + expect(page).toContain(`type="password"`); // never echoed/visible + expect(page).not.toContain("Invalid setup token"); + expect(renderTokenEntryPage(true)).toContain("Invalid setup token"); // shown after a wrong submission + }); });