Files
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe
Checkout client (src/billing/stripe-client.js), webhook bridge
(scripts/stripe-license-bridge.js), and pricing page
(status/pricing/index.html). One-time payment keyed by productId at
$20/$50/$70/$99 — no more monthly/annual subscription drift.

Bridge resolves duration via metadata.productId (single contract),
requires payment_status === 'paid' before fulfillment (rejects
unpaid/no_payment_required/missing with ack 200), handles
async_payment_succeeded for ACH/SEPA delayed-payment flow. License
persisted to fulfillment-store BEFORE email — SMTP failure path serves
the persisted code via the new /api/v1/billing/lookup/:sessionId
endpoint (the documented customer recovery path).

Layer-1 (event-id) + layer-2 (session-id) idempotency prevent
duplicate issuance. Checkout return URLs derived from
STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) —
closes host-header-poisoning + session-ID-leak attack class.

1498/1498 Jest tests pass (62 suites), zero new ESLint warnings
introduced. Test files:
  - stripe-license-bridge.test.js (24 tests)
  - billing-lookup.test.js (8 tests, HTTP-level)
  - bridge-lookup-http.test.js (5 tests, uses exported createServer)
  - pricing-page-catalog.test.js (9 tests, per-tier consistency)
  - checkout-origin.test.js (6 tests, host injection rejection)
  - stripe-client.test.js (rewrite for productId + mode:payment)

Bridge code refactored: handleWebhook decomposed into verifySignature +
parseEventBody + checkEventIdempotency + fulfillCheckout +
ensureLicensePersisted (under ESLint complexity=20 cap). New
createServer()/createRequestHandler() factories guarded by
require.main === module.

Removed 3 stale test files from the rolled-back DC-055 attempt.
2026-08-04 14:18:49 -07:00

228 lines
9.2 KiB
JavaScript

/**
* 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:/);
});
});