Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/api/src/auth/auth-server-origins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
24 changes: 16 additions & 8 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,21 @@ const corsRedisClient = new Redis({
});

async function getCustomDomains(): Promise<Set<string>> {
// Try Redis cache first (non-fatal if Redis is unavailable)
try {
// Try Redis cache first
const cached = await corsRedisClient.get<string[]>(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 },
Expand All @@ -115,13 +118,18 @@ async function getCustomDomains(): Promise<Set<string>> {
.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();
}
}
Expand All @@ -130,7 +138,7 @@ async function getCustomDomains(): Promise<Set<string>> {
* 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<boolean> {
if (isStaticTrustedOrigin(origin)) {
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/auth/origin-check.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CSRF exemption too broad, covers authenticated admin endpoints

Medium Severity

The /v1/trust-access prefix exemption from origin validation is too broad. While the public-facing endpoints under this path are indeed unauthenticated, there are multiple admin/* sub-paths (e.g., approve, deny, revoke) protected by HybridAuthGuard that support cookie-based session auth. Exempting them from the origin check removes the CSRF defense-in-depth for these state-changing admin actions. The comment says "no auth, no cookies" but that only applies to non-admin routes.

Fix in Cursor Fix in Web

];

/**
Expand Down
Loading