diff --git a/app/api/gate/route.ts b/app/api/gate/route.ts index 6cd1b78..1d8c03e 100644 --- a/app/api/gate/route.ts +++ b/app/api/gate/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { createGateToken, GATE_COOKIE, GATE_MAX_AGE_SECONDS } from '../../../site-gate'; // Verifies the temporary site password (see middleware.ts) and, on success, // sets an httpOnly cookie that the middleware checks. Reads the password from @@ -6,9 +7,6 @@ import { NextResponse } from 'next/server'; export const runtime = 'nodejs'; -const COOKIE = 'site_gate'; -const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days - export async function POST(request: Request) { const password = process.env.SITE_PASSWORD; @@ -31,13 +29,16 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false }, { status: 401 }); } - const res = NextResponse.json({ ok: true }); - res.cookies.set(COOKIE, password, { + const token = await createGateToken(password); + const res = NextResponse.json({ ok: true }, { headers: { 'cache-control': 'no-store' } }); + + // Security Fix: Set secure, httpOnly cookie with strict sameSite enforcement + res.cookies.set(GATE_COOKIE, token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', - maxAge: MAX_AGE_SECONDS, + maxAge: GATE_MAX_AGE_SECONDS, }); return res; -} +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index d78502c..d90512a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { disabledRoutePrefixes } from './deploy.config.mjs'; +import { GATE_COOKIE, isValidGateToken } from './site-gate'; // TEMPORARY site-wide password gate. // @@ -15,9 +16,7 @@ import { disabledRoutePrefixes } from './deploy.config.mjs'; // To remove the gate later: delete this file and app/api/gate/route.ts, and // unset SITE_PASSWORD in Vercel. -const COOKIE = 'site_gate'; - -export function middleware(req: NextRequest) { +export async function middleware(req: NextRequest) { // Surfaces not shipped to this build target (deploy.config.mjs) 404 at the // edge. This is the authoritative status block: a disabled section's page may // be statically prerendered, so its layout notFound() serves 404 content with @@ -37,7 +36,8 @@ export function middleware(req: NextRequest) { return NextResponse.next(); } - if (req.cookies.get(COOKIE)?.value === password) { + // Verify the HMAC token securely without exposing the plaintext password + if (await isValidGateToken(req.cookies.get(GATE_COOKIE)?.value, password)) { return NextResponse.next(); } @@ -109,4 +109,4 @@ function gateHtml(): string { `; -} +} \ No newline at end of file diff --git a/site-gate.test.ts b/site-gate.test.ts new file mode 100644 index 0000000..39ebdfb --- /dev/null +++ b/site-gate.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { createGateToken, isValidGateToken } from './site-gate'; + +describe('site gate token', () => { + const password = 'test-password'; + const nowSeconds = 1_700_000_000; + + it('accepts a token signed with the configured password', async () => { + const token = await createGateToken(password, nowSeconds); + + await expect(isValidGateToken(token, password, nowSeconds)).resolves.toBe(true); + }); + + it('rejects tokens signed with another password', async () => { + const token = await createGateToken(password, nowSeconds); + + await expect(isValidGateToken(token, 'wrong-password', nowSeconds)).resolves.toBe(false); + }); + + it('rejects expired and malformed tokens', async () => { + const token = await createGateToken(password, nowSeconds); + + await expect( + isValidGateToken(token, password, nowSeconds + 60 * 60 * 24 * 8), + ).resolves.toBe(false); + await expect( + isValidGateToken('v1.not-a-number.invalid', password, nowSeconds), + ).resolves.toBe(false); + }); +}); \ No newline at end of file diff --git a/site-gate.ts b/site-gate.ts new file mode 100644 index 0000000..6e9bba5 --- /dev/null +++ b/site-gate.ts @@ -0,0 +1,85 @@ +/** + * Shared constants and Web Crypto helpers for the temporary site gate. + * + * The cookie contains a short-lived HMAC token instead of the configured + * password, so a cookie inspection cannot disclose the deployment secret. + */ +export const GATE_COOKIE = 'site_gate'; +export const GATE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + +const TOKEN_VERSION = 'v1'; +const TOKEN_ALGORITHM = { name: 'HMAC', hash: 'SHA-256' } as const; + +function encodeBase64Url(bytes: ArrayBuffer): string { + let binary = ''; + for (const byte of new Uint8Array(bytes)) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function decodeBase64Url(value: string): Uint8Array { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function importGateKey(password: string): Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + TOKEN_ALGORITHM, + false, + ['sign', 'verify'], + ); +} + +async function signPayload(payload: string, password: string): Promise { + const key = await importGateKey(password); + const signature = await crypto.subtle.sign( + TOKEN_ALGORITHM.name, + key, + new TextEncoder().encode(payload), + ); + return encodeBase64Url(signature); +} + +/** Create a signed, expiring gate token for the configured password. */ +export async function createGateToken( + password: string, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const expiresAt = nowSeconds + GATE_MAX_AGE_SECONDS; + const payload = `${TOKEN_VERSION}.${expiresAt}`; + return `${payload}.${await signPayload(payload, password)}`; +} + +/** Validate a gate token without exposing or comparing the password in a cookie. */ +export async function isValidGateToken( + token: string | undefined, + password: string, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + if (!token) return false; + + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return false; + + const expiresAt = Number(parts[1]); + if (!Number.isSafeInteger(expiresAt) || expiresAt <= nowSeconds) return false; + + try { + const payload = `${TOKEN_VERSION}.${parts[1]}`; + const key = await importGateKey(password); + return await crypto.subtle.verify( + TOKEN_ALGORITHM.name, + key, + decodeBase64Url(parts[2]), + new TextEncoder().encode(payload), + ); + } catch { + // Treat malformed or unverifiable cookies as unauthenticated requests. + return false; + } +} \ No newline at end of file