From 3b6d7315a6c1abdcfb4ff8434a856a5c304895e8 Mon Sep 17 00:00:00 2001 From: devchant Date: Sun, 31 May 2026 01:47:16 +0100 Subject: [PATCH] security: enforce-secure-cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export authConfig with useSecureCookies and secure session token cookie flags that activate only in production (NODE_ENV === 'production') - Enforce HTTPS in middleware: redirect HTTP → HTTPS (301) in production using both protocol and x-forwarded-proto header checks - Export middlewareCallback for isolated unit testing - Add lib/security.test.ts covering: * Secure cookie options match env (httpOnly, sameSite, secure flag) * Middleware redirects HTTP to HTTPS in production * Middleware does NOT redirect in development (sandbox stays on HTTP) - Set package.json 'type': 'module' for clean ESM resolution - Include security.test.ts in test:unit script Closes #667 --- admin-dashboard/auth.ts | 26 ++++++-- admin-dashboard/lib/security.test.ts | 92 ++++++++++++++++++++++++++++ admin-dashboard/middleware.ts | 18 +++++- admin-dashboard/package.json | 3 +- 4 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 admin-dashboard/lib/security.test.ts diff --git a/admin-dashboard/auth.ts b/admin-dashboard/auth.ts index 092d22a1..16c12bcc 100644 --- a/admin-dashboard/auth.ts +++ b/admin-dashboard/auth.ts @@ -24,7 +24,7 @@ declare module "next-auth/jwt" { } } -export const { handlers, auth, signIn, signOut } = NextAuth({ +export const authConfig = { providers: [ Credentials({ credentials: { @@ -81,16 +81,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }), ], - session: { strategy: "jwt", maxAge: 8 * 60 * 60 }, + session: { strategy: "jwt" as const, maxAge: 8 * 60 * 60 }, + useSecureCookies: process.env.NODE_ENV === "production", + cookies: { + sessionToken: { + name: process.env.NODE_ENV === "production" + ? "__Secure-next-auth.session-token" + : "next-auth.session-token", + options: { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + }, callbacks: { - jwt: async ({ token, user }) => { + jwt: async ({ token, user }: any) => { if (user) { token.role = user.role; token.adminJwt = user.adminJwt; } return token; }, - session: async ({ session, token }) => { + session: async ({ session, token }: any) => { if (session.user) { session.user.role = token.role as string; session.user.adminJwt = token.adminJwt as string | undefined; @@ -99,4 +113,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, pages: { signIn: "/login" }, -}); +}; + +export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); diff --git a/admin-dashboard/lib/security.test.ts b/admin-dashboard/lib/security.test.ts new file mode 100644 index 00000000..b8daa9a0 --- /dev/null +++ b/admin-dashboard/lib/security.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { NextResponse } from "next/server"; +import { authConfig } from "../auth.ts"; +import { middlewareCallback } from "../middleware.ts"; + +test("NextAuth configuration session cookie matches env", () => { + const isProd = process.env.NODE_ENV === "production"; + assert.equal(authConfig.useSecureCookies, isProd); + + const sessionToken = authConfig.cookies?.sessionToken; + assert.ok(sessionToken); + assert.equal(sessionToken.options.secure, isProd); + if (isProd) { + assert.ok(sessionToken.name.startsWith("__Secure-")); + } else { + assert.ok(!sessionToken.name.startsWith("__Secure-")); + } +}); + +test("middlewareCallback redirects according to environment", () => { + const origNodeEnv = process.env.NODE_ENV; + + try { + // 1. Production HTTPS redirect check + process.env.NODE_ENV = "production"; + const mockReqProd = { + nextUrl: { + pathname: "/admin/dashboard", + protocol: "http:", + }, + url: "http://example.com/admin/dashboard", + headers: { + get: (name: string) => { + if (name === "x-forwarded-proto") return "http"; + return null; + } + }, + auth: null, + } as any; + + const originalRedirect = NextResponse.redirect; + let redirectedTo: string | null = null; + let redirectStatus: number | null = null; + + NextResponse.redirect = (url: string | URL, status?: number) => { + redirectedTo = url.toString(); + redirectStatus = status || null; + return { type: "redirect" } as any; + }; + + try { + const res = middlewareCallback(mockReqProd); + assert.ok(res); + assert.equal(redirectedTo, "https://example.com/admin/dashboard"); + assert.equal(redirectStatus, 301); + } finally { + NextResponse.redirect = originalRedirect; + } + + // 2. Development HTTP validation check (should not redirect) + process.env.NODE_ENV = "development"; + const mockReqDev = { + nextUrl: { + pathname: "/admin/dashboard", + protocol: "http:", + }, + url: "http://example.com/admin/dashboard", + headers: { + get: (name: string) => null, + }, + auth: null, + } as any; + + let redirected = false; + NextResponse.redirect = (url: string | URL, status?: number) => { + redirected = true; + return { type: "redirect" } as any; + }; + + try { + const res = middlewareCallback(mockReqDev); + assert.ok(!redirected); + } finally { + NextResponse.redirect = originalRedirect; + } + + } finally { + process.env.NODE_ENV = origNodeEnv; + } +}); +export {}; diff --git a/admin-dashboard/middleware.ts b/admin-dashboard/middleware.ts index 97d6cf14..79b5fbb9 100644 --- a/admin-dashboard/middleware.ts +++ b/admin-dashboard/middleware.ts @@ -1,10 +1,22 @@ import { auth } from "@/auth"; import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; -export default auth((req) => { +export const middlewareCallback = (req: NextRequest & { auth: any }) => { const { pathname } = req.nextUrl; const session = req.auth; + // Force redirect to HTTPS in production + if (process.env.NODE_ENV === "production") { + const xForwardedProto = req.headers.get("x-forwarded-proto"); + const isHttp = req.nextUrl.protocol === "http:" || xForwardedProto === "http"; + if (isHttp) { + const secureUrl = new URL(req.url); + secureUrl.protocol = "https:"; + return NextResponse.redirect(secureUrl.toString(), 301); + } + } + // Protect all /admin/* routes if (pathname.startsWith("/admin")) { if (!session) { @@ -20,7 +32,9 @@ export default auth((req) => { } return NextResponse.next(); -}); +}; + +export default auth(middlewareCallback); // Matcher excludes static assets and Next.js internals automatically export const config = { diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json index 7fed25f5..8247a0a6 100644 --- a/admin-dashboard/package.json +++ b/admin-dashboard/package.json @@ -1,6 +1,7 @@ { "name": "admin-dashboard", "version": "0.1.1", + "type": "module", "private": true, "scripts": { "dev": "next dev", @@ -10,7 +11,7 @@ "test": "npm run test:unit && npm run test:integration", "test:watch": "vitest", "format:check": "echo \"No format check configured\"", - "test:unit": "node --test --experimental-test-isolation=none --experimental-strip-types lib/portal-links.test.ts lib/theme.test.ts", + "test:unit": "node --test --experimental-test-isolation=none --experimental-strip-types lib/portal-links.test.ts lib/theme.test.ts lib/security.test.ts", "test:integration": "node --test --experimental-test-isolation=none --experimental-strip-types lib/theme.integration.test.ts", "test:e2e": "playwright test", "docs": "typedoc",