Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions app/api/gate/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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
// SITE_PASSWORD; nothing is hardcoded.

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;

Expand All @@ -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;
}
}
10 changes: 5 additions & 5 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -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
Expand All @@ -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();
}

Expand Down Expand Up @@ -109,4 +109,4 @@ function gateHtml(): string {
</script>
</body>
</html>`;
}
}
30 changes: 30 additions & 0 deletions site-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
85 changes: 85 additions & 0 deletions site-gate.ts
Original file line number Diff line number Diff line change
@@ -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<CryptoKey> {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
TOKEN_ALGORITHM,
false,
['sign', 'verify'],
);
}

async function signPayload(payload: string, password: string): Promise<string> {
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<string> {
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<boolean> {
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;
}
}