- 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
125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
export const SubscriptionTier = {
|
|
FREE: 'free',
|
|
BASIC: 'basic',
|
|
PLUS: 'plus',
|
|
PREMIUM: 'premium',
|
|
} as const;
|
|
|
|
export type SubscriptionTier = typeof SubscriptionTier[keyof typeof SubscriptionTier];
|
|
|
|
export const BillingConfigSchema = z.object({
|
|
stripe: z.object({
|
|
apiKey: z.string().min(1, 'STRIPE_API_KEY required'),
|
|
webhookSecret: z.string().min(1, 'STRIPE_WEBHOOK_SECRET required'),
|
|
pricingTableId: z.string().optional(),
|
|
}),
|
|
tiers: z.object({
|
|
free: z.object({
|
|
priceId: z.string(),
|
|
monthlyPriceCents: z.number().default(0),
|
|
callMinutesLimit: z.number().default(100),
|
|
smsCountLimit: z.number().default(500),
|
|
darkWebScans: z.number().default(1),
|
|
}),
|
|
basic: z.object({
|
|
priceId: z.string(),
|
|
monthlyPriceCents: z.number().default(999),
|
|
callMinutesLimit: z.number().default(500),
|
|
smsCountLimit: z.number().default(2000),
|
|
darkWebScans: z.number().default(12),
|
|
}),
|
|
plus: z.object({
|
|
priceId: z.string(),
|
|
monthlyPriceCents: z.number().default(1999),
|
|
callMinutesLimit: z.number().default(2000),
|
|
smsCountLimit: z.number().default(10000),
|
|
darkWebScans: z.number().default(12),
|
|
voiceCloning: z.boolean().default(true),
|
|
}),
|
|
premium: z.object({
|
|
priceId: z.string(),
|
|
monthlyPriceCents: z.number().default(4999),
|
|
callMinutesLimit: z.number().default(10000),
|
|
smsCountLimit: z.number().default(50000),
|
|
darkWebScans: z.number().default(12),
|
|
voiceCloning: z.boolean().default(true),
|
|
homeTitleMonitor: z.boolean().default(true),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
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: {
|
|
apiKey: process.env.STRIPE_API_KEY!,
|
|
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
pricingTableId: process.env.STRIPE_PRICING_TABLE_ID,
|
|
},
|
|
tiers: {
|
|
free: {
|
|
priceId: process.env.STRIPE_FREE_TIER_PRICE_ID || 'price_free',
|
|
monthlyPriceCents: 0,
|
|
callMinutesLimit: 100,
|
|
smsCountLimit: 500,
|
|
darkWebScans: 1,
|
|
},
|
|
basic: {
|
|
priceId: process.env.STRIPE_BASIC_TIER_PRICE_ID || 'price_basic',
|
|
monthlyPriceCents: 999,
|
|
callMinutesLimit: 500,
|
|
smsCountLimit: 2000,
|
|
darkWebScans: 12,
|
|
},
|
|
plus: {
|
|
priceId: process.env.STRIPE_PLUS_TIER_PRICE_ID || 'price_plus',
|
|
monthlyPriceCents: 1999,
|
|
callMinutesLimit: 2000,
|
|
smsCountLimit: 10000,
|
|
darkWebScans: 12,
|
|
voiceCloning: true,
|
|
},
|
|
premium: {
|
|
priceId: process.env.STRIPE_PREMIUM_TIER_PRICE_ID || 'price_premium',
|
|
monthlyPriceCents: 4999,
|
|
callMinutesLimit: 10000,
|
|
smsCountLimit: 50000,
|
|
darkWebScans: 12,
|
|
voiceCloning: true,
|
|
homeTitleMonitor: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
return BillingConfigSchema.parse(rawConfig);
|
|
};
|