diff --git a/packages/core/src/__tests__/middleware/bootstrap-security.test.ts b/packages/core/src/__tests__/middleware/bootstrap-security.test.ts new file mode 100644 index 000000000..aa05b108e --- /dev/null +++ b/packages/core/src/__tests__/middleware/bootstrap-security.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { verifySecurityConfig } from '../../middleware/bootstrap' + +describe('verifySecurityConfig', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('should not warn when all config is properly set', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'a-strong-random-secret-value-here', + CORS_ORIGINS: 'https://mysite.com', + ENVIRONMENT: 'production', + }) + + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('should warn when JWT_SECRET is not set', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + CORS_ORIGINS: 'http://localhost:8787', + ENVIRONMENT: 'development', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('JWT_SECRET is not set') + ) + }) + + it('should warn when JWT_SECRET contains the default value', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'your-super-secret-jwt-key-change-in-production', + CORS_ORIGINS: 'http://localhost:8787', + ENVIRONMENT: 'development', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('JWT_SECRET contains the default value') + ) + }) + + it('should warn when CORS_ORIGINS is not set', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'a-strong-secret', + ENVIRONMENT: 'development', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('CORS_ORIGINS is not set') + ) + }) + + it('should warn when ENVIRONMENT is not set', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'a-strong-secret', + CORS_ORIGINS: 'http://localhost:8787', + }) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('ENVIRONMENT is not set') + ) + }) + + it('should log multiple warnings when multiple items are missing', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + }) + + expect(warnSpy).toHaveBeenCalledTimes(3) + }) + + it('should throw in production when JWT_SECRET is not set', () => { + expect(() => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + CORS_ORIGINS: 'https://mysite.com', + ENVIRONMENT: 'production', + }) + }).toThrow('[SonicJS Security] CRITICAL') + }) + + it('should throw in production when JWT_SECRET is the default value', () => { + expect(() => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'your-super-secret-jwt-key-change-in-production', + CORS_ORIGINS: 'https://mysite.com', + ENVIRONMENT: 'production', + }) + }).toThrow('[SonicJS Security] CRITICAL') + }) + + it('should NOT throw in production when JWT_SECRET is properly set', () => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + JWT_SECRET: 'a-strong-random-secret-value', + ENVIRONMENT: 'production', + }) + + // Should warn about CORS_ORIGINS but not throw + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('CORS_ORIGINS is not set') + ) + }) + + it('should NOT throw in development even when JWT_SECRET is missing', () => { + expect(() => { + verifySecurityConfig({ + DB: {} as D1Database, + KV: {} as KVNamespace, + ENVIRONMENT: 'development', + }) + }).not.toThrow() + + // Should still warn + expect(warnSpy).toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/middleware/bootstrap.ts b/packages/core/src/middleware/bootstrap.ts index 5b6ebee5f..4fbdbd588 100644 --- a/packages/core/src/middleware/bootstrap.ts +++ b/packages/core/src/middleware/bootstrap.ts @@ -7,11 +7,71 @@ import type { SonicJSConfig } from "../app"; type Bindings = { DB: D1Database; KV: KVNamespace; + JWT_SECRET?: string; + CORS_ORIGINS?: string; + ENVIRONMENT?: string; }; // Track if bootstrap has been run in this worker instance let bootstrapComplete = false; +/** + * Verify security-critical environment configuration at startup. + * Logs warnings in development, throws in production to prevent + * insecure deployments from silently running. + */ +export function verifySecurityConfig(env: Bindings): void { + const warnings: string[] = []; + + // Check JWT secret + if (!env.JWT_SECRET) { + warnings.push( + "JWT_SECRET is not set — using hardcoded fallback. Set via `wrangler secret put JWT_SECRET`" + ); + } else if (env.JWT_SECRET.includes("change-in-production")) { + warnings.push( + "JWT_SECRET contains the default value — tokens are forgeable. Generate a strong random secret" + ); + } + + // Check CORS origins + if (!env.CORS_ORIGINS) { + warnings.push( + "CORS_ORIGINS is not set — all cross-origin API requests will be rejected" + ); + } + + // Check environment designation + if (!env.ENVIRONMENT) { + warnings.push( + "ENVIRONMENT is not set — HSTS header will not be applied. Set to \"production\" or \"development\"" + ); + } + + if (warnings.length === 0) { + return; + } + + const isProduction = env.ENVIRONMENT === "production"; + + for (const warning of warnings) { + console.warn(`[SonicJS Security] ${warning}`); + } + + if (isProduction) { + // In production, a missing or default JWT_SECRET is a hard failure — + // every token issued would be forgeable by anyone reading the source code. + const hasCritical = + !env.JWT_SECRET || env.JWT_SECRET.includes("change-in-production"); + if (hasCritical) { + throw new Error( + "[SonicJS Security] CRITICAL: Production deployment is missing a secure JWT_SECRET. " + + "Set it via `wrangler secret put JWT_SECRET` before deploying." + ); + } + } +} + /** * Bootstrap middleware that ensures system initialization * Runs once per worker instance @@ -77,6 +137,10 @@ export function bootstrapMiddleware(config: SonicJSConfig = {}) { // Don't prevent the app from starting, but log the error } + // 4. Verify security configuration (outside try/catch so critical + // errors in production propagate and prevent insecure deployments) + verifySecurityConfig(c.env as Bindings); + return next(); }; } diff --git a/packages/core/src/middleware/index.ts b/packages/core/src/middleware/index.ts index 96abeb470..1c25cf15b 100644 --- a/packages/core/src/middleware/index.ts +++ b/packages/core/src/middleware/index.ts @@ -8,7 +8,7 @@ */ // Bootstrap middleware -export { bootstrapMiddleware } from './bootstrap' +export { bootstrapMiddleware, verifySecurityConfig } from './bootstrap' // Auth middleware export { AuthManager, requireAuth, requireRole, optionalAuth } from './auth'