Gate Stripe webhook on STRIPE_ALLOWED_PRODUCT_IDS allowlist

Prevents foreign Stripe events (from other apps sharing this Stripe
account) from being processed. Webhook ignores events whose products
are not in the allowlist; server refuses to boot without it set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lawrence Hook
2026-04-22 19:07:44 -04:00
parent a8c0379fd1
commit 3b1789e89e
7 changed files with 120 additions and 5 deletions
+4
View File
@@ -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
+9
View File
@@ -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'; },
+5
View File
@@ -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();
+21 -1
View File
@@ -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': {
+39
View File
@@ -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,
};
+1
View File
@@ -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';
+41 -4
View File
@@ -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',
},