diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-12-14 23:24:27 -0800 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-12-15 00:06:20 -0800 |
| commit | 9c9f35734e795e3c2cea21384349b655d7ffa164 (patch) | |
| tree | 66351cc8a4d72f2eabb2a6ec6ee96ed7abbb253f /src | |
| parent | 15e37e367c7e30a8b172c5a379791fb3e0b820b8 (diff) | |
| download | posthook-9c9f35734e795e3c2cea21384349b655d7ffa164.tar.gz posthook-9c9f35734e795e3c2cea21384349b655d7ffa164.zip | |
Add cors flags
Diffstat (limited to 'src')
| -rw-r--r-- | src/activity/index.ts | 3 | ||||
| -rw-r--r-- | src/index.ts | 6 | ||||
| -rw-r--r-- | src/server/index.ts | 199 | ||||
| -rw-r--r-- | src/storage/index.ts | 1 |
4 files changed, 179 insertions, 30 deletions
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<PenguenoError, WebhookResult>({ 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<IEither<Error, void>> => { 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<IEither<Error, void> 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<IEither<Error, void> 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<CorsOriginRule>((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<PenguenoRequest, ServerTrace>) { - const url = new URL(req.get().req.url); - const { pathname } = url; + private corsHeaders(origin: string | undefined): Record<string, string> { + 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<string, string> { + 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<string, string>) { + for (const [key, value] of Object.entries(headers)) { + res.headers[key] = value; } + } - // === Admin Routes (/admin) - Put behind OAuth proxy === + public async serve(req: ITraceable<PenguenoRequest, ServerTrace>): Promise<PenguenoResponse> { + 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<PenguenoResponse>; + + // === 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))); |
