From 9c9f35734e795e3c2cea21384349b655d7ffa164 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 14 Dec 2025 23:24:27 -0800 Subject: Add cors flags --- README.md | 2 +- src/activity/index.ts | 3 +- src/index.ts | 6 +- src/server/index.ts | 199 +++++++++++++++++++++++++++++++++++++++++++------- src/storage/index.ts | 1 - test/cors.test.ts | 39 ++++++++++ test/storage.test.ts | 3 - 7 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 test/cors.test.ts diff --git a/README.md b/README.md index a59bcc1..4b741e1 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,6 @@ UUID: 123e4567-e89b-12d3-a456-426614174000 Requests are stored per-topic in a per-request directory: `data/{routeName}/{timestamp}_{uuid}/`: - `request.json` - full stored request (headers + parsed body + file metadata) -- `body.json` - parsed body only (convenience) - `files/` - uploaded files (multipart only) Example `request.json`: @@ -381,6 +380,7 @@ Then configure your reverse proxy: - `--host` - Server host (default: 0.0.0.0) - `--data-dir` - Data storage directory (default: ./data) - `--token-secret` - HMAC secret for token signing (optional, generates random if not provided) +- `--cors-origins` - Allowed CORS origins (default: `*`). Supports `*`, exact origins (`https://a.com`), and host patterns (`liz.coffee`, `*.liz.coffee`). When not `*`, only `https` origins are allowed. Responses only set `Access-Control-Allow-Origin` (preflight uses the standard allow-\* headers). ### Environment Variables: diff --git a/src/activity/index.ts b/src/activity/index.ts index c3ee821..e14507d 100644 --- a/src/activity/index.ts +++ b/src/activity/index.ts @@ -381,12 +381,11 @@ export class WebhookActivityImpl implements IWebhookActivity { } const baseName = `${storedRequest.timestamp}_${storedRequest.uuid}`; - const storedPath = `${baseName}/request.json`; return tReq.move( Either.right({ success: true, - stored: storedPath, + stored: baseName, redirect, }), ); diff --git a/src/index.ts b/src/index.ts index cbc946a..0ece985 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,13 @@ import { TokenSigner } from './token/index.js'; const main = async (_argv = process.argv.slice(2)): Promise> => { const argsResult = argv( - ['--port', '--host', '--data-dir', '--token-secret'], + ['--port', '--host', '--data-dir', '--token-secret', '--cors-origins'], { '--port': { absent: 9000, present: (port) => parseInt(port) }, '--host': { absent: '0.0.0.0', present: (host) => host }, '--data-dir': { absent: './data', present: (dir) => dir }, '--token-secret': { absent: undefined, present: (secret) => secret }, + '--cors-origins': { absent: '*', present: (origins) => origins }, }, _argv, ); @@ -23,6 +24,7 @@ const main = async (_argv = process.argv.slice(2)): Promise host: args['--host'], dataDir: args['--data-dir'], tokenSecret: args['--token-secret'], + corsOrigins: args['--cors-origins'], })) .flatMapAsync(async (config) => { // Initialize storage @@ -47,7 +49,7 @@ const main = async (_argv = process.argv.slice(2)): Promise console.log(`Starting server on ${config.host}:${config.port}`); // Create and start server - const server = new PosthookServer(storage, signer); + const server = new PosthookServer(storage, signer, config.corsOrigins); const hono = new HonoProxy(server); return hono.serve(config.port, config.host); diff --git a/src/server/index.ts b/src/server/index.ts index 0747680..a38b47a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,7 @@ import { type IHealthCheckActivity, type ITraceable, PenguenoRequest, + PenguenoResponse, Server, type ServerTrace, } from '@emprespresso/pengueno'; @@ -29,10 +30,63 @@ const defaultHealthCheck: HealthChecker = async (input) => { return Either.right(HealthCheckOutput.YAASSSLAYQUEEN); }; +type CorsOriginRule = + | { kind: 'origin'; origin: string } + | { kind: 'host'; host: string; port?: string } + | { kind: 'wildcard-host'; suffix: string }; + +type AllowedCorsOrigins = '*' | CorsOriginRule[]; + +const parseAllowedCorsOrigins = (raw: string): AllowedCorsOrigins => { + const trimmed = raw.trim(); + if (trimmed === '' || trimmed === '*') { + return '*'; + } + + const rules = trimmed + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .flatMap((entry) => { + // Full origin (scheme + host [+ port]) + if (entry.includes('://')) { + try { + const parsed = new URL(entry); + return [{ kind: 'origin', origin: parsed.origin }]; + } catch { + return []; + } + } + + // Wildcard hostname (matches any subdomain, not apex) + if (entry.startsWith('*.')) { + const suffix = entry.slice(2).toLowerCase(); + return suffix.length > 0 ? [{ kind: 'wildcard-host', suffix }] : []; + } + + // Hostname (optionally with :port) + const hostPortMatch = entry.match(/^(.+?)(?::(\d+))?$/); + if (!hostPortMatch) { + return []; + } + + const host = hostPortMatch[1]!.toLowerCase(); + const port = hostPortMatch[2]; + if (host.length === 0) { + return []; + } + + return [{ kind: 'host', host, port }]; + }); + + return rules.length === 0 ? '*' : rules; +}; + export class PosthookServer implements Server { constructor( storage: Storage, signer: TokenSigner, + corsOriginsRaw: string = '*', healthCheck: HealthChecker = defaultHealthCheck, private readonly healthCheckActivity: IHealthCheckActivity = new HealthCheckActivityImpl(healthCheck), private readonly registerRouteActivity: IRegisterRouteActivity = new RegisterRouteActivityImpl(storage), @@ -40,45 +94,140 @@ export class PosthookServer implements Server { private readonly tokenGenerateActivity: ITokenGenerateActivity = new TokenGenerateActivityImpl(storage, signer), private readonly listRoutesActivity: IListRoutesActivity = new ListRoutesActivityImpl(storage), private readonly fourOhFourActivity: IFourOhFourActivity = new FourOhFourActivityImpl(), + private readonly corsOrigins: AllowedCorsOrigins = parseAllowedCorsOrigins(corsOriginsRaw), ) {} - public serve(req: ITraceable) { - const url = new URL(req.get().req.url); - const { pathname } = url; + private corsHeaders(origin: string | undefined): Record { + if (origin === undefined) { + return {}; + } - // === Public Routes (/) === + // Special case: allow everything (including http origins). + if (this.corsOrigins === '*') { + return { + 'Access-Control-Allow-Origin': '*', + }; + } - // Health check endpoint - if (pathname === '/health') { - return this.healthCheckActivity.checkHealth(req); + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return {}; } - // Token generation endpoint - /hook/{routeName}/token - const tokenMatch = pathname.match(/^\/hook\/([^/]+)\/token$/); - if (tokenMatch && req.get().req.method === 'GET') { - const routeName = tokenMatch[1]; - return this.tokenGenerateActivity.generateToken(routeName)(req); + // If we're restricting origins, only allow https. + if (parsed.protocol !== 'https:') { + return {}; } - // Dynamic webhook endpoints - /hook/{routeName} - const hookMatch = pathname.match(/^\/hook\/([^/]+)$/); - if (hookMatch && req.get().req.method === 'POST') { - const routeName = hookMatch[1]; - return this.webhookActivity.processWebhook(routeName)(req); + const candidateOrigin = parsed.origin; + const hostname = parsed.hostname.toLowerCase(); + const port = parsed.port; + + const allowed = this.corsOrigins.some((rule) => { + if (rule.kind === 'origin') { + return candidateOrigin === rule.origin; + } + + if (rule.kind === 'host') { + if (hostname !== rule.host) { + return false; + } + + return rule.port === undefined ? true : rule.port === port; + } + + // wildcard-host + if (hostname === rule.suffix) { + return false; + } + + return hostname.endsWith(`.${rule.suffix}`); + }); + + if (!allowed) { + return {}; + } + + return { + 'Access-Control-Allow-Origin': candidateOrigin, + }; + } + + private corsPreflightHeaders(requestedHeaders: string | undefined): Record { + const allowHeaders = + requestedHeaders?.trim() ?? 'Content-Type, X-CSRF-Token, H-Captcha-Response, Authorization, Accept, Origin'; + + return { + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': allowHeaders, + 'Access-Control-Max-Age': '86400', + }; + } + + private applyCorsHeaders(res: PenguenoResponse, headers: Record) { + for (const [key, value] of Object.entries(headers)) { + res.headers[key] = value; } + } - // === Admin Routes (/admin) - Put behind OAuth proxy === + public async serve(req: ITraceable): Promise { + const penguenoReq = req.get().req; + const method = penguenoReq.method; + const requestHeaders = penguenoReq.header(); + const origin = requestHeaders['origin']; + const corsHeaders = this.corsHeaders(origin); - // Admin endpoints for route management - if (pathname === '/admin/routes' && req.get().req.method === 'POST') { - return this.registerRouteActivity.registerRoute(req); + // Handle CORS preflight. + if (method === 'OPTIONS' && origin !== undefined && Object.keys(corsHeaders).length > 0) { + return new PenguenoResponse(req, '', { + status: 204, + statusText: 'No Content', + headers: { + ...corsHeaders, + ...this.corsPreflightHeaders(requestHeaders['access-control-request-headers']), + }, + }); + } + + const url = new URL(penguenoReq.url); + const { pathname } = url; + + let result: Promise; + + // === Public Routes (/) === + if (pathname === '/health') { + result = this.healthCheckActivity.checkHealth(req); + } else { + // Token generation endpoint - /hook/{routeName}/token + const tokenMatch = pathname.match(/^\/hook\/([^/]+)\/token$/); + if (tokenMatch && method === 'GET') { + const routeName = tokenMatch[1]; + result = this.tokenGenerateActivity.generateToken(routeName)(req); + } else { + // Dynamic webhook endpoints - /hook/{routeName} + const hookMatch = pathname.match(/^\/hook\/([^/]+)$/); + if (hookMatch && method === 'POST') { + const routeName = hookMatch[1]; + result = this.webhookActivity.processWebhook(routeName)(req); + } else if (pathname === '/admin/routes' && method === 'POST') { + // === Admin Routes (/admin) - Put behind OAuth proxy === + result = this.registerRouteActivity.registerRoute(req); + } else if (pathname === '/admin/routes' && method === 'GET') { + result = this.listRoutesActivity.listRoutes(req); + } else { + // 404 for everything else + result = this.fourOhFourActivity.fourOhFour(req); + } + } } - if (pathname === '/admin/routes' && req.get().req.method === 'GET') { - return this.listRoutesActivity.listRoutes(req); + const response = await result; + if (Object.keys(corsHeaders).length > 0) { + this.applyCorsHeaders(response, corsHeaders); } - // 404 for everything else - return this.fourOhFourActivity.fourOhFour(req); + return response; } } diff --git a/src/storage/index.ts b/src/storage/index.ts index 631fc2e..c3c97d8 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -152,7 +152,6 @@ export class Storage { }; await writeFile(join(requestDir, 'request.json'), JSON.stringify(stored, null, 2)); - await writeFile(join(requestDir, 'body.json'), JSON.stringify(body, null, 2)); return Either.right(stored); } catch (err) { return Either.left(err instanceof Error ? err : new Error(String(err))); diff --git a/test/cors.test.ts b/test/cors.test.ts new file mode 100644 index 0000000..621c263 --- /dev/null +++ b/test/cors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { PosthookServer } from '../src/server/index.js'; + +const corsHeaders = (corsOriginsRaw: string, origin: string | undefined) => { + const server = new PosthookServer({} as any, {} as any, corsOriginsRaw); + return (server as any).corsHeaders(origin) as Record; +}; + +describe('CORS origin matching', () => { + it('defaults to allow-all with *', () => { + expect(corsHeaders('*', 'http://localhost:8080')).toEqual({ + 'Access-Control-Allow-Origin': '*', + }); + }); + + it('supports apex and wildcard host matching over https', () => { + expect(corsHeaders('*.liz.coffee,liz.coffee', 'https://liz.coffee')).toEqual({ + 'Access-Control-Allow-Origin': 'https://liz.coffee', + }); + + expect(corsHeaders('*.liz.coffee,liz.coffee', 'https://beta.posthook.liz.coffee')).toEqual({ + 'Access-Control-Allow-Origin': 'https://beta.posthook.liz.coffee', + }); + + expect(corsHeaders('*.liz.coffee,liz.coffee', 'https://evil.com')).toEqual({}); + }); + + it('rejects http origins when restricted', () => { + expect(corsHeaders('*.liz.coffee,liz.coffee', 'http://liz.coffee')).toEqual({}); + }); + + it('does not match apex with wildcard alone', () => { + expect(corsHeaders('*.liz.coffee', 'https://liz.coffee')).toEqual({}); + expect(corsHeaders('*.liz.coffee', 'https://a.liz.coffee')).toMatchObject({ + 'Access-Control-Allow-Origin': 'https://a.liz.coffee', + }); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts index 7b64aa1..200c81a 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -97,9 +97,6 @@ describe('Storage', () => { expect(requestJson.routeName).toBe('route1'); expect(requestJson.files?.[0].filename).toBe(storedFile.filename); - const bodyJson = JSON.parse(await readFile(join(requestDir, 'body.json'), 'utf-8')); - expect(bodyJson).toEqual({ hello: 'world' }); - const savedBytes = await readFile(join(requestDir, storedFile.path)); expect(savedBytes.length).toBe(3); }); -- cgit v1.2.3-70-g09d2