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 --- src/server/index.ts | 199 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 174 insertions(+), 25 deletions(-) (limited to 'src/server/index.ts') 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; } } -- cgit v1.2.3-70-g09d2