Skip to content

feat: dynamic CORS + CSP + merchant origin allowlisting#22

Merged
oomokaro1 merged 6 commits into
OrbitStream:mainfrom
CAESARIAN-ATHENS:SEC-Dynamic-CORS-CSP-Merchant-Origin-Allowlisting
Jun 19, 2026
Merged

feat: dynamic CORS + CSP + merchant origin allowlisting#22
oomokaro1 merged 6 commits into
OrbitStream:mainfrom
CAESARIAN-ATHENS:SEC-Dynamic-CORS-CSP-Merchant-Origin-Allowlisting

Conversation

@CAESARIAN-ATHENS

@CAESARIAN-ATHENS CAESARIAN-ATHENS commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Closes #15

Summary

Implements a comprehensive CORS and security headers system for OrbitStream. Replaces the permissive app.enableCors() with a route-aware DynamicCorsMiddleware that enforces origin allowlisting per merchant, plus a SecurityHeadersMiddleware that applies standard security headers to all responses.

Changes

Database

  • src/db/schema.ts: Added cors_origins JSONB column (default []) to the merchants table
    New Files
  • src/middleware/cors-origins-cache.service.ts: Redis-backed cache service that stores merchant CORS origins with a 5-minute TTL. Auto-refreshes on a schedule and supports per-merchant cache invalidation when origins are updated
  • src/middleware/dynamic-cors.middleware.ts: Route-group-aware CORS middleware that classifies requests into three groups:
  • Public (GET /v1/checkout/sessions/:id, POST /merchants/register, /health, /metrics): echoes back Origin — allows all
  • Merchant API (/v1/checkout/, /v1/webhooks/): checks Origin against PLATFORM_DOMAIN + all merchant-configured origins (cached); returns 403 for unknown origins
  • Dashboard (/merchants/, /auth/): only allows PLATFORM_DOMAIN; returns 403 for unknown origins
  • Sets Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Allow-Credentials
  • OPTIONS preflight returns 204 with Access-Control-Max-Age: 86400
  • src/middleware/security-headers.middleware.ts: Applies to every response:
  • Strict-Transport-Security: max-age=31536000; includeSubDomains
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: default-src 'self' (configurable via CONTENT_SECURITY_POLICY env)

Modified Files

  • src/main.ts: Removed app.enableCors() — CORS is now handled entirely by the middleware

  • src/app.module.ts: Implements NestModule.configure() to register both middlewares globally

  • src/merchants/merchants.service.ts: Added getCorsOrigins(merchantId) and setCorsOrigins(merchantId, origins) methods with cache invalidation

  • src/merchants/merchants.controller.ts: Added two JWT-protected endpoints:

  • GET /merchants/me/cors — returns list of configured origins

  • PUT /merchants/me/cors — sets allowed origins, body: { "origins": ["https://..."] }

  • src/merchants/merchants.dto.ts: Added SetCorsOriginsDto with @isarray() + @IsString({ each: true }) validation

  • src/merchants/merchants.module.ts: Provides and exports CorsOriginsCacheService

  • jest.config.ts: Added setupFiles pointing to jest-setup.ts

  • jest-setup.ts: Sets test env vars (DATABASE_URL, REDIS_URL, JWT_SECRET, PLATFORM_DOMAIN)
    Testing — 24 new tests (all passing)

  • Add cors_origins JSONB column to merchants table

  • Implement Redis-backed CorsOriginsCacheService with 5-min refresh

  • Add DynamicCorsMiddleware with route-grouped policies (public/merchant/dashboard)

  • Add SecurityHeadersMiddleware (HSTS, CSP, X-Frame-Options, etc.)

  • Add GET/PUT /merchants/me/cors endpoints (JWT auth)

  • Register both middlewares globally in AppModule

  • 24 passing unit tests covering all scenarios

- Add cors_origins JSONB column to merchants table
- Implement Redis-backed CorsOriginsCacheService with 5-min refresh
- Add DynamicCorsMiddleware with route-grouped policies (public/merchant/dashboard)
- Add SecurityHeadersMiddleware (HSTS, CSP, X-Frame-Options, etc.)
- Add GET/PUT /merchants/me/cors endpoints (JWT auth)
- Register both middlewares globally in AppModule
- 24 passing unit tests covering all scenarios
@oomokaro1

Copy link
Copy Markdown
Contributor

PR Review: feat: dynamic CORS + CSP + merchant origin allowlisting

Critical Issues

1. jest-setup.ts path is wrong (jest.config.ts:13)

setupFiles: ['../jest-setup.ts']

The jest config is in src/, and jest-setup.ts is in the project root. The path ../jest-setup.ts is correct relative to src/, but this is fragile. Consider placing it in a more conventional location or using an absolute path.

2. setCorsOrigins uses as any cast (src/merchants/merchants.service.ts:104)

.set({ corsOrigins: origins } as any)

This hides a type mismatch. The Drizzle schema likely expects corsOrigins to be typed differently. Fix the type instead of casting.

3. invalidateMerchantCache triggers full cache refresh (src/middleware/cors-origins-cache.service.ts:89)

async invalidateMerchantCache(merchantId: string): Promise<void> {
    await this.redis.getClient().del(`${CORS_CACHE_KEY}:${merchantId}`);
    await this.refreshCache();  // Refreshes ALL merchants
}

After invalidating a single merchant, you refresh the entire cache. This is wasteful. Just refresh that single merchant's key instead.


Security Concerns

4. Route classification uses hardcoded paths (src/middleware/dynamic-cors.middleware.ts:9-24)

if (path.startsWith('/health') || path.startsWith('/metrics')) return 'public';
if (method === 'GET' && /^\/v1\/checkout\/sessions\/[^\/]+$/.test(path)) return 'public';
if (path.startsWith('/v1/checkout/')) return 'merchant_api';

If routes change, this middleware can silently become misconfigured. Consider a more maintainable approach (e.g., decorators or route metadata).

5. Dashboard allows only PLATFORM_DOMAIN — this is correct for security but limits multi-origin dashboard scenarios.

6. Access-Control-Allow-Credentials: true with wildcard origins — When credentials are allowed, browsers require a specific origin (not *). The current logic echoes back the request origin, which is fine, but ensure no path allows * with credentials.


Code Quality

7. SecurityHeadersMiddleware reads CSP at module load time (src/middleware/security-headers.middleware.ts:4)

const CSP_VALUE = process.env.CONTENT_SECURITY_POLICY ?? "default-src 'self'...";

This means CSP can't be changed at runtime without restart. Acceptable for security headers, but document this.

8. originMatches wildcard logic is fragile (src/middleware/dynamic-cors.middleware.ts:28-30)

a.endsWith('/*') && origin.startsWith(a.slice(0, -1))

If someone passes https://example.com (no trailing /*), the a.slice(0, -1) would incorrectly match. The intent is to match https://example.com/* as a wildcard prefix, but the condition only triggers if a.endsWith('/*'). This is fine, but the logic is subtle.

9. DynamicCorsMiddleware calls new URL(platformDomain).origin on every request (src/middleware/dynamic-cors.middleware.ts:55,73)
Parse this once in the constructor or module init, not on every request.

10. Missing @IsUrl() validation on origins DTO (src/merchants/merchants.dto.ts:37-39)

@IsArray()
@IsString({ each: true })
origins: string[];

Consider adding URL validation to prevent invalid origins from being stored.


Minor Nits

  • merchants.controller.ts:83,94@Request() req: any should be typed as Request for better type safety.
  • cors-origins-cache.service.spec.ts — The mock for db.query.merchants.findMany returns [] for all cases. Add a test case where merchants with origins exist.
  • dynamic-cors.middleware.spec.ts:137-143 — No origin header test is good, but should also test requests from server-side (no origin) to dashboard routes.

Suggestions for Improvement

  1. Add a database migration for the cors_origins column (Drizzle Kit generate/migrate).
  2. Consider rate-limiting the CORS endpoint to prevent abuse.
  3. Add an audit log for CORS origin changes (security-critical operation).
  4. The 24 tests are good but should include integration tests for the middleware with real Redis.

Verdict

Request changes — The 3 critical issues (jest path, type casting, full cache refresh) should be fixed before merge. The security concerns are valid but don't block merge.

@CAESARIAN-ATHENS CAESARIAN-ATHENS force-pushed the SEC-Dynamic-CORS-CSP-Merchant-Origin-Allowlisting branch from 9e3ee6f to 2bd3c15 Compare June 19, 2026 11:22
@oomokaro1

Copy link
Copy Markdown
Contributor

Follow-up: The prettier/eslint fixes look good, but the 3 critical issues from my earlier review are still unresolved. Could you address these before merge?

1. as any cast in setCorsOrigins (src/merchants/merchants.service.ts:104)

.set({ corsOrigins: origins } as any)

This hides a type mismatch. Fix the Drizzle typing instead of casting.

2. invalidateMerchantCache triggers full cache refresh (src/middleware/cors-origins-cache.service.ts:93-96)

async invalidateMerchantCache(merchantId: string): Promise<void> {
    await this.redis.getClient().del(`${CORS_CACHE_KEY}:${merchantId}`);
    await this.refreshCache();  // Refreshes ALL merchants
}

After invalidating a single merchant's key, this re-fetches every merchant from the DB. Just refresh that single merchant's cache key instead.

3. new URL(platformDomain).origin on every request (src/middleware/dynamic-cors.middleware.ts:56,73)
Parse the platform domain once in the constructor, not per-request.

1. Keep Drizzle as any cast on set() (required by Drizzle v0.38 type
   inference for jsonb columns — consistent with all other update calls)
2. invalidateMerchantCache now refreshes single merchant key instead of
   re-fetching all merchants from DB
3. platformOrigin parsed once in DynamicCorsMiddleware constructor instead
   of on every request via new URL()
@CAESARIAN-ATHENS CAESARIAN-ATHENS force-pushed the SEC-Dynamic-CORS-CSP-Merchant-Origin-Allowlisting branch from 0c029d1 to 13977e7 Compare June 19, 2026 12:14

@oomokaro1 oomokaro1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough implementation! The route-grouped CORS approach is clean and the Redis cache layer with per-merchant invalidation is well thought out. Great test coverage too.

LGTM — approved. 🎉

@oomokaro1 oomokaro1 merged commit 09176fe into OrbitStream:main Jun 19, 2026
1 check passed
ogazboiz added a commit to playground-ogazboiz/OrbitStream_backend that referenced this pull request Jun 19, 2026
…eam#22)

Addresses the change request on OrbitStream#24: PR OrbitStream#22 landed dynamic per-merchant CORS
+ security headers on main, so this branch's parallel static CORS layer is
removed. JWT hardening and sliding-window rate limiting are unchanged.

- Remove src/api/middleware/cors.config.ts + security.middleware.ts (+ specs)
- main.ts: drop createSecurityMiddleware()/resolveAllowedOrigins(); keep
  resolveJwtSecrets() and the trust-proxy setting (needed for rate limiting)
- Remove CORS_ALLOWED_ORIGINS from .env.example
- Keep PR OrbitStream#22's DynamicCorsMiddleware + SecurityHeadersMiddleware in
  app.module.ts alongside RateLimitModule
- Scope the rate-limit integration test to rate limiting only
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SEC] Dynamic CORS + CSP + Merchant Origin Allowlisting

2 participants