forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
225
apps/api/src/core/cors.ts
Normal file
225
apps/api/src/core/cors.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user