Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value> (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
Expand Down
10 changes: 6 additions & 4 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<SELFHOST_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:

Expand Down
21 changes: 21 additions & 0 deletions src/selfhost/setup-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,26 @@ which are written to a file for you to load — then restart the container.</p>
</body></html>`;
}

/** 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<AppCredentials> {
const res = await fetchImpl(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
Expand Down
28 changes: 23 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { exportOrbBatch } from "./selfhost/orb-collector";
import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter";
import { readiness } from "./selfhost/health";
Expand Down Expand Up @@ -237,6 +244,10 @@ async function main(): Promise<void> {
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.
Expand All @@ -248,13 +259,20 @@ async function main(): Promise<void> {
);
}
if (path === "/setup") {
const suppliedToken =
new URL(request.url).searchParams.get("token") ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Setup token accepted from URL query parameter, leaking secret to logs and history

Setup token can be passed in the URL query string, leaking it to logs and browser history.

Remove query parameter support; accept the setup token only via secure headers.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="src/server.ts">
<violation number="1" location="src/server.ts:263">
<priority>P2</priority>
<title>Setup token accepted from URL query parameter, leaking secret to logs and history</title>
<evidence>The /setup endpoint reads the setup token from new URL(request.url).searchParams.get("token"), allowing the secret to be passed in the URL query string. This leaks the token to web server access logs, reverse-proxy logs, browser history, and shared links. The PR adds Referrer-Policy: no-referrer to mitigate referrer leakage but does not address query-string logging.</evidence>
<recommendation>Remove the query-parameter fallback for the setup token. Accept the token only via the x-setup-token or Authorization headers. Update documentation to stop instructing users to visit /setup?token=....</recommendation>
</violation>
</file>

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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Setup token compared with timing-unsafe string operator

Setup token comparison uses !==, enabling timing side-channel attacks.

Use crypto.timingSafeEqual for constant-time token comparison.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="src/server.ts">
<violation number="1" location="src/server.ts:266">
<priority>P2</priority>
<title>Setup token compared with timing-unsafe string operator</title>
<evidence>The suppliedToken is compared against setupToken using the !== operator, which short-circuits on the first mismatched character and is vulnerable to timing side-channel attacks.</evidence>
<recommendation>Use crypto.timingSafeEqual to compare the supplied token against the configured token, after first checking lengths match (or padding to a fixed length).</recommendation>
</violation>
</file>

// 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`,
},
});
}
Expand All @@ -264,8 +282,8 @@ async function main(): Promise<void> {
// 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 {
Expand Down
25 changes: 24 additions & 1 deletion test/unit/selfhost-setup-wizard.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 () =>
Expand Down
Loading