From 50d7838de351b9403973c7f3ed51f9cdbb7b354f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 7 Sep 2026 11:50:22 +0200 Subject: [PATCH] fix: support Discord sign-in behind a trusted TLS proxy --- .env.production.example | 3 +++ src/auth/auth.test.ts | 26 ++++++++++++++++++++++++++ src/auth/config.ts | 2 ++ src/auth/index.ts | 6 +++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.env.production.example b/.env.production.example index 4ed871e..e0cc899 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,5 +1,8 @@ # Loaded by bun start and bun run db:migrate:production. # Set your public origin, e.g. https://habits.example.com APP_ORIGIN= +# Enable only behind a private proxy that overwrites X-Forwarded-Proto. +TRUST_PROXY=false +# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1. # Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID, # DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET. diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index f0b3fbb..0f906a1 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -55,6 +55,32 @@ beforeEach(() => { afterEach(() => sqlite.close()); describe("Discord sign-in and sessions", () => { + test("trusted TLS proxy supports sign-in and secure callback cookies", async () => { + const publicOrigin = "https://habits.example"; + const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp); + const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } }); + const location = new URL(response.headers.get("Location")!); + expect(location.origin).toBe("https://discord.com"); + expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`); + expect(response.headers.get("Set-Cookie")).toContain("Secure"); + const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, { + headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") }, + }); + expect(cookie(callback, "__Host-minabot_session")).not.toBe(""); + expect(callback.headers.get("Set-Cookie")).toContain("Secure"); + }); + + test("forwarded protocol is opt-in and cannot override the actual hostname", async () => { + for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) { + const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp); + const response = await proxyApp.request(`http://${host}/api/auth/discord`, { + headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" }, + }); + expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example"); + expect(response.headers.get("Set-Cookie")).toBeNull(); + } + }); + test("me requires a valid session and health stays public", async () => { for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) { const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } }); diff --git a/src/auth/config.ts b/src/auth/config.ts index c5f89c4..2bb941a 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -3,6 +3,7 @@ export type AuthConfig = { clientId: string; clientSecret: string; cookieSecret: string; + trustProxy?: boolean; }; export function readAuthConfig(env = process.env): AuthConfig { @@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig { clientId: env.DISCORD_CLIENT_ID ?? "", clientSecret: env.DISCORD_CLIENT_SECRET ?? "", cookieSecret: env.AUTH_COOKIE_SECRET ?? "", + trustProxy: env.TRUST_PROXY === "true", }; } diff --git a/src/auth/index.ts b/src/auth/index.ts index d0ec127..0477dda 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi routes.get("/discord", async c => { if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); // Use one configured origin for redirects and cookie ownership. - if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`); + const requestUrl = new URL(c.req.url); + // Enable only behind a private proxy that overwrites X-Forwarded-Proto. + // Keep the actual Host; never trust a client-supplied forwarded hostname. + if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:"; + if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`); const state = randomToken(); await setSignedCookie(c, stateName, JSON.stringify({ state,