diff --git a/api/subscribe.ts b/api/subscribe.ts index cd35870..4f2239a 100644 --- a/api/subscribe.ts +++ b/api/subscribe.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { SUBSCRIBE_API_SECURITY_HEADERS } from '../src/utils/security-headers'; /** * Vercel serverless function — not part of the Vite/tsc build (only `src` is @@ -83,8 +84,10 @@ function logEvent(requestId: string, event: string, status: number) { function sendJson(res: ServerResponse, requestId: string, status: number, payload: unknown) { res.statusCode = status; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Cache-Control', 'no-store'); + for (const [key, value] of Object.entries(SUBSCRIBE_API_SECURITY_HEADERS)) { + res.setHeader(key, value); + } + res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('X-Request-Id', requestId); res.end(JSON.stringify(payload)); } diff --git a/middleware.ts b/middleware.ts index 92d8fac..bb3858b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,6 +5,7 @@ import { CACHE_CONTROL, OG_LOCALE_TAGS, } from './src/utils/og-metadata'; +import { PAGE_SECURITY_HEADERS } from './src/utils/security-headers'; export const config = { matcher: ['/:path*'], @@ -95,7 +96,7 @@ export default async function middleware(request: Request) { html = html.replace(//i, ``); - const headers = new Headers(); + const headers = new Headers(PAGE_SECURITY_HEADERS); headers.set('content-type', 'text/html;charset=UTF-8'); headers.set('cache-control', CACHE_CONTROL); diff --git a/scripts/preview-server.ts b/scripts/preview-server.ts index 2a392fa..a444627 100644 --- a/scripts/preview-server.ts +++ b/scripts/preview-server.ts @@ -37,6 +37,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { readFile } from 'node:fs/promises'; import { dirname, extname, join, normalize, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + PAGE_SECURITY_HEADERS, + SUBSCRIBE_API_SECURITY_HEADERS, + resolveStaticRouteHeaders, +} from '../src/utils/security-headers.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); @@ -78,6 +83,12 @@ function contentType(filePath: string): string { return MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; } +function applyHeaders(res: ServerResponse, headers: Record): void { + for (const [key, value] of Object.entries(headers)) { + res.setHeader(key, value); + } +} + // ─── Edge-runtime shims ───────────────────────────────────────────────────── const realFetch = globalThis.fetch; @@ -167,7 +178,9 @@ function decodePath(rawPath: string): string | null { function badRequest(res: ServerResponse): void { res.statusCode = 400; + applyHeaders(res, PAGE_SECURITY_HEADERS); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); res.end('Bad request'); } @@ -190,12 +203,12 @@ async function serveStatic(req: IncomingMessage, res: ServerResponse): Promise; +} + +const vercelConfig = vercelJson as VercelConfig; + +function headersForSource(source: string): Record { + const rule = vercelConfig.headers?.find((entry) => entry.source === source); + expect(rule, `Missing vercel.json header rule for ${source}`).toBeDefined(); + return Object.fromEntries(rule!.headers.map(({ key, value }) => [key, value])); +} + +function parseCsp(csp: string): Record { + return Object.fromEntries( + csp + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [directive, ...tokens] = part.split(/\s+/); + return [directive!, tokens]; + }), + ); +} + +describe('vercel.json production security headers', () => { + it('defines global security headers on /(.*) matching PAGE_SECURITY_HEADERS', () => { + const globalHeaders = headersForSource('/(.*)'); + expect(globalHeaders).toEqual(PAGE_SECURITY_HEADERS); + expect(globalHeaders['Strict-Transport-Security']).toBe( + 'max-age=63072000; includeSubDomains; preload', + ); + expect(globalHeaders['X-Content-Type-Options']).toBe('nosniff'); + expect(globalHeaders['X-Frame-Options']).toBe('DENY'); + expect(globalHeaders['Referrer-Policy']).toBe('strict-origin-when-cross-origin'); + expect(globalHeaders['Cross-Origin-Opener-Policy']).toBe('same-origin'); + expect(globalHeaders['Permissions-Policy']).toContain('camera=()'); + }); + + it('defines route-appropriate headers for feeds, sitemaps, assets, and API routes', () => { + expect(headersForSource('/assets/(.*)')).toEqual({ + 'Cache-Control': IMMUTABLE_ASSET_HEADERS['Cache-Control']!, + }); + + expect(headersForSource('/fonts/(.*)')).toEqual({ + 'Cache-Control': FONT_ASSET_HEADERS['Cache-Control']!, + 'Access-Control-Allow-Origin': '*', + }); + + for (const mediaSource of ['/og/(.*)', '/press-kit/(.*)', '/press-kit.zip']) { + expect(headersForSource(mediaSource)).toEqual({ + 'Cache-Control': STATIC_MEDIA_HEADERS['Cache-Control']!, + }); + } + + for (const feedSource of ['/feed.xml', '/feed/tag/(.*)']) { + expect(headersForSource(feedSource)).toEqual({ + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': FEED_SECURITY_HEADERS['Cache-Control']!, + 'Content-Security-Policy': FEED_CONTENT_SECURITY_POLICY, + }); + } + + expect(headersForSource('/sitemap.xml')).toEqual({ + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': FEED_SECURITY_HEADERS['Cache-Control']!, + 'Content-Security-Policy': FEED_CONTENT_SECURITY_POLICY, + }); + + expect(headersForSource('/api/(.*)')).toEqual({ + 'Content-Security-Policy': API_CONTENT_SECURITY_POLICY, + }); + + expect(headersForSource('/api/subscribe')).toEqual({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + }); + + expect(headersForSource('/api/og')).toEqual({ + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + }); +}); + +describe('Content-Security-Policy compatibility', () => { + const directives = parseCsp(PAGE_CONTENT_SECURITY_POLICY); + + it('allows self-hosted fonts and data: font URIs', () => { + expect(directives['font-src']).toEqual(expect.arrayContaining(["'self'", 'data:'])); + }); + + it('allows Plausible analytics script and event beacon endpoints', () => { + expect(directives['script-src']).toEqual( + expect.arrayContaining(["'self'", "'unsafe-inline'", 'https://plausible.io']), + ); + expect(directives['connect-src']).toEqual(expect.arrayContaining(['https://plausible.io'])); + }); + + it('allows Soroban/Horizon RPC and Wraith status endpoints used by interactive tools', () => { + expect(directives['connect-src']).toEqual( + expect.arrayContaining([ + "'self'", + 'https://soroban-testnet.stellar.org', + 'https://soroban-rpc.stellar.org', + 'https://horizon-testnet.stellar.org', + 'https://horizon.stellar.org', + 'https://status.usewraith.xyz', + ]), + ); + }); + + it('stays compatible with inline critical CSS, bootstrap scripts, and generated JSON-LD blocks', () => { + expect(directives['style-src']).toEqual(expect.arrayContaining(["'self'", "'unsafe-inline'"])); + expect(directives['script-src']).toEqual(expect.arrayContaining(["'self'", "'unsafe-inline'"])); + // Ensure CSP does not contain a sha256-* or nonce-* token in script-src or + // style-src that would cause browsers to ignore 'unsafe-inline' for static HTML. + for (const token of [...(directives['script-src'] ?? []), ...(directives['style-src'] ?? [])]) { + expect(token).not.toMatch(/^'(sha256|sha384|sha512|nonce)-/); + } + }); + + it('hardens framing, object embedding, base-uri, and form-action', () => { + expect(directives['frame-ancestors']).toEqual(["'none'"]); + expect(directives['object-src']).toEqual(["'none'"]); + expect(directives['base-uri']).toEqual(["'self'"]); + expect(directives['form-action']).toEqual(["'self'"]); + }); +}); + +describe('resolveStaticRouteHeaders', () => { + it('resolves route-appropriate headers for pages, feeds, fonts, bundles, and media', () => { + const page = resolveStaticRouteHeaders('/stellar', '.html'); + expect(page.contentType).toBe('text/html; charset=utf-8'); + expect(page.cacheControl).toBe('no-store'); + expect(page.headers['Content-Security-Policy']).toBe(PAGE_CONTENT_SECURITY_POLICY); + expect(page.headers['X-Content-Type-Options']).toBe('nosniff'); + + const rss = resolveStaticRouteHeaders('/feed.xml', '.xml'); + expect(rss.contentType).toBe('application/rss+xml; charset=utf-8'); + expect(rss.headers['Content-Security-Policy']).toBe(FEED_CONTENT_SECURITY_POLICY); + + const tagRss = resolveStaticRouteHeaders('/feed/tag/wave-7.xml', '.xml'); + expect(tagRss.contentType).toBe('application/rss+xml; charset=utf-8'); + + const sitemap = resolveStaticRouteHeaders('/sitemap.xml', '.xml'); + expect(sitemap.contentType).toBe('application/xml; charset=utf-8'); + + const font = resolveStaticRouteHeaders('/fonts/space-grotesk-700-normal.woff2', '.woff2'); + expect(font.cacheControl).toBe('public, max-age=31536000, immutable'); + expect(font.headers['Access-Control-Allow-Origin']).toBe('*'); + + const bundle = resolveStaticRouteHeaders('/assets/index-abc123.js', '.js'); + expect(bundle.cacheControl).toBe('public, max-age=31536000, immutable'); + + const ogImage = resolveStaticRouteHeaders('/og/home.png', '.png'); + expect(ogImage.cacheControl).toBe('public, max-age=86400, stale-while-revalidate=604800'); + }); +}); + +describe('edge middleware security headers', () => { + it('attaches PAGE_SECURITY_HEADERS on bot responses from middleware.ts', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + text: () => Promise.resolve('Default'), + }), + ); + + const response = await middleware( + new Request('https://usewraith.xyz/stellar', { + headers: { 'user-agent': 'TwitterBot/1.0' }, + }), + ); + + expect(response).toBeDefined(); + for (const [key, value] of Object.entries(PAGE_SECURITY_HEADERS)) { + expect(response!.headers.get(key)).toBe(value); + } + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/api/og.tsx b/src/api/og.tsx index 460c299..30a1410 100644 --- a/src/api/og.tsx +++ b/src/api/og.tsx @@ -1,4 +1,5 @@ import { ImageResponse } from '@vercel/og'; +import { OG_API_SECURITY_HEADERS } from '../utils/security-headers'; export const config = { runtime: 'edge', @@ -66,6 +67,7 @@ export default async function handler(req: Request) { { width: 1200, height: 630, + headers: OG_API_SECURITY_HEADERS, fonts: [ { name: 'Inter', @@ -78,6 +80,13 @@ export default async function handler(req: Request) { ); } catch (e: any) { console.error(`${e.message}`); - return new Response(`Failed to generate the image`, { status: 500 }); + return new Response(`Failed to generate the image`, { + status: 500, + headers: { + ...OG_API_SECURITY_HEADERS, + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); } } diff --git a/src/utils/security-headers.ts b/src/utils/security-headers.ts new file mode 100644 index 0000000..f1f4b15 --- /dev/null +++ b/src/utils/security-headers.ts @@ -0,0 +1,162 @@ +export const PAGE_CSP_DIRECTIVES = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://plausible.io", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data: blob: https://usewraith.xyz", + "connect-src 'self' https://plausible.io https://soroban-testnet.stellar.org https://soroban-rpc.stellar.org https://horizon-testnet.stellar.org https://horizon.stellar.org https://status.usewraith.xyz", + "frame-ancestors 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + 'upgrade-insecure-requests', +] as const; + +export const PAGE_CONTENT_SECURITY_POLICY = PAGE_CSP_DIRECTIVES.join('; '); + +export const API_CONTENT_SECURITY_POLICY = "default-src 'none'; frame-ancestors 'none'"; + +export const FEED_CONTENT_SECURITY_POLICY = + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"; + +export const COMMON_SECURITY_HEADERS: Record = { + 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()', + 'Cross-Origin-Opener-Policy': 'same-origin', +}; + +export const PAGE_SECURITY_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': PAGE_CONTENT_SECURITY_POLICY, +}; + +export const SUBSCRIBE_API_SECURITY_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': API_CONTENT_SECURITY_POLICY, + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', +}; + +export const OG_API_SECURITY_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': API_CONTENT_SECURITY_POLICY, +}; + +export const FEED_SECURITY_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': FEED_CONTENT_SECURITY_POLICY, + 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400', +}; + +export const IMMUTABLE_ASSET_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': PAGE_CONTENT_SECURITY_POLICY, + 'Cache-Control': 'public, max-age=31536000, immutable', +}; + +export const FONT_ASSET_HEADERS: Record = { + ...IMMUTABLE_ASSET_HEADERS, + 'Access-Control-Allow-Origin': '*', +}; + +export const STATIC_MEDIA_HEADERS: Record = { + ...COMMON_SECURITY_HEADERS, + 'Content-Security-Policy': PAGE_CONTENT_SECURITY_POLICY, + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800', +}; + +export interface RouteHeaderResolution { + contentType?: string; + cacheControl: string; + headers: Record; +} + +/** + * Resolves route-appropriate security, cache, and content-type headers for + * pages, feeds, static assets, and API responses so the preview server and + * edge/serverless handlers mirror `vercel.json`. + */ +export function resolveStaticRouteHeaders( + urlPath: string, + candidateExt: string, +): RouteHeaderResolution { + const normalizedPath = urlPath.split('?')[0] ?? '/'; + + // RSS feeds (`/feed.xml` and `/feed/tag/*.xml`) + if ( + normalizedPath === '/feed.xml' || + (normalizedPath.startsWith('/feed/tag/') && normalizedPath.endsWith('.xml')) + ) { + return { + contentType: 'application/rss+xml; charset=utf-8', + cacheControl: FEED_SECURITY_HEADERS['Cache-Control']!, + headers: { ...FEED_SECURITY_HEADERS }, + }; + } + + // XML sitemap (`/sitemap.xml`) + if (normalizedPath === '/sitemap.xml' || candidateExt === '.xml') { + return { + contentType: 'application/xml; charset=utf-8', + cacheControl: FEED_SECURITY_HEADERS['Cache-Control']!, + headers: { ...FEED_SECURITY_HEADERS }, + }; + } + + // Self-hosted fonts (`/fonts/*`) + if ( + normalizedPath.startsWith('/fonts/') || + candidateExt === '.woff2' || + candidateExt === '.woff' + ) { + return { + cacheControl: FONT_ASSET_HEADERS['Cache-Control']!, + headers: { ...FONT_ASSET_HEADERS }, + }; + } + + // Fingerprinted Vite bundle assets (`/assets/*`) + if (normalizedPath.startsWith('/assets/')) { + return { + cacheControl: IMMUTABLE_ASSET_HEADERS['Cache-Control']!, + headers: { ...IMMUTABLE_ASSET_HEADERS }, + }; + } + + // Static OG images & press kit assets (`/og/*`, `/press-kit/*`, `/press-kit.zip`) + if ( + normalizedPath.startsWith('/og/') || + normalizedPath.startsWith('/press-kit/') || + normalizedPath === '/press-kit.zip' + ) { + return { + cacheControl: STATIC_MEDIA_HEADERS['Cache-Control']!, + headers: { ...STATIC_MEDIA_HEADERS }, + }; + } + + // HTML documents / SPA routes + if (candidateExt === '.html' || candidateExt === '') { + return { + contentType: 'text/html; charset=utf-8', + cacheControl: 'no-store', + headers: { + ...PAGE_SECURITY_HEADERS, + 'Cache-Control': 'no-store', + }, + }; + } + + // Other public root assets (`favicon.svg`, `robots.txt`, etc.) + return { + cacheControl: 'public, max-age=3600', + headers: { + ...PAGE_SECURITY_HEADERS, + 'Cache-Control': 'public, max-age=3600', + }, + }; +} diff --git a/tests/smoke/serverless.smoke.test.ts b/tests/smoke/serverless.smoke.test.ts index 9d51b32..2fbbe49 100644 --- a/tests/smoke/serverless.smoke.test.ts +++ b/tests/smoke/serverless.smoke.test.ts @@ -1,5 +1,13 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + API_CONTENT_SECURITY_POLICY, + COMMON_SECURITY_HEADERS, + FEED_CONTENT_SECURITY_POLICY, + PAGE_CONTENT_SECURITY_POLICY, + PAGE_SECURITY_HEADERS, + SUBSCRIBE_API_SECURITY_HEADERS, +} from '../../src/utils/security-headers'; /** * Preview smoke tests for the serverless routes. @@ -106,6 +114,18 @@ function pngSize(bytes: Uint8Array): { width: number; height: number } { const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47]; const isPng = (bytes: Uint8Array) => [...bytes.slice(0, 4)].join() === PNG_SIGNATURE.join(); +function expectCommonSecurityHeaders( + response: Response, + overrides: Partial> = {}, +) { + const expected = { ...COMMON_SECURITY_HEADERS, ...overrides }; + for (const [key, value] of Object.entries(expected)) { + if (value !== undefined) { + expect(response.headers.get(key), `Header ${key} mismatch`).toBe(value); + } + } +} + // ─── /api/subscribe ───────────────────────────────────────────────────────── describe('POST /api/subscribe', () => { @@ -129,6 +149,7 @@ describe('POST /api/subscribe', () => { expect(response.status).toBe(201); expect(response.headers.get('content-type')).toContain('application/json'); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); expect(await response.json()).toEqual({ ok: true }); }); @@ -174,12 +195,14 @@ describe('POST /api/subscribe', () => { }); expect(response.status).toBe(400); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); }); it('answers 405 for non-POST methods', async () => { for (const method of ['GET', 'PUT', 'DELETE']) { const response = await fetch(subscribeUrl, { method }); expect(response.status).toBe(405); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); expect(await response.json()).toEqual({ error: 'Method not allowed.', code: 'method_not_allowed', @@ -247,6 +270,9 @@ describe('GET /api/og', () => { expect(response.headers.get('cache-control')).toContain('public'); expect(response.headers.get('cache-control')).toContain('immutable'); expect(response.headers.get('cache-control')).toContain('max-age=31536000'); + expectCommonSecurityHeaders(response, { + 'Content-Security-Policy': API_CONTENT_SECURITY_POLICY, + }); expect(isPng(bytes)).toBe(true); expect(bytes.byteLength).toBeGreaterThan(1_000); expect(pngSize(bytes)).toEqual({ width: 1200, height: 630 }); @@ -307,6 +333,9 @@ describe('serverless failure responses', () => { const response = await get(`${preview.baseUrl}/api/og?title=Broken`); expect(response.status).toBe(500); + expectCommonSecurityHeaders(response, { + 'Content-Security-Policy': API_CONTENT_SECURITY_POLICY, + }); expect(await response.text()).toContain('Failed to generate the image'); }); @@ -316,6 +345,7 @@ describe('serverless failure responses', () => { }); expect(response.status).toBe(500); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); expect(await response.json()).toEqual({ error: 'Subscription service is not configured.', code: 'not_configured', @@ -330,3 +360,88 @@ describe('serverless failure responses', () => { } }); }); + +// ─── security headers verification (preview smoke server & deployed preview) ── + +describe('production security headers across pages, feeds, assets, and API routes', () => { + let preview: Preview | undefined; + let targetBaseUrl: string; + + beforeAll(async () => { + const externalPreviewUrl = process.env.PREVIEW_URL?.trim(); + if (externalPreviewUrl) { + targetBaseUrl = externalPreviewUrl.replace(/\/+$/, ''); + } else { + preview = await startPreview(); + targetBaseUrl = preview.baseUrl; + } + }); + + afterAll(async () => { + if (preview) { + await preview.stop(); + } + }); + + it('serves HTML pages and SPA fallback routes with PAGE_SECURITY_HEADERS and JSON-LD intact', async () => { + for (const route of ['/', '/stellar', '/blog/wave-7-kickoff', '/spa-client-route']) { + const response = await get(`${targetBaseUrl}${route}`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + expectCommonSecurityHeaders(response, PAGE_SECURITY_HEADERS); + + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toBe(PAGE_CONTENT_SECURITY_POLICY); + + const html = await response.text(); + expect(html).toContain('