mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'; },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user