Spaces:
Runtime error
Runtime error
| export interface CorsPolicy { | |
| /** Explicit origin allowlist (empty when none / wildcard blocked). */ | |
| origins: string[]; | |
| /** Whether any origin is allowed (wildcard) β never true in production. */ | |
| allowAnyOrigin: boolean; | |
| /** CORS credentials are only allowed with an explicit allowlist (never with a wildcard). */ | |
| credentials: boolean; | |
| } | |
| /** | |
| * Resolves the effective CORS policy from CORS_ORIGINS + NODE_ENV. | |
| * - Dev: wildcard allowed (no credentials with wildcard β spec-compliant). | |
| * - Prod: a wildcard origin is REFUSED (collapses to same-origin only) so a | |
| * misconfigured deployment cannot reflect arbitrary origins with credentials. | |
| */ | |
| export function resolveCorsPolicy(corsOriginsEnv?: string, nodeEnv?: string): CorsPolicy { | |
| const origins = corsOriginsEnv | |
| ?.split(',') | |
| .map(o => o.trim()) | |
| .filter(Boolean) ?? ['*']; | |
| const hasWildcard = origins.includes('*'); | |
| // In production a wildcard origin is refused: collapse to same-origin only. | |
| if (hasWildcard && nodeEnv === 'production') { | |
| return { origins: [], allowAnyOrigin: false, credentials: false }; | |
| } | |
| return { | |
| origins, | |
| allowAnyOrigin: hasWildcard, | |
| // Credentials are only safe with an explicit allowlist, never with a wildcard. | |
| credentials: !hasWildcard, | |
| }; | |
| } | |
| /** | |
| * Whether to serve the Swagger UI (/api/docs). An explicit ENABLE_SWAGGER wins ('true'/'false'). | |
| * When unset, it defaults ON outside production but OFF in production β the public API schema is | |
| * reconnaissance surface, so production must opt in with ENABLE_SWAGGER=true. | |
| */ | |
| export function isSwaggerEnabled(enableSwaggerEnv?: string, nodeEnv?: string): boolean { | |
| if (enableSwaggerEnv === 'true') return true; | |
| if (enableSwaggerEnv === 'false') return false; | |
| return nodeEnv !== 'production'; | |
| } | |
| /** | |
| * Whether the global ValidationPipe should EXPOSE field-level validation error messages. Hidden by | |
| * default in production (a 400 there returns a generic message so the DTO shape isn't reflected back) | |
| * and shown outside production. `VALIDATION_ERROR_DETAIL=true` forces detail on β useful for debugging | |
| * an SDK/integration against a production instance without flipping NODE_ENV β and `=false` forces it | |
| * off everywhere. Mirrors isSwaggerEnabled's exact-string, production-default-off contract. | |
| */ | |
| export function isValidationErrorDetailEnabled(validationDetailEnv?: string, nodeEnv?: string): boolean { | |
| if (validationDetailEnv === 'true') return true; | |
| if (validationDetailEnv === 'false') return false; | |
| return nodeEnv !== 'production'; | |
| } | |
| /** | |
| * Whether to emit the CSP `upgrade-insecure-requests` directive (browsers auto-upgrade HTTPβHTTPS). | |
| * An explicit CSP_UPGRADE_INSECURE_REQUESTS wins ('true'/'false'). When unset it keeps the legacy | |
| * behaviour β ON in production only (the secure default for Internet-facing TLS deployments), OFF | |
| * elsewhere. Set CSP_UPGRADE_INSECURE_REQUESTS=false for an HTTP-only deployment on a trusted private | |
| * network, where the upgrade would otherwise force the dashboard to https and make it unreachable. (#611) | |
| */ | |
| export function isUpgradeInsecureRequestsEnabled(cspEnv?: string, nodeEnv?: string): boolean { | |
| if (cspEnv === 'true') return true; | |
| if (cspEnv === 'false') return false; | |
| return nodeEnv === 'production'; | |
| } | |
| /** | |
| * Whether a boot is likely walking into the #731 blank-dashboard trap: production serves the bundled | |
| * UI with `upgrade-insecure-requests` on, so a browser reaching it over plain HTTP silently upgrades | |
| * the UI's own script fetches to https:// and renders a blank page. The server never sees the failed | |
| * request (it dies in the browser), so a boot warning is the only pointer we can give. | |
| * | |
| * This cannot distinguish direct-HTTP (broken) from behind-a-TLS-proxy (correct) β Express `trust | |
| * proxy` is off, so at boot there is no signal either way. It deliberately fires for both; the | |
| * warning text tells a proxied operator to ignore it. | |
| */ | |
| export function isDashboardCspUpgradeTrapLikely(env: { | |
| nodeEnv?: string; | |
| cspEnv?: string; | |
| dashboardServed: boolean; | |
| }): boolean { | |
| return env.dashboardServed && isUpgradeInsecureRequestsEnabled(env.cspEnv, env.nodeEnv); | |
| } | |
| /** | |
| * Request body-size cap (DoS hardening). Default is media-aware (base64 sends ride in the JSON body). | |
| * A value the body-size parser can't understand (e.g. 'unlimited', 'none', a typo) resolves to a null | |
| * limit downstream, which SILENTLY DISABLES the cap β so an unparseable value falls back to the default | |
| * instead. Accepts a positive number with an optional bytes unit (b/kb/mb/gb/tb/pb). | |
| */ | |
| const BODY_LIMIT_PATTERN = /^\d+(\.\d+)?\s?(b|kb|mb|gb|tb|pb)?$/i; | |
| export function resolveBodyLimit(bodySizeEnv?: string): string { | |
| const trimmed = bodySizeEnv?.trim(); | |
| return trimmed && BODY_LIMIT_PATTERN.test(trimmed) ? trimmed : '25mb'; | |
| } | |
| /** Known weak/default/placeholder secret values that must never reach production. */ | |
| const FORBIDDEN_PROD_SECRETS = new Set([ | |
| 'openwa', | |
| 'minioadmin', | |
| 'your-secure-password', | |
| 'dev-master-key', | |
| 'dev-admin-key', | |
| 'changeme', | |
| 'change-me', | |
| 'password', | |
| 'secret', | |
| 'admin', | |
| '123456', | |
| 'qwerty', | |
| 'root', | |
| 'test', | |
| 'demo', | |
| ]); | |
| /** | |
| * Whether to warn that API_KEY_PEPPER is unset in production. Without a pepper, stored API-key hashes | |
| * fall back to plain SHA-256 (still functional). Advisory only β enabling a pepper re-hashes keys and | |
| * invalidates existing ones (see api-key-hash.ts), so it stays opt-in and must never be enforced. | |
| */ | |
| export function isApiKeyPepperMissingInProduction(nodeEnv?: string, apiKeyPepper?: string): boolean { | |
| return nodeEnv === 'production' && !apiKeyPepper?.trim(); | |
| } | |
| /** A built-in S3 endpoint is the bundled MinIO (host `minio`). An unset endpoint means the standard | |
| * AWS regional endpoint, so it is external and its credentials must never be exempted. */ | |
| function isInternalS3Endpoint(endpoint?: string): boolean { | |
| const e = endpoint?.trim(); | |
| if (!e) return false; | |
| try { | |
| return new URL(e).hostname === 'minio'; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export interface SecretCheckEnv { | |
| nodeEnv?: string; | |
| databaseType?: string; | |
| databasePassword?: string; | |
| /** POSTGRES_BUILTIN β when 'true', OpenWA runs the bundled Postgres on the internal-only network. */ | |
| postgresBuiltIn?: string; | |
| /** DATABASE_HOST β used to confirm a built-in exemption really points at the internal `postgres`. */ | |
| databaseHost?: string; | |
| storageType?: string; | |
| s3AccessKey?: string; | |
| s3SecretKey?: string; | |
| /** S3_ENDPOINT β used to confirm a built-in exemption really points at the internal `minio`. */ | |
| s3Endpoint?: string; | |
| /** MINIO_BUILTIN β when 'true', OpenWA runs the bundled MinIO on the internal-only network. */ | |
| minioBuiltIn?: string; | |
| apiMasterKey?: string; | |
| /** ALLOW_DEV_API_KEY β when 'true' it seeds the well-known public `dev-admin-key` as an ADMIN credential. */ | |
| allowDevApiKey?: string; | |
| /** REDIS_PASSWORD β optional; passwordless private-network Redis is supported, so only a known placeholder is rejected. */ | |
| redisPassword?: string; | |
| } | |
| /** | |
| * Refuse to boot in production when a required secret is empty or a known default/ | |
| * placeholder. Only secrets actually in use are checked: the DB password | |
| * when DATABASE_TYPE=postgres, the S3 keys when STORAGE_TYPE=s3, and API_MASTER_KEY | |
| * whenever it is set. Throws with the offending var names so the operator can fix them. | |
| */ | |
| export function assertNoDefaultSecretsInProduction(env: SecretCheckEnv): void { | |
| if (env.nodeEnv !== 'production') return; | |
| const isWeak = (value?: string): boolean => !value || FORBIDDEN_PROD_SECRETS.has(value.trim().toLowerCase()); | |
| const problems: string[] = []; | |
| // Built-in datastores run on the internal-only Docker network (not published), so their fixed | |
| // 'openwa'/'minioadmin' credentials are not internet-reachable β exempt them so selecting the | |
| // built-in option doesn't crash-loop a production boot. The exemption requires BOTH the built-in | |
| // flag AND an internal host: a host-pinned EXTERNAL datastore (even with the built-in flag set) is | |
| // reachable, so its weak credential is still enforced. | |
| const dbHost = env.databaseHost?.trim(); | |
| const dbExempt = env.postgresBuiltIn === 'true' && (!dbHost || dbHost === 'postgres'); | |
| if (env.databaseType === 'postgres' && !dbExempt && isWeak(env.databasePassword)) { | |
| problems.push('DATABASE_PASSWORD'); | |
| } | |
| const s3Exempt = env.minioBuiltIn === 'true' && isInternalS3Endpoint(env.s3Endpoint); | |
| if (env.storageType === 's3' && !s3Exempt) { | |
| if (isWeak(env.s3AccessKey)) problems.push('S3_ACCESS_KEY'); | |
| if (isWeak(env.s3SecretKey)) problems.push('S3_SECRET_KEY'); | |
| } | |
| // API_MASTER_KEY is optional, but if provided it must not be a known default. | |
| if (env.apiMasterKey && FORBIDDEN_PROD_SECRETS.has(env.apiMasterKey.trim().toLowerCase())) { | |
| problems.push('API_MASTER_KEY'); | |
| } | |
| // Redis auth is optional (passwordless private-network Redis is a supported deployment), so unlike | |
| // DATABASE_PASSWORD this rejects only a known placeholder VALUE β never an empty/unset password. | |
| if (env.redisPassword && FORBIDDEN_PROD_SECRETS.has(env.redisPassword.trim().toLowerCase())) { | |
| problems.push('REDIS_PASSWORD'); | |
| } | |
| // ALLOW_DEV_API_KEY=true seeds the publicly-documented `dev-admin-key` as an ADMIN credential | |
| // (when no API_MASTER_KEY is set) β never allow that opt-in to be carried into production. | |
| if (env.allowDevApiKey === 'true') { | |
| problems.push('ALLOW_DEV_API_KEY (seeds the public dev-admin-key)'); | |
| } | |
| if (problems.length > 0) { | |
| throw new Error( | |
| `Refusing to start in production: insecure or default value for ${problems.join(', ')}. ` + | |
| 'Set strong, unique secrets (see .env.example).', | |
| ); | |
| } | |
| } | |