Fix open redirect in Stripe customer portal returnUrl (FRE-5399)

- Add isValidReturnUrl validation at route level for fast rejection
- Add defense-in-depth validation in BillingService.createCustomerPortalSession
- Fix isValidReturnUrl bug: origin comparison was never reached due to
  incorrect protocol check, allowing substring attacks (e.g., app.shieldai.com.evil.com)
- Export isValidReturnUrl from shared-billing package index
- Add unit tests for all attack vectors

Files changed:
- packages/api/src/routes/subscription.routes.ts
- packages/shared-billing/src/services/billing.service.ts
- packages/shared-billing/src/config/billing.config.ts
- packages/shared-billing/src/index.ts
- packages/shared-billing/src/__tests__/billing.config.test.ts
This commit is contained in:
Founding Engineer
2026-05-17 05:39:13 -04:00
committed by Michael Freno
parent e72a0ba5cf
commit 7fb8b83810
5 changed files with 98 additions and 3 deletions

View File

@@ -52,6 +52,32 @@ export const BillingConfigSchema = z.object({
export type BillingConfig = z.infer<typeof BillingConfigSchema>;
// Allowed return URL origins for Stripe customer portal (open redirect prevention)
const allowedReturnUrlOrigins: string[] = (process.env.ALLOWED_RETURN_URL_ORIGINS || 'https://app.shieldai.com,shieldai://').split(',').map(s => s.trim()).filter(Boolean);
export function isValidReturnUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Custom app schemes (e.g., shieldai://) have origin === 'null'
// Use prefix matching for these since they don't have a standard origin
if (parsed.origin === 'null') {
return allowedReturnUrlOrigins.some(origin => url.startsWith(origin));
}
// Standard web protocols: compare origins to prevent substring attacks
return allowedReturnUrlOrigins.some(origin => {
try {
const originUrl = new URL(origin);
return parsed.origin === originUrl.origin;
} catch {
// If the allowed origin can't be parsed as a URL, it's a custom scheme
return false;
}
});
} catch {
return false;
}
}
export const loadBillingConfig = (): BillingConfig => {
const rawConfig = {
stripe: {