From 25b10f04da6923e8c3255abaf00730428ffa8b6d Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Sat, 12 Sep 2026 09:25:27 +0700 Subject: [PATCH] feat(auth): enhance custom-domain same-origin handling and proxy trust validation --- .env.example | 6 +- src/server/middleware/auth.ts | 65 ++++++++++++++-- .../backend/database-session-security.test.ts | 76 +++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index fd129f4..e367a97 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost:4000 # /* --------------- Connection settings for PostgreSQL database -------------- */ -# Frontend server +# Frontend server. Optional for same-origin browser access; recommended as the +# canonical callback/fallback origin in production. Use https and no path. FRONTEND_URL=http://localhost:3000 # Optional canonical/backend URL if ever needed @@ -35,6 +36,9 @@ SESSION_ABSOLUTE_TIMEOUT_HOURS=168 # /* -------------------------- Reverse proxy / rate limiting ------------------------- */ +# Set true only when Doktainer is behind a trusted reverse proxy that overwrites +# X-Forwarded-Host and X-Forwarded-Proto. This enables the effective-origin +# fallback for clients that do not send Fetch Metadata. TRUST_PROXY=false RATE_LIMIT_MAX=300 RATE_LIMIT_TIME_WINDOW=1 minute diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 95f11dc..ff0959f 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -36,16 +36,61 @@ function getHeaderValue(value: string | string[] | undefined): string | null { const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const BROWSER_MUTATION_HEADER = "x-doktainer-request"; -function getAllowedBrowserOrigins(env = process.env): Set { - const origins = [env.FRONTEND_URL, ...(env.CORS_ORIGINS ?? "").split(",")] - .map((origin) => origin?.trim().replace(/\/$/, "")) +function normalizeHttpOrigin(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + + try { + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.origin; + } catch { + return null; + } +} + +function firstForwardedValue(value: string | null): string | null { + return value?.split(",")[0]?.trim() || null; +} + +function getEffectiveRequestOrigin( + req: FastifyRequest, + env: NodeJS.ProcessEnv, +): string | null { + const trustProxy = env.TRUST_PROXY === "true"; + const forwardedHost = trustProxy + ? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-host"])) + : null; + const forwardedProtocol = trustProxy + ? firstForwardedValue(getHeaderValue(req.headers["x-forwarded-proto"])) + : null; + const host = forwardedHost || getHeaderValue(req.headers.host); + const protocol = forwardedProtocol || req.protocol; + + if (!host || !protocol) return null; + return normalizeHttpOrigin(`${protocol.replace(/:$/, "")}://${host}`); +} + +function getAllowedBrowserOrigins( + req: FastifyRequest, + env: NodeJS.ProcessEnv, +): Set { + const origins = [ + env.NEXT_PUBLIC_PANEL_URL, + env.FRONTEND_URL, + ...(env.CORS_ORIGINS ?? "").split(","), + ] + .map(normalizeHttpOrigin) .filter((origin): origin is string => Boolean(origin)); + const requestOrigin = getEffectiveRequestOrigin(req, env); + if (requestOrigin) origins.push(requestOrigin); return new Set(origins.length > 0 ? origins : ["http://localhost:3000"]); } export function enforceBrowserMutationProtection( req: FastifyRequest, reply: FastifyReply, + env: NodeJS.ProcessEnv = process.env, ): boolean { if (SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true; @@ -57,7 +102,8 @@ export function enforceBrowserMutationProtection( return false; } - if (getHeaderValue(req.headers["sec-fetch-site"]) === "cross-site") { + const fetchSite = getHeaderValue(req.headers["sec-fetch-site"]); + if (fetchSite === "cross-site") { reply.status(403).send({ success: false, error: "Forbidden — cross-site request rejected", @@ -65,8 +111,15 @@ export function enforceBrowserMutationProtection( return false; } - const origin = getHeaderValue(req.headers.origin)?.replace(/\/$/, ""); - if (origin && !getAllowedBrowserOrigins().has(origin)) { + const rawOrigin = getHeaderValue(req.headers.origin); + const origin = normalizeHttpOrigin(rawOrigin); + const browserConfirmedSameOrigin = fetchSite === "same-origin"; + if ( + rawOrigin && + (!origin || + (!browserConfirmedSameOrigin && + !getAllowedBrowserOrigins(req, env).has(origin))) + ) { reply.status(403).send({ success: false, error: "Forbidden — request origin is not allowed", diff --git a/tests/backend/database-session-security.test.ts b/tests/backend/database-session-security.test.ts index db5361d..6aad139 100644 --- a/tests/backend/database-session-security.test.ts +++ b/tests/backend/database-session-security.test.ts @@ -106,6 +106,82 @@ test("safe methods and verified same-origin mutations are accepted", () => { ); }); +test("browser-confirmed same-origin mutations use the active origin without env configuration", () => { + const allowed = enforceBrowserMutationProtection( + { + method: "POST", + protocol: "http", + headers: { + host: "127.0.0.1:4000", + origin: "https://panel.example.com", + "sec-fetch-site": "same-origin", + "x-doktainer-request": "1", + }, + } as never, + createReplyRecorder() as never, + {} as NodeJS.ProcessEnv, + ); + + assert.equal(allowed, true); +}); + +test("verified mutations accept the effective custom-domain request origin", () => { + const allowed = enforceBrowserMutationProtection( + { + method: "POST", + protocol: "https", + headers: { + host: "panel.example.com", + origin: "https://panel.example.com", + "sec-fetch-site": "same-site", + "x-doktainer-request": "1", + }, + } as never, + createReplyRecorder() as never, + { + FRONTEND_URL: "http://localhost:3000", + TRUST_PROXY: "false", + } as NodeJS.ProcessEnv, + ); + + assert.equal(allowed, true); +}); + +test("forwarded custom-domain origin is trusted only behind an enabled proxy", () => { + const request = { + method: "POST", + protocol: "http", + headers: { + host: "127.0.0.1:4000", + origin: "https://panel.example.com", + "sec-fetch-site": "same-site", + "x-doktainer-request": "1", + "x-forwarded-host": "panel.example.com", + "x-forwarded-proto": "https", + }, + } as never; + + assert.equal( + enforceBrowserMutationProtection( + request, + createReplyRecorder() as never, + { TRUST_PROXY: "true" } as NodeJS.ProcessEnv, + ), + true, + ); + + const untrustedReply = createReplyRecorder(); + assert.equal( + enforceBrowserMutationProtection( + request, + untrustedReply as never, + { TRUST_PROXY: "false" } as NodeJS.ProcessEnv, + ), + false, + ); + assert.equal(untrustedReply.statusCode, 403); +}); + test("only owners and super admins can revoke a managed login session", () => { assert.equal( canRevokeManagedSession({