From fbf8ca38b07cc5a0bcb9dd06fb1b2f5c9cd17990 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:12:01 +0200 Subject: [PATCH] Harden forward auth: store redirect URIs server-side, eliminate client control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace client-controlled redirectUri with server-side redirect intents. The portal server component validates the ?rd= hostname against isForwardAuthDomain, stores the URI in a new forward_auth_redirect_intents table, and passes only an opaque rid (128-bit random, SHA-256 hashed) to the client. Login endpoints consume the intent atomically (one-time use, 10-minute TTL) and retrieve the stored URI — the client never sends the redirect URL to any API endpoint. Security properties: - Redirect URI is never client-controlled in API requests - rid is 128-bit random, stored as SHA-256 hash (not reversible from DB) - Atomic one-time consumption prevents replay - 10-minute TTL limits attack window for OAuth round-trip - Immediate deletion after consumption - Expired intents cleaned up opportunistically - Hostname validated against registered forward-auth domains before storage Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(auth)/portal/PortalLoginForm.tsx | 21 ++++--- app/(auth)/portal/page.tsx | 36 +++++++----- app/api/forward-auth/login/route.ts | 31 +++++----- app/api/forward-auth/session-login/route.ts | 29 +++++----- .../0018_forward_auth_redirect_intents.sql | 12 ++++ drizzle/meta/_journal.json | 7 +++ src/lib/db/schema.ts | 16 +++++ src/lib/models/forward-auth.ts | 58 +++++++++++++++++++ 8 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 drizzle/0018_forward_auth_redirect_intents.sql diff --git a/app/(auth)/portal/PortalLoginForm.tsx b/app/(auth)/portal/PortalLoginForm.tsx index b7906e13..c8f2acf0 100644 --- a/app/(auth)/portal/PortalLoginForm.tsx +++ b/app/(auth)/portal/PortalLoginForm.tsx @@ -11,14 +11,16 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Separator } from "@/components/ui/separator"; interface PortalLoginFormProps { - redirectUri: string; + rid: string; + hasRedirect: boolean; targetDomain: string; enabledProviders?: Array<{ id: string; name: string }>; existingSession?: { userId: string; name: string | null; email: string | null } | null; } export default function PortalLoginForm({ - redirectUri, + rid, + hasRedirect, targetDomain, enabledProviders = [], existingSession, @@ -29,12 +31,12 @@ export default function PortalLoginForm({ // If user already has a NextAuth session (e.g. from OAuth), auto-create forward auth session useEffect(() => { - if (existingSession && redirectUri) { + if (existingSession && rid) { setPending(true); fetch("/api/forward-auth/session-login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ redirectUri }), + body: JSON.stringify({ rid }), }) .then((res) => res.json()) .then((data) => { @@ -50,7 +52,7 @@ export default function PortalLoginForm({ setPending(false); }); } - }, [existingSession, redirectUri]); + }, [existingSession, rid]); const handleCredentialSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -71,7 +73,7 @@ export default function PortalLoginForm({ const response = await fetch("/api/forward-auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password, redirectUri }), + body: JSON.stringify({ username, password, rid }), }); const data = await response.json(); @@ -92,14 +94,15 @@ export default function PortalLoginForm({ const handleOAuthSignIn = (providerId: string) => { setError(null); setOauthPending(providerId); - // Redirect back to this portal page after OAuth, with the rd param preserved - const callbackUrl = `/portal?rd=${encodeURIComponent(redirectUri)}`; + // Redirect back to this portal page after OAuth, with the rid param preserved. + // The rid is an opaque server-side ID — the actual redirect URI is never in the URL. + const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`; signIn(providerId, { callbackUrl }); }; const disabled = pending || !!oauthPending; - if (!redirectUri) { + if (!hasRedirect) { return (
diff --git a/app/(auth)/portal/page.tsx b/app/(auth)/portal/page.tsx index 0177bbc9..7720d68f 100644 --- a/app/(auth)/portal/page.tsx +++ b/app/(auth)/portal/page.tsx @@ -1,29 +1,38 @@ import { auth } from "@/src/lib/auth"; import { getEnabledOAuthProviders } from "@/src/lib/config"; -import { isForwardAuthDomain } from "@/src/lib/models/forward-auth"; +import { isForwardAuthDomain, createRedirectIntent } from "@/src/lib/models/forward-auth"; import PortalLoginForm from "./PortalLoginForm"; interface PortalPageProps { - searchParams: Promise<{ rd?: string }>; + searchParams: Promise<{ rd?: string; rid?: string }>; } export default async function PortalPage({ searchParams }: PortalPageProps) { const params = await searchParams; const redirectUri = params.rd ?? ""; + // After OAuth callback, the portal is loaded with ?rid= (the opaque ID we created earlier) + const existingRid = params.rid ?? ""; - // Only display the target domain if it's a genuine forward-auth-protected host. - // This prevents attackers from using the portal to phish with arbitrary domain names - // and avoids leaking the list of configured proxies (we only confirm/deny a specific domain). + // Two entry modes: + // 1. Fresh from Caddy redirect: ?rd= → validate, store server-side, create rid + // 2. Returning from OAuth: ?rid= → reuse the existing rid (redirect already stored) let targetDomain = ""; - try { - if (redirectUri) { - const hostname = new URL(redirectUri).hostname; - if (await isForwardAuthDomain(hostname)) { - targetDomain = hostname; + let rid = existingRid; + if (!rid && redirectUri) { + try { + const parsed = new URL(redirectUri); + if ( + (parsed.protocol === "https:" || parsed.protocol === "http:") && + await isForwardAuthDomain(parsed.hostname) + ) { + targetDomain = parsed.hostname; + // Store the redirect URI server-side. The client only gets an opaque ID, + // so a tampered ?rd= parameter cannot influence the final redirect target. + rid = await createRedirectIntent(redirectUri); } + } catch { + // invalid URL — portal will show a generic message } - } catch { - // invalid URL — will be caught by the login endpoint } const session = await auth(); @@ -31,7 +40,8 @@ export default async function PortalPage({ searchParams }: PortalPageProps) { return ( statement-breakpoint +CREATE UNIQUE INDEX `fari_rid_hash_unique` ON `forward_auth_redirect_intents` (`rid_hash`); +--> statement-breakpoint +CREATE INDEX `fari_expires_idx` ON `forward_auth_redirect_intents` (`expires_at`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3b62df4b..26f3760b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1775500000000, "tag": "0017_forward_auth", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1775600000000, + "tag": "0018_forward_auth_redirect_intents", + "breakpoints": true } ] } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d69a0c16..d4e944d7 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -428,6 +428,22 @@ export const forwardAuthExchanges = sqliteTable( }) ); +export const forwardAuthRedirectIntents = sqliteTable( + "forward_auth_redirect_intents", + { + id: integer("id").primaryKey({ autoIncrement: true }), + ridHash: text("rid_hash").notNull(), + redirectUri: text("redirect_uri").notNull(), + expiresAt: text("expires_at").notNull(), + consumed: integer("consumed", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at").notNull() + }, + (table) => ({ + ridHashUnique: uniqueIndex("fari_rid_hash_unique").on(table.ridHash), + expiresIdx: index("fari_expires_idx").on(table.expiresAt) + }) +); + // ── L4 Proxy Hosts ─────────────────────────────────────────────────── export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", { diff --git a/src/lib/models/forward-auth.ts b/src/lib/models/forward-auth.ts index 8afc3b8c..4403aaff 100644 --- a/src/lib/models/forward-auth.ts +++ b/src/lib/models/forward-auth.ts @@ -5,17 +5,75 @@ import { forwardAuthSessions, forwardAuthExchanges, forwardAuthAccess, + forwardAuthRedirectIntents, groupMembers, } from "../db/schema"; import { and, eq, gt, inArray, lt } from "drizzle-orm"; const DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60; // 7 days in seconds const EXCHANGE_CODE_TTL = 60; // 60 seconds +const REDIRECT_INTENT_TTL = 10 * 60; // 10 minutes — covers login + OAuth flow time function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } +// ── Redirect Intents ──────────────────────────────────────────────── +// Store redirect URIs server-side so the client only holds an opaque ID. + +export async function createRedirectIntent(redirectUri: string): Promise { + const rid = randomBytes(16).toString("hex"); + const ridHash = hashToken(rid); + const now = nowIso(); + const expiresAt = new Date(Date.now() + REDIRECT_INTENT_TTL * 1000).toISOString(); + + await db.insert(forwardAuthRedirectIntents).values({ + ridHash, + redirectUri, + expiresAt, + consumed: false, + createdAt: now + }); + + // Opportunistic cleanup of expired intents + await db + .delete(forwardAuthRedirectIntents) + .where(lt(forwardAuthRedirectIntents.expiresAt, now)); + + return rid; +} + +export async function consumeRedirectIntent( + rid: string +): Promise { + const ridHash = hashToken(rid); + const now = nowIso(); + + // Atomic claim: only succeeds if the intent exists, is unconsumed, and not expired + const claimed = await db + .update(forwardAuthRedirectIntents) + .set({ consumed: true }) + .where( + and( + eq(forwardAuthRedirectIntents.ridHash, ridHash), + eq(forwardAuthRedirectIntents.consumed, false), + gt(forwardAuthRedirectIntents.expiresAt, now) + ) + ) + .returning(); + + if (claimed.length === 0) return null; + + const redirectUri = claimed[0].redirectUri; + + // Delete immediately after consumption + await db + .delete(forwardAuthRedirectIntents) + .where(eq(forwardAuthRedirectIntents.id, claimed[0].id)); + + return redirectUri; +} + // ── Sessions ───────────────────────────────────────────────────────── export type ForwardAuthSession = {