aboutsummaryrefslogtreecommitdiff
path: root/src/server/index.ts
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 23:24:27 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-15 00:06:20 -0800
commit9c9f35734e795e3c2cea21384349b655d7ffa164 (patch)
tree66351cc8a4d72f2eabb2a6ec6ee96ed7abbb253f /src/server/index.ts
parent15e37e367c7e30a8b172c5a379791fb3e0b820b8 (diff)
downloadposthook-9c9f35734e795e3c2cea21384349b655d7ffa164.tar.gz
posthook-9c9f35734e795e3c2cea21384349b655d7ffa164.zip
Add cors flags
Diffstat (limited to 'src/server/index.ts')
-rw-r--r--src/server/index.ts199
1 files changed, 174 insertions, 25 deletions
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;
}
}