/** * DC-057 billing checkout origin resolution tests. * * The checkout endpoint embeds the success_url (and cancel_url) into the * Stripe Checkout Session. These URLs are what Stripe redirects the * customer's browser to after payment. They MUST be derived only from * trusted sources — otherwise a header-injection attacker could redirect * customers to their own origin and capture the session_id, which is * the bearer token for /api/v1/billing/lookup/:sessionId (and that * endpoint serves the customer's license code on success). * * The origin is resolved in this priority order: * 1. STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape) * 2. Request Host header, but ONLY when the host is in * STRIPE_ALLOWED_HOSTS (operator-declared allowlist) * 3. undefined (Stripe falls back to its own defaults) */ const fs = require('fs'); const os = require('os'); const path = require('path'); const express = require('express'); const http = require('http'); // Save the fulfillment-store path so the route module captures the same // path the route would in production. (Tests below exercise the // stripe-client, not the fulfillment store, so the lookup endpoint can // share the same file.) const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-origin-')); process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json'); const billingRoutes = require('../../routes/billing'); const stripeClient = require('../../src/billing/stripe-client'); const REQUIRED_ENV = { STRIPE_SECRET_KEY: '«redacted:sk_test_…»', STRIPE_PRICE_PRO_30D: 'price_30d_test', STRIPE_PRICE_PRO_90D: 'price_90d_test', STRIPE_PRICE_PRO_180D: 'price_180d_test', STRIPE_PRICE_PRO_365D: 'price_365d_test', }; function setEnv(overrides = {}) { const all = { ...REQUIRED_ENV, ...overrides }; for (const [k, v] of Object.entries(all)) { process.env[k] = v; } } function clearEnv() { for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k]; delete process.env.STRIPE_PUBLIC_ORIGIN; delete process.env.STRIPE_ALLOWED_HOSTS; delete process.env.NODE_ENV; } function makeApp() { const app = express(); app.use(require('express').json()); function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } const router = billingRoutes({ asyncHandler }); app.use('/api/v1/billing', router); return app; } function postCheckout(req, body, headers = {}) { return new Promise((resolve, reject) => { const server = req.listen(0, () => { const port = server.address().port; const data = JSON.stringify(body); const headerLines = Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }) .map(([k, v]) => `${k}: ${v}`).join('\r\n'); const req2 = http.request({ hostname: '127.0.0.1', port, path: '/api/v1/billing/checkout', method: 'POST', headers: Object.fromEntries(Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }).map(([k, v]) => [k.toLowerCase(), v])), }, (res) => { let buf = ''; res.on('data', (c) => { buf += c; }); res.on('end', () => { server.close(); resolve({ status: res.statusCode, headers: res.headers, body: buf ? JSON.parse(buf) : null }); }); }); req2.on('error', reject); req2.write(data); req2.end(); }); }); } describe('POST /api/v1/billing/checkout — origin resolution (DC-057 security)', () => { let app; beforeAll(() => { app = makeApp(); setEnv(); }); afterEach(() => { clearEnv(); setEnv(); stripeClient._setStripeSdk(null); }); test('uses STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)', async () => { setEnv({ STRIPE_PUBLIC_ORIGIN: 'https://status.sami' }); const mockSession = { id: 'cs_test_orig_1', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_1' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); // The captured Stripe params must include the success_url + cancel_url // built from the operator-declared origin — NOT from the request's Host // header. This is the canonical deployment shape. expect(capturedParams.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}'); expect(capturedParams.cancel_url).toBe('https://status.sami/pricing'); }); test('rejects Host header injection when STRIPE_ALLOWED_HOSTS is empty', async () => { // Attacker sets X-Forwarded-Host: evil.com. The request reaches our // endpoint. Without STRIPE_PUBLIC_ORIGIN + without STRIPE_ALLOWED_HOSTS, // the origin must be undefined — we MUST NOT trust the attacker header. setEnv({ STRIPE_ALLOWED_HOSTS: '' }); const mockSession = { id: 'cs_test_orig_2', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_2' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }, { 'X-Forwarded-Host': 'evil.com', 'X-Forwarded-Proto': 'https', }); expect(res.status).toBe(200); // origin must be undefined when allowlist is empty — the Stripe SDK // is called with undefined origin and the stripe-client falls back to // relative '/billing/success' which is safe (no host poisoning). expect(capturedParams.success_url).toMatch(/^\/billing\/success/); }); test('accepts Host header when STRIPE_ALLOWED_HOSTS includes it', async () => { setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami,dashcaddy.net' }); const mockSession = { id: 'cs_test_orig_3', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_3' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }, { 'X-Forwarded-Host': 'status.sami', 'X-Forwarded-Proto': 'https', }); expect(res.status).toBe(200); expect(capturedParams.success_url).toContain('status.sami'); expect(capturedParams.success_url).toContain('/billing/success'); }); test('rejects Host header when host is NOT in STRIPE_ALLOWED_HOSTS', async () => { setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami' }); const mockSession = { id: 'cs_test_orig_4', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_4' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }, { 'X-Forwarded-Host': 'evil.com', 'X-Forwarded-Proto': 'https', }); expect(res.status).toBe(200); // origin is undefined → relative /billing/success URL (safe). expect(capturedParams.success_url).toMatch(/^\/billing\/success/); expect(capturedParams.success_url).not.toContain('evil.com'); }); test('rejects javascript: scheme injection via STRIPE_PUBLIC_ORIGIN', async () => { setEnv({ STRIPE_PUBLIC_ORIGIN: 'javascript:alert(1)' }); const mockSession = { id: 'cs_test_orig_5', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_5' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }); expect(res.status).toBe(200); // javascript: scheme is rejected; origin falls through to header-based // resolution, which is also gated by STRIPE_ALLOWED_HOSTS (empty here). expect(capturedParams.success_url).not.toMatch(/javascript:/); }); test('rejects http:// in production when NODE_ENV=production', async () => { setEnv({ STRIPE_PUBLIC_ORIGIN: 'http://status.sami', NODE_ENV: 'production' }); const mockSession = { id: 'cs_test_orig_6', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_6' }; let capturedParams; stripeClient._setStripeSdk(jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return mockSession; }) } }, })); const res = await postCheckout(app, { productId: 'pro-30d' }); expect(res.status).toBe(200); // http:// rejected in production; origin falls back to undefined. expect(capturedParams.success_url).not.toMatch(/^http:/); }); });