Files
gongxue-base/apps/api/src/core/cors.ts
2026-07-12 19:26:57 +08:00

226 lines
7.8 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'node:http';
import { queryOne } from './db.js';
import { isLocalTenantHost } from '../features/tenant/locator.js';
export type CorsDecisionReason =
| 'no-origin'
| 'wildcard'
| 'static-origin'
| 'tenant-domain'
| 'invalid-origin'
| 'tenant-domain-disabled'
| 'tenant-domain-lookup-failed';
export interface CorsDecision {
allowed: boolean;
allowOrigin: string | null;
reason: CorsDecisionReason;
}
export interface CorsPolicyOptions {
staticOrigins: string[];
tenantDomainsEnabled: boolean;
positiveCacheTtlMs: number;
negativeCacheTtlMs: number;
maxCacheEntries: number;
lookupTenantDomain?: (host: string) => Promise<boolean>;
now?: () => number;
onLookupError?: (error: unknown, host: string) => void;
}
interface NormalizedOrigin {
origin: string;
hostname: string;
protocol: 'http:' | 'https:';
hasNonDefaultPort: boolean;
}
interface CachedTenantDomain {
allowed: boolean;
expiresAt: number;
lookupFailed: boolean;
}
const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key';
function firstHeader(req: IncomingMessage, name: string) {
if (name.toLowerCase() === 'origin') {
const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin');
if (originHeaders.length > 1) return '__multiple_origin_headers__';
}
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) return value[0] || '';
return value || '';
}
function addVaryHeader(res: ServerResponse, value: string) {
const current = res.getHeader('vary');
const values = (Array.isArray(current) ? current : String(current || '').split(','))
.map(item => String(item).trim())
.filter(Boolean);
if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value);
res.setHeader('vary', values.join(', '));
}
export function normalizeCorsOrigin(value: string): NormalizedOrigin | null {
const raw = value.trim();
if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null;
try {
const parsed = new URL(raw);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
if (!parsed.hostname || parsed.hostname.endsWith('.')) return null;
const protocol = parsed.protocol as 'http:' | 'https:';
const defaultPort = protocol === 'https:' ? '443' : '80';
return {
origin: parsed.origin.toLowerCase(),
hostname: parsed.hostname.toLowerCase(),
protocol,
hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort),
};
} catch {
return null;
}
}
async function lookupActiveTenantDomain(host: string) {
const row = await queryOne<{ allowed: boolean }>(
`
select exists (
select 1
from public.tenant_domains d
join public.tenants t on t.id = d.tenant_id
where d.host = $1
and d.status = 'active'
and t.status = 'active'
) as allowed
`,
[host],
);
return row?.allowed === true;
}
export class CorsPolicy {
private readonly staticOrigins: Set<string>;
private readonly allowAll: boolean;
private readonly tenantDomainsEnabled: boolean;
private readonly positiveCacheTtlMs: number;
private readonly negativeCacheTtlMs: number;
private readonly maxCacheEntries: number;
private readonly lookupTenantDomain: (host: string) => Promise<boolean>;
private readonly now: () => number;
private readonly onLookupError?: (error: unknown, host: string) => void;
private readonly cache = new Map<string, CachedTenantDomain>();
private readonly pendingLookups = new Map<string, Promise<{ allowed: boolean; lookupFailed: boolean }>>();
constructor(options: CorsPolicyOptions) {
this.allowAll = options.staticOrigins.includes('*');
this.staticOrigins = new Set(
options.staticOrigins
.filter(origin => origin !== '*')
.map(origin => normalizeCorsOrigin(origin)?.origin || '')
.filter(Boolean),
);
this.tenantDomainsEnabled = options.tenantDomainsEnabled;
this.positiveCacheTtlMs = options.positiveCacheTtlMs;
this.negativeCacheTtlMs = options.negativeCacheTtlMs;
this.maxCacheEntries = options.maxCacheEntries;
this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain;
this.now = options.now || Date.now;
this.onLookupError = options.onLookupError;
}
async evaluate(rawOrigin: string): Promise<CorsDecision> {
if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' };
const normalized = normalizeCorsOrigin(rawOrigin);
if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' };
if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' };
if (this.staticOrigins.has(normalized.origin)) {
return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' };
}
// Tenant browser domains are production HTTPS hosts. Development ports belong
// in the explicit static allowlist and must never be inferred from Host headers.
if (
!this.tenantDomainsEnabled
|| normalized.protocol !== 'https:'
|| normalized.hasNonDefaultPort
|| isLocalTenantHost(normalized.hostname)
) {
return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' };
}
const lookup = await this.lookupWithCache(normalized.hostname);
if (!lookup.allowed) {
return {
allowed: false,
allowOrigin: null,
reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled',
};
}
return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' };
}
private async lookupWithCache(host: string) {
const now = this.now();
const cached = this.cache.get(host);
if (cached && cached.expiresAt > now) {
this.cache.delete(host);
this.cache.set(host, cached);
return { allowed: cached.allowed, lookupFailed: cached.lookupFailed };
}
if (cached) this.cache.delete(host);
const pending = this.pendingLookups.get(host);
if (pending) return pending;
const lookup = this.performLookup(host);
this.pendingLookups.set(host, lookup);
try {
return await lookup;
} finally {
this.pendingLookups.delete(host);
}
}
private async performLookup(host: string) {
let allowed = false;
let lookupFailed = false;
try {
allowed = await this.lookupTenantDomain(host);
} catch (error) {
lookupFailed = true;
this.onLookupError?.(error, host);
}
const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs;
this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed });
return { allowed, lookupFailed };
}
private storeCache(host: string, entry: CachedTenantDomain) {
this.cache.delete(host);
while (this.cache.size >= this.maxCacheEntries) {
const oldest = this.cache.keys().next().value as string | undefined;
if (!oldest) break;
this.cache.delete(oldest);
}
this.cache.set(host, entry);
}
}
export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) {
const decision = await policy.evaluate(firstHeader(req, 'origin'));
res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS);
res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS);
res.setHeader('access-control-expose-headers', 'x-request-id');
if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin);
if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin');
return decision;
}