diff --git a/server/.env.example b/server/.env.example index bde35d8..4af0548 100644 --- a/server/.env.example +++ b/server/.env.example @@ -10,6 +10,10 @@ STRIPE_SECRET_KEY=sk_test_... STRIPE_PRICE_MONTHLY=price_... STRIPE_PRICE_YEARLY=price_... STRIPE_WEBHOOK_SECRET=whsec_... +# Comma-separated product IDs this server is allowed to act on. Webhook events +# whose products are not in this list are ignored (guards against cross-product +# event bleed when multiple apps share a Stripe account). +STRIPE_ALLOWED_PRODUCT_IDS=prod_...,prod_... # AWS SES AWS_REGION=us-east-1 diff --git a/server/src/config.js b/server/src/config.js index 4088ec8..1320372 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -26,6 +26,15 @@ module.exports = { get STRIPE_PRICE_MONTHLY() { return process.env.STRIPE_PRICE_MONTHLY; }, get STRIPE_PRICE_YEARLY() { return process.env.STRIPE_PRICE_YEARLY; }, get STRIPE_WEBHOOK_SECRET() { return process.env.STRIPE_WEBHOOK_SECRET; }, + // Comma-separated list of Stripe product IDs this server is allowed to act on. + // Webhook events whose products are not in this list are ignored. Guards against + // cross-product event bleed when multiple apps share a Stripe account. + get STRIPE_ALLOWED_PRODUCT_IDS() { + return (process.env.STRIPE_ALLOWED_PRODUCT_IDS || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + }, // AWS SES get AWS_REGION() { return process.env.AWS_REGION || 'us-east-1'; }, diff --git a/server/src/index.js b/server/src/index.js index a71e536..600f3de 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -129,6 +129,11 @@ function start() { process.exit(1); } + if (config.STRIPE_ALLOWED_PRODUCT_IDS.length === 0) { + console.error('STRIPE_ALLOWED_PRODUCT_IDS must be set (comma-separated Stripe product IDs)'); + process.exit(1); + } + // Ensure data directories exist storage.ensureDirectories(); diff --git a/server/src/routes/webhook.js b/server/src/routes/webhook.js index 0fc4952..08f35b0 100644 --- a/server/src/routes/webhook.js +++ b/server/src/routes/webhook.js @@ -1,5 +1,10 @@ const express = require('express'); -const { constructWebhookEvent, stripe } = require('../services/stripe'); +const { + constructWebhookEvent, + stripe, + getEventProductIds, + isAllowedProduct, +} = require('../services/stripe'); const { sendWelcomeEmail, sendCancellationEmail } = require('../services/email'); const storage = require('../storage'); @@ -28,6 +33,21 @@ router.post('/stripe', async (req, res) => { return res.status(400).json({ error: 'Invalid signature' }); } + // Reject events whose product isn't in our allowlist. Protects against + // cross-product bleed when multiple apps share a Stripe account. + try { + const productIds = await getEventProductIds(event); + if (productIds !== null && !isAllowedProduct(productIds)) { + console.log( + `[webhook] Ignored ${event.type} ${event.id} (products [${productIds.join(', ')}] not in allowlist)` + ); + return res.json({ received: true, ignored: true }); + } + } catch (err) { + console.error(`[webhook] Product allowlist check failed for ${event.id}:`, err.message); + return res.status(500).json({ error: 'Allowlist check failed' }); + } + // Handle the event switch (event.type) { case 'checkout.session.completed': { diff --git a/server/src/services/stripe.js b/server/src/services/stripe.js index c0fd6ef..4107bf9 100644 --- a/server/src/services/stripe.js +++ b/server/src/services/stripe.js @@ -92,6 +92,43 @@ function constructWebhookEvent(payload, signature) { ); } +// Extract the product IDs associated with a webhook event, or null if the event +// type has no product association we can determine. For checkout.session.completed +// we must re-fetch the session with line items expanded — the event payload +// doesn't include them. +async function getEventProductIds(event) { + const obj = event.data.object; + switch (event.type) { + case 'checkout.session.completed': { + const session = await stripe.checkout.sessions.retrieve(obj.id, { + expand: ['line_items.data.price'], + }); + return (session.line_items?.data || []) + .map(li => li.price?.product) + .filter(Boolean); + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + return (obj.items?.data || []) + .map(i => i.price?.product) + .filter(Boolean); + case 'invoice.payment_failed': + case 'invoice.payment_succeeded': + return (obj.lines?.data || []) + .map(l => l.price?.product) + .filter(Boolean); + default: + return null; + } +} + +function isAllowedProduct(productIds) { + if (!productIds || productIds.length === 0) return false; + const allowlist = new Set(config.STRIPE_ALLOWED_PRODUCT_IDS); + return productIds.some(id => allowlist.has(id)); +} + module.exports = { stripe, findCustomerByEmail, @@ -102,4 +139,6 @@ module.exports = { createCheckoutSession, createBillingPortalSession, constructWebhookEvent, + getEventProductIds, + isAllowedProduct, }; diff --git a/server/tests/setup.js b/server/tests/setup.js index 31bcfed..8001247 100644 --- a/server/tests/setup.js +++ b/server/tests/setup.js @@ -6,6 +6,7 @@ process.env.STRIPE_SECRET_KEY = 'sk_test_fake'; process.env.STRIPE_PRICE_MONTHLY = 'price_test_monthly'; process.env.STRIPE_PRICE_YEARLY = 'price_test_yearly'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; +process.env.STRIPE_ALLOWED_PRODUCT_IDS = 'prod_test_rys'; process.env.EMAIL_FROM = 'test@example.com'; process.env.BASE_URL = 'http://localhost:3000'; process.env.DATA_DIR = './tests/test-data'; diff --git a/server/tests/webhook.test.js b/server/tests/webhook.test.js index 04cac07..4846b15 100644 --- a/server/tests/webhook.test.js +++ b/server/tests/webhook.test.js @@ -23,6 +23,18 @@ stripeService.stripe.customers = { return { email: 'buyer@example.com' }; }, }; +stripeService.stripe.checkout = { + sessions: { + retrieve: async (id) => ({ + id, + line_items: { + data: [{ price: { product: 'prod_test_rys' } }], + }, + }), + }, +}; + +const ownedItems = { data: [{ price: { product: 'prod_test_rys' } }] }; const { app } = require('../src/index'); const storage = require('../src/storage'); @@ -54,11 +66,33 @@ describe('Webhook Routes', () => { assert.strictEqual(res.status, 400); }); + it('should ignore subscription events for products not in the allowlist', async () => { + mockWebhookEvent = { + id: 'evt_foreign', + type: 'customer.subscription.updated', + data: { + object: { + customer: 'cus_123', + status: 'active', + items: { data: [{ price: { product: 'prod_other_app' } }] }, + }, + }, + }; + + const res = await sendWebhook(); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.ignored, true); + + const status = storage.getSubscriptionStatus('buyer@example.com'); + assert.ok(!status || status.premium !== true, 'foreign product should not upgrade user'); + }); + it('checkout.session.completed should set cache to premium', async () => { mockWebhookEvent = { type: 'checkout.session.completed', data: { object: { + id: 'cs_test_123', customer_email: 'buyer@example.com', customer: 'cus_123', }, @@ -85,6 +119,7 @@ describe('Webhook Routes', () => { object: { customer: 'cus_123', status: 'incomplete', + items: ownedItems, }, }, }; @@ -103,6 +138,7 @@ describe('Webhook Routes', () => { object: { customer: 'cus_123', status: 'active', + items: ownedItems, }, }, }; @@ -121,7 +157,7 @@ describe('Webhook Routes', () => { mockWebhookEvent = { type: 'customer.subscription.updated', data: { - object: { customer: 'cus_123', status: 'active' }, + object: { customer: 'cus_123', status: 'active', items: ownedItems }, }, }; await sendWebhook(); @@ -133,7 +169,7 @@ describe('Webhook Routes', () => { mockWebhookEvent = { type: 'customer.subscription.created', data: { - object: { customer: 'cus_123', status: 'incomplete' }, + object: { customer: 'cus_123', status: 'incomplete', items: ownedItems }, }, }; await sendWebhook(); @@ -147,7 +183,7 @@ describe('Webhook Routes', () => { mockWebhookEvent = { type: 'customer.subscription.updated', data: { - object: { customer: 'cus_123', status: 'active' }, + object: { customer: 'cus_123', status: 'active', items: ownedItems }, }, }; await sendWebhook(); @@ -155,7 +191,7 @@ describe('Webhook Routes', () => { mockWebhookEvent = { type: 'customer.subscription.created', data: { - object: { customer: 'cus_123', status: 'incomplete' }, + object: { customer: 'cus_123', status: 'incomplete', items: ownedItems }, }, }; await sendWebhook(); @@ -164,6 +200,7 @@ describe('Webhook Routes', () => { type: 'checkout.session.completed', data: { object: { + id: 'cs_test_123', customer_email: 'buyer@example.com', customer: 'cus_123', },