forked from wangziqi/gongxue-base
226 lines
7.8 KiB
TypeScript
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;
|
|
}
|