diff --git a/apps/dashboard/src/server/features/auth/routes.ts b/apps/dashboard/src/server/features/auth/routes.ts index 3cf0c22..4090d71 100644 --- a/apps/dashboard/src/server/features/auth/routes.ts +++ b/apps/dashboard/src/server/features/auth/routes.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import { config } from "@fluxcore/config"; import { buildCallbackUrl, getAuthorizationUrl, @@ -7,6 +8,7 @@ import { fetchGuilds, } from "../../shared/auth.js"; import { createSession, deleteSession, getSession } from "../../shared/session.js"; +import { generateCsrfToken } from "../../shared/csrf.js"; import { logger } from "@fluxcore/utils"; const isProduction = process.env.NODE_ENV === "production"; @@ -18,17 +20,14 @@ const authRateLimit = { }; export function registerAuthRoutes(app: FastifyInstance): void { - app.get("/auth/login", { ...authRateLimit }, async (request, reply) => { - const proto = (request.headers["x-forwarded-proto"] as string) || request.protocol; - const host = request.headers["x-forwarded-host"] as string || request.hostname; - const origin = `${proto}://${host}`; - const callbackUrl = buildCallbackUrl(origin); + app.get("/auth/login", { ...authRateLimit }, async (_request, reply) => { + const callbackUrl = buildCallbackUrl(config.dashboardPublicUrl); const { url, state } = getAuthorizationUrl(callbackUrl); reply .setCookie("oauth_state", state, { path: "/", httpOnly: true, - sameSite: "lax", + sameSite: "strict", secure: isProduction, signed: true, maxAge: 300, // 5 minutes @@ -55,15 +54,19 @@ export function registerAuthRoutes(app: FastifyInstance): void { const unsignedState = request.unsignCookie(stateCookie); if (!unsignedState.valid || unsignedState.value !== state) { - reply.code(403).send({ error: "Invalid state parameter" }); + reply + .clearCookie("oauth_state", { path: "/" }) + .code(403) + .send({ error: "Invalid state parameter" }); return; } + // State is valid — burn it immediately so it cannot be replayed + // regardless of whether the rest of the flow succeeds. + reply.clearCookie("oauth_state", { path: "/" }); + try { - const proto = (request.headers["x-forwarded-proto"] as string) || request.protocol; - const host = request.headers["x-forwarded-host"] as string || request.hostname; - const origin = `${proto}://${host}`; - const callbackUrl = buildCallbackUrl(origin); + const callbackUrl = buildCallbackUrl(config.dashboardPublicUrl); const token = await exchangeCode(code, callbackUrl); const [user, guilds] = await Promise.all([ fetchUser(token.access_token), @@ -79,14 +82,13 @@ export function registerAuthRoutes(app: FastifyInstance): void { }); reply - .clearCookie("oauth_state", { path: "/" }) .setCookie("session", sessionId, { path: "/", httpOnly: true, sameSite: "lax", secure: isProduction, signed: true, - maxAge: 604800, // 7 days + maxAge: 86400, // 24 hours }) .redirect("/"); } catch (error) { @@ -98,6 +100,19 @@ export function registerAuthRoutes(app: FastifyInstance): void { } }); + app.get("/auth/csrf", async (_request, reply) => { + const token = generateCsrfToken(); + reply + .setCookie("csrf_token", token, { + path: "/", + httpOnly: false, // double-submit: JS must read it + sameSite: "lax", + secure: isProduction, + maxAge: 604800, + }) + .send({ token }); + }); + app.get("/auth/logout", async (request, reply) => { const sessionCookie = request.cookies?.session; if (sessionCookie) { diff --git a/apps/dashboard/src/server/index.ts b/apps/dashboard/src/server/index.ts index 70f5515..c94674e 100644 --- a/apps/dashboard/src/server/index.ts +++ b/apps/dashboard/src/server/index.ts @@ -4,6 +4,7 @@ import fastifyHelmet from "@fastify/helmet"; import fastifyRateLimit from "@fastify/rate-limit"; import fastifyStatic from "@fastify/static"; import { join, dirname } from "node:path"; +import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { config } from "@fluxcore/config"; import { logger } from "@fluxcore/utils"; @@ -34,6 +35,7 @@ import { registerStarboardRoutes } from "./features/starboard/routes.js"; import { registerDashboardRoleRoutes } from "./features/permissions/roles-routes.js"; import { registerDashboardPermissionRoutes } from "./features/permissions/routes.js"; import { registerI18n } from "./shared/i18n.js"; +import { requireCsrf } from "./shared/csrf.js"; async function main(): Promise { if (!config.dashboardClientSecret) { @@ -41,10 +43,7 @@ async function main(): Promise { process.exit(1); } - if (process.env.NODE_ENV === "production" && !process.env.DASHBOARD_SESSION_SECRET) { - logger.error("DASHBOARD_SESSION_SECRET is required in production"); - process.exit(1); - } + // DASHBOARD_SESSION_SECRET fail-fast is enforced inside @fluxcore/config await connectDatabase(); @@ -55,11 +54,21 @@ async function main(): Promise { }); await app.register(fastifyHelmet, { + enableCSPNonces: true, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + scriptSrc: [ + "'self'", + (_req, reply) => + `'nonce-${(reply as unknown as { cspNonce: { script: string } }).cspNonce.script}'`, + ], + styleSrc: [ + "'self'", + "https://fonts.googleapis.com", + (_req, reply) => + `'nonce-${(reply as unknown as { cspNonce: { style: string } }).cspNonce.style}'`, + ], fontSrc: ["'self'", "https://fonts.gstatic.com"], imgSrc: ["'self'", "data:", "https://cdn.discordapp.com"], }, @@ -71,6 +80,16 @@ async function main(): Promise { timeWindow: "1 minute", }); + // CSRF double-submit enforcement on mutating /api/* routes + app.addHook("preHandler", async (request, reply) => { + if ( + request.url.startsWith("/api/") && + !["GET", "HEAD", "OPTIONS"].includes(request.method) + ) { + await requireCsrf(request, reply); + } + }); + const __dirname = dirname(fileURLToPath(import.meta.url)); // In production, serve the built React SPA @@ -128,12 +147,18 @@ async function main(): Promise { // SPA fallback: serve index.html for non-API/auth routes in production if (process.env.NODE_ENV === "production") { + const indexHtmlPath = join(__dirname, "../client/index.html"); + const indexHtmlTemplate = await readFile(indexHtmlPath, "utf8"); + app.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith("/api/") || request.url.startsWith("/auth/")) { reply.code(404).send({ error: "Not found" }); return; } - return reply.sendFile("index.html"); + const nonce = (reply as unknown as { cspNonce: { style: string } }) + .cspNonce.style; + const html = indexHtmlTemplate.replace(/