From 0d5489959d3675d3de7f1bc36c34b3c52c876f10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:55:18 -0400 Subject: [PATCH] fix(trust-portal): fix CORS for custom domain trust portals (#2371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom domain trust portals (e.g. trust.sessionlab.com) failed with CORS errors on client-side API calls. The page loaded fine via SSR but form submissions like "Request Access" failed with "Failed to fetch". Root cause: getCustomDomains() filtered by domainVerified=true, but that flag only gets set when an admin completes our DNS check flow. Vercel can serve the domain before that, so the portal works but CORS rejects the origin. - Remove domainVerified filter from CORS domain query — an admin adding a domain is sufficient authorization - Add independent error handling for Redis/DB in getCustomDomains so a Redis failure doesn't silently drop valid DB results - Exempt /v1/trust-access from origin check middleware since those endpoints are public (no auth, no cookies, no CSRF risk) Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- apps/api/src/auth/auth-server-origins.spec.ts | 61 +++++++++++++++++++ apps/api/src/auth/auth.server.ts | 24 +++++--- apps/api/src/auth/origin-check.middleware.ts | 7 ++- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/api/src/auth/auth-server-origins.spec.ts b/apps/api/src/auth/auth-server-origins.spec.ts index 6f9b0f7d3..97ce7cb44 100644 --- a/apps/api/src/auth/auth-server-origins.spec.ts +++ b/apps/api/src/auth/auth-server-origins.spec.ts @@ -115,3 +115,64 @@ describe('isStaticTrustedOrigin', () => { expect(mainTs).toContain("import { isTrustedOrigin } from './auth/auth.server'"); }); }); + +describe('getCustomDomains (structural)', () => { + it('auth.server.ts should NOT filter by domainVerified in CORS domain query', () => { + // Custom domains should be allowed for CORS as soon as they are configured + // by an admin, not only after DNS verification completes. Vercel can serve + // the trust portal before our domainVerified flag is set, causing CORS + // failures on client-side API calls. + const fs = require('fs'); + const path = require('path'); + const authServer = fs.readFileSync( + path.join(__dirname, 'auth.server.ts'), + 'utf-8', + ) as string; + + // Extract the getCustomDomains function body + const fnMatch = authServer.match( + /async function getCustomDomains[\s\S]*?^}/m, + ); + expect(fnMatch).toBeTruthy(); + const fnBody = fnMatch![0]; + + // Must NOT require domainVerified — that flag lags behind Vercel's own verification + expect(fnBody).not.toContain('domainVerified'); + + // Must still filter by published status + expect(fnBody).toContain("status: 'published'"); + }); + + it('auth.server.ts getCustomDomains should have independent error handling for Redis and DB', () => { + const fs = require('fs'); + const path = require('path'); + const authServer = fs.readFileSync( + path.join(__dirname, 'auth.server.ts'), + 'utf-8', + ) as string; + + const fnMatch = authServer.match( + /async function getCustomDomains[\s\S]*?^}/m, + ); + expect(fnMatch).toBeTruthy(); + const fnBody = fnMatch![0]; + + // Should have multiple try/catch blocks (Redis read, DB query, Redis write) + const tryCatchCount = (fnBody.match(/\btry\s*\{/g) || []).length; + expect(tryCatchCount).toBeGreaterThanOrEqual(3); + }); +}); + +describe('originCheckMiddleware (structural)', () => { + it('should exempt trust-access paths from origin validation', () => { + const fs = require('fs'); + const path = require('path'); + const middleware = fs.readFileSync( + path.join(__dirname, 'origin-check.middleware.ts'), + 'utf-8', + ) as string; + + // Trust-access endpoints are public (no auth, no cookies) — no CSRF risk + expect(middleware).toContain('/v1/trust-access'); + }); +}); diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index a1205a852..3832a3f9f 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -94,18 +94,21 @@ const corsRedisClient = new Redis({ }); async function getCustomDomains(): Promise> { + // Try Redis cache first (non-fatal if Redis is unavailable) try { - // Try Redis cache first const cached = await corsRedisClient.get(CORS_DOMAINS_CACHE_KEY); if (cached) { return new Set(cached); } + } catch (error) { + console.error('[CORS] Redis cache read failed, falling back to DB:', error); + } - // Cache miss — query DB and store in Redis + // Cache miss or Redis unavailable — query DB + try { const trusts = await db.trust.findMany({ where: { domain: { not: null }, - domainVerified: true, status: 'published', }, select: { domain: true }, @@ -115,13 +118,18 @@ async function getCustomDomains(): Promise> { .map((t) => t.domain) .filter((d): d is string => d !== null); - await corsRedisClient.set(CORS_DOMAINS_CACHE_KEY, domains, { - ex: CORS_DOMAINS_CACHE_TTL_SECONDS, - }); + // Best-effort cache update (don't lose DB results if Redis SET fails) + try { + await corsRedisClient.set(CORS_DOMAINS_CACHE_KEY, domains, { + ex: CORS_DOMAINS_CACHE_TTL_SECONDS, + }); + } catch { + // Redis unavailable — continue without caching + } return new Set(domains); } catch (error) { - console.error('[CORS] Failed to fetch custom domains:', error); + console.error('[CORS] Failed to fetch custom domains from DB:', error); return new Set(); } } @@ -130,7 +138,7 @@ async function getCustomDomains(): Promise> { * Check if an origin is trusted. Checks (in order): * 1. Static trusted origins list * 2. *.trycomp.ai / *.trust.inc subdomains - * 3. Verified custom domains from the DB (cached in Redis, TTL 5 min) + * 3. Published custom domains from the DB (cached in Redis, TTL 5 min) */ export async function isTrustedOrigin(origin: string): Promise { if (isStaticTrustedOrigin(origin)) { diff --git a/apps/api/src/auth/origin-check.middleware.ts b/apps/api/src/auth/origin-check.middleware.ts index d5d5aaf5f..27cb20226 100644 --- a/apps/api/src/auth/origin-check.middleware.ts +++ b/apps/api/src/auth/origin-check.middleware.ts @@ -8,9 +8,10 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); * These are called by external services that don't send browser Origin headers. */ const EXEMPT_PATH_PREFIXES = [ - '/api/auth', // better-auth handles its own CSRF - '/v1/health', // health check - '/api/docs', // swagger + '/api/auth', // better-auth handles its own CSRF + '/v1/health', // health check + '/api/docs', // swagger + '/v1/trust-access', // public trust portal endpoints (no auth, no cookies) ]; /**