Skip to content

Commit d92feb6

Browse files
fix(billing): address review feedback on security and correctness
- Validate successUrl/cancelUrl against app domain to prevent open redirects - Fix toObject() spread in update that could break with populated org ref - Add optional return_url to portal session creation - Add Zod CheckoutRequest/PortalRequest schemas with model.isValid middleware - Fix CASL resolveSubject to use route-derived billing subjects - Update unit tests to cover URL validation and portal returnUrl
1 parent 85c0cc5 commit d92feb6

6 files changed

Lines changed: 125 additions & 11 deletions

File tree

lib/middlewares/policy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ const resolveSubject = (req) => {
125125
// For user admin routes, the loaded user is stored in req.model by userByID param middleware
126126
if (req.model) return { subjectType: 'UserAdmin', document: normalizeForCasl(req.model) };
127127
if (req.membershipDoc) return { subjectType: 'Membership', document: normalizeForCasl(req.membershipDoc) };
128-
if (req.organization) return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
128+
// Billing routes use req.organization for context but authorize via route-derived subjects
129+
if (req.organization && !req.route?.path?.startsWith('/api/billing')) {
130+
return { subjectType: 'Organization', document: normalizeForCasl(req.organization) };
131+
}
129132
return null;
130133
};
131134

modules/billing/controllers/billing.controller.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const checkout = async (req, res) => {
2828
*/
2929
const portal = async (req, res) => {
3030
try {
31-
const url = await BillingService.createPortalSession(req.organization);
31+
const { returnUrl } = req.body;
32+
const url = await BillingService.createPortalSession(req.organization, returnUrl);
3233
responses.success(res, 'portal session created')({ url });
3334
} catch (err) {
3435
responses.error(res, 422, 'Unprocessable Entity', 'Failed to create portal session')(err);

modules/billing/models/billing.subscription.schema.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,29 @@ const SubscriptionCore = z.object({
4141

4242
const SubscriptionUpdate = SubscriptionCore.partial();
4343

44+
/**
45+
* Checkout request body schema
46+
*/
47+
const CheckoutRequest = z
48+
.object({
49+
priceId: z.string().trim().min(1, 'priceId is required'),
50+
successUrl: z.string().url('successUrl must be a valid URL'),
51+
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
52+
})
53+
.strict();
54+
55+
/**
56+
* Portal request body schema
57+
*/
58+
const PortalRequest = z
59+
.object({
60+
returnUrl: z.string().url('returnUrl must be a valid URL').optional(),
61+
})
62+
.strict();
63+
4464
export default {
4565
Subscription,
4666
SubscriptionUpdate,
67+
CheckoutRequest,
68+
PortalRequest,
4769
};

modules/billing/routes/billing.routes.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
*/
44
import passport from 'passport';
55

6+
import model from '../../../lib/middlewares/model.js';
67
import policy from '../../../lib/middlewares/policy.js';
78
import organization from '../../organizations/middleware/organizations.middleware.js';
9+
import billingSchema from '../models/billing.subscription.schema.js';
810
import billingPlans from '../controllers/billing.plans.controller.js';
911
import billing from '../controllers/billing.controller.js';
1012

@@ -21,13 +23,13 @@ export default (app) => {
2123
app
2224
.route('/api/billing/checkout')
2325
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
24-
.post(billing.checkout);
26+
.post(model.isValid(billingSchema.CheckoutRequest), billing.checkout);
2527

2628
// portal
2729
app
2830
.route('/api/billing/portal')
2931
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
30-
.post(billing.portal);
32+
.post(model.isValid(billingSchema.PortalRequest), billing.portal);
3133

3234
// subscription
3335
app

modules/billing/services/billing.service.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ const getStripe = () => {
2222
return stripeClient;
2323
};
2424

25+
/**
26+
* @desc Validate that a URL uses https (or http in dev/test) and belongs to the app domain
27+
* @param {String} url - URL to validate
28+
* @returns {Boolean} true if valid
29+
*/
30+
const isAllowedUrl = (url) => {
31+
try {
32+
const parsed = new URL(url);
33+
const allowHttp = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
34+
if (!allowHttp && parsed.protocol !== 'https:') return false;
35+
if (config.domain) {
36+
const configHost = new URL(config.domain.startsWith('http') ? config.domain : `https://${config.domain}`).hostname;
37+
if (parsed.hostname !== configHost) return false;
38+
}
39+
return true;
40+
} catch {
41+
return false;
42+
}
43+
};
44+
2545
/**
2646
* @desc Create a Stripe Checkout Session for the given organization
2747
* @param {Object} organization - The organization document
@@ -34,6 +54,10 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
3454
const stripe = getStripe();
3555
if (!stripe) throw new Error('Stripe is not configured');
3656

57+
if (!isAllowedUrl(successUrl) || !isAllowedUrl(cancelUrl)) {
58+
throw new Error('Invalid redirect URL: must use HTTPS and match the application domain');
59+
}
60+
3761
// Find or create subscription record with Stripe customer
3862
let subscription = await SubscriptionRepository.findByOrganization(organization._id);
3963

@@ -45,7 +69,7 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
4569

4670
if (subscription) {
4771
subscription = await SubscriptionRepository.update({
48-
...subscription.toObject(),
72+
_id: subscription._id,
4973
stripeCustomerId: customer.id,
5074
});
5175
} else {
@@ -70,18 +94,23 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
7094
/**
7195
* @desc Create a Stripe Customer Portal session for the given organization
7296
* @param {Object} organization - The organization document
97+
* @param {String} returnUrl - Optional URL to redirect back to after portal
7398
* @returns {Promise<String>} Portal session URL
7499
*/
75-
const createPortalSession = async (organization) => {
100+
const createPortalSession = async (organization, returnUrl) => {
76101
const stripe = getStripe();
77102
if (!stripe) throw new Error('Stripe is not configured');
78103

79104
const subscription = await SubscriptionRepository.findByOrganization(organization._id);
80105
if (!subscription?.stripeCustomerId) throw new Error('No Stripe customer found for this organization');
81106

82-
const session = await stripe.billingPortal.sessions.create({
83-
customer: subscription.stripeCustomerId,
84-
});
107+
const params = { customer: subscription.stripeCustomerId };
108+
if (returnUrl) {
109+
if (!isAllowedUrl(returnUrl)) throw new Error('Invalid return URL: must use HTTPS and match the application domain');
110+
params.return_url = returnUrl;
111+
}
112+
113+
const session = await stripe.billingPortal.sessions.create(params);
85114

86115
return session.url;
87116
};

modules/billing/tests/billing.service.unit.tests.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,32 @@ describe('Billing service unit tests:', () => {
7979
);
8080
});
8181

82+
test('should throw when redirect URL is invalid', async () => {
83+
jest.unstable_mockModule('../../../config/index.js', () => ({
84+
default: { stripe: { secretKey: 'sk_test_url' } },
85+
}));
86+
87+
const mod = await import('../services/billing.service.js');
88+
BillingService = mod.default;
89+
90+
await expect(BillingService.createCheckout(mockOrganization, 'price_123', 'not-a-url', 'http://cancel')).rejects.toThrow(
91+
'Invalid redirect URL',
92+
);
93+
});
94+
95+
test('should reject URL with wrong domain when config.domain is set', async () => {
96+
jest.unstable_mockModule('../../../config/index.js', () => ({
97+
default: { stripe: { secretKey: 'sk_test_domain' }, domain: 'https://myapp.com' },
98+
}));
99+
100+
const mod = await import('../services/billing.service.js');
101+
BillingService = mod.default;
102+
103+
await expect(
104+
BillingService.createCheckout(mockOrganization, 'price_123', 'http://evil.com/success', 'http://myapp.com/cancel'),
105+
).rejects.toThrow('Invalid redirect URL');
106+
});
107+
82108
test('should create customer and subscription when none exists', async () => {
83109
jest.unstable_mockModule('../../../config/index.js', () => ({
84110
default: { stripe: { secretKey: 'sk_test_123' } },
@@ -111,7 +137,6 @@ describe('Billing service unit tests:', () => {
111137
const existingSub = {
112138
_id: 'sub_existing',
113139
stripeCustomerId: null,
114-
toObject: jest.fn().mockReturnValue({ _id: 'sub_existing', stripeCustomerId: null }),
115140
};
116141
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existingSub);
117142
mockSubscriptionRepository.update.mockResolvedValue({ stripeCustomerId: 'cus_new123' });
@@ -196,7 +221,7 @@ describe('Billing service unit tests:', () => {
196221
);
197222
});
198223

199-
test('should return portal session URL when customer exists', async () => {
224+
test('should return portal session URL without returnUrl', async () => {
200225
jest.unstable_mockModule('../../../config/index.js', () => ({
201226
default: { stripe: { secretKey: 'sk_test_portal3' } },
202227
}));
@@ -213,6 +238,38 @@ describe('Billing service unit tests:', () => {
213238
customer: 'cus_portal',
214239
});
215240
});
241+
242+
test('should include return_url when returnUrl is provided', async () => {
243+
jest.unstable_mockModule('../../../config/index.js', () => ({
244+
default: { stripe: { secretKey: 'sk_test_portal4' } },
245+
}));
246+
247+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_portal' });
248+
249+
const mod = await import('../services/billing.service.js');
250+
BillingService = mod.default;
251+
252+
const url = await BillingService.createPortalSession(mockOrganization, 'http://app/settings');
253+
254+
expect(url).toBe('https://billing.stripe.com/portal_123');
255+
expect(mockStripeInstance.billingPortal.sessions.create).toHaveBeenCalledWith({
256+
customer: 'cus_portal',
257+
return_url: 'http://app/settings',
258+
});
259+
});
260+
261+
test('should throw when returnUrl is invalid', async () => {
262+
jest.unstable_mockModule('../../../config/index.js', () => ({
263+
default: { stripe: { secretKey: 'sk_test_portal5' } },
264+
}));
265+
266+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_portal' });
267+
268+
const mod = await import('../services/billing.service.js');
269+
BillingService = mod.default;
270+
271+
await expect(BillingService.createPortalSession(mockOrganization, 'not-a-url')).rejects.toThrow('Invalid return URL');
272+
});
216273
});
217274

218275
describe('getSubscription', () => {

0 commit comments

Comments
 (0)