fix(security): add input validation for POST/PATCH endpoints

- Install express-validator package
- Add validation to beneficiaries.js:
  - POST /: name (string 1-200), phone (optional), address (optional)
  - PATCH /🆔 name (string 1-200), phone, address, customName (max 100)
- Add validation to stripe.js:
  - create-checkout-session: userId, beneficiaryName, beneficiaryAddress, email
  - create-portal-session: customerId (string)
  - create-payment-sheet: email (valid email), amount (positive int)
  - create-subscription: beneficiaryId (int), paymentMethodId (string)
  - cancel-subscription: beneficiaryId (int)
  - reactivate-subscription: beneficiaryId (int)
  - create-subscription-payment-sheet: beneficiaryId (int)
  - confirm-subscription-payment: subscriptionId (string)
- Add validation to invitations.js:
  - POST /: beneficiaryId (int), role (enum: caretaker/guardian), email (valid)
  - POST /accept: code (string)
  - POST /accept-public: code (string)
  - PATCH /🆔 role (enum: caretaker/guardian)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-26 16:47:35 -08:00
parent a055e1b6f8
commit 4a4fc5c077
5 changed files with 284 additions and 70 deletions

View File

@ -16,6 +16,7 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@ -2283,6 +2284,19 @@
"express": ">= 4.11" "express": ">= 4.11"
} }
}, },
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
"integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"validator": "~13.15.23"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.2.5", "version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
@ -2703,6 +2717,12 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -3603,6 +3623,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.26",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -16,6 +16,7 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const Stripe = require('stripe'); const Stripe = require('stripe');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const storage = require('../services/storage'); const storage = require('../services/storage');
const legacyAPI = require('../services/legacyAPI'); const legacyAPI = require('../services/legacyAPI');
@ -362,15 +363,32 @@ router.get('/:id', async (req, res) => {
* Now uses the proper beneficiaries table (not users) * Now uses the proper beneficiaries table (not users)
* AUTO-CREATES FIRST DEPLOYMENT * AUTO-CREATES FIRST DEPLOYMENT
*/ */
router.post('/', async (req, res) => { router.post('/',
[
body('name')
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim()
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId; const userId = req.user.userId;
const { name, phone, address } = req.body; const { name, phone, address } = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
console.log('[BENEFICIARY] Creating beneficiary:', { userId, name }); console.log('[BENEFICIARY] Creating beneficiary:', { userId, name });
// Create beneficiary in the proper beneficiaries table (not users!) // Create beneficiary in the proper beneficiaries table (not users!)
@ -563,8 +581,35 @@ router.post('/', async (req, res) => {
* - name, phone, address: beneficiary data (custodian only) * - name, phone, address: beneficiary data (custodian only)
* - customName: user's personal alias for this beneficiary (any role) * - customName: user's personal alias for this beneficiary (any role)
*/ */
router.patch('/:id', async (req, res) => { router.patch('/:id',
[
body('name')
.optional()
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim(),
body('customName')
.optional({ nullable: true })
.isString().withMessage('customName must be a string')
.trim()
.isLength({ max: 100 }).withMessage('customName must be 100 characters or less')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId; const userId = req.user.userId;
const beneficiaryId = parseInt(req.params.id, 10); const beneficiaryId = parseInt(req.params.id, 10);

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto'); const crypto = require('crypto');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const { sendInvitationEmail } = require('../services/email'); const { sendInvitationEmail } = require('../services/email');
@ -78,14 +79,23 @@ router.get('/info/:code', async (req, res) => {
* POST /api/invitations/accept-public * POST /api/invitations/accept-public
* Used from web page - no login required * Used from web page - no login required
*/ */
router.post('/accept-public', async (req, res) => { router.post('/accept-public',
[
body('code')
.notEmpty().withMessage('code is required')
.isString().withMessage('code must be a string')
.trim()
],
async (req, res) => {
try { try {
const { code } = req.body; // Check validation errors
const errors = validationResult(req);
if (!code) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Code is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { code } = req.body;
console.log('[INVITE] Public accept:', { code }); console.log('[INVITE] Public accept:', { code });
// Find invitation by code // Find invitation by code
@ -232,21 +242,37 @@ function generateInviteToken() {
* POST /api/invitations * POST /api/invitations
* Creates an invitation for someone to access a beneficiary * Creates an invitation for someone to access a beneficiary
*/ */
router.post('/', async (req, res) => { router.post('/',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'),
body('role')
.notEmpty().withMessage('role is required')
.isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian'),
body('email')
.optional({ nullable: true })
.isEmail().withMessage('email must be a valid email address')
.normalizeEmail(),
body('label')
.optional({ nullable: true })
.isString().withMessage('label must be a string')
.trim()
.isLength({ max: 100 }).withMessage('label must be 100 characters or less')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId; const userId = req.user.userId;
const { beneficiaryId, role, email, label } = req.body; const { beneficiaryId, role, email, label } = req.body;
console.log('[INVITE] Creating invitation:', { userId, beneficiaryId, role, email }); console.log('[INVITE] Creating invitation:', { userId, beneficiaryId, role, email });
if (!beneficiaryId || !role) {
return res.status(400).json({ error: 'beneficiaryId and role are required' });
}
if (!['caretaker', 'guardian'].includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be: caretaker or guardian' });
}
// Get current user's email to check self-invite // Get current user's email to check self-invite
const { data: currentUser, error: userError } = await supabase const { data: currentUser, error: userError } = await supabase
.from('users') .from('users')
@ -439,15 +465,24 @@ router.get('/beneficiary/:beneficiaryId', async (req, res) => {
* POST /api/invitations/accept * POST /api/invitations/accept
* Accepts an invitation code * Accepts an invitation code
*/ */
router.post('/accept', async (req, res) => { router.post('/accept',
[
body('code')
.notEmpty().withMessage('code is required')
.isString().withMessage('code must be a string')
.trim()
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId; const userId = req.user.userId;
const { code } = req.body; const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
}
// Find valid invitation (no expiration check - invitations are permanent) // Find valid invitation (no expiration check - invitations are permanent)
const { data: invitation, error: findError } = await supabase const { data: invitation, error: findError } = await supabase
.from('invitations') .from('invitations')
@ -606,18 +641,26 @@ router.get('/', async (req, res) => {
* PATCH /api/invitations/:id * PATCH /api/invitations/:id
* Updates an invitation's role (before it's accepted) * Updates an invitation's role (before it's accepted)
*/ */
router.patch('/:id', async (req, res) => { router.patch('/:id',
[
body('role')
.notEmpty().withMessage('role is required')
.isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId; const userId = req.user.userId;
const invitationId = parseInt(req.params.id, 10); const invitationId = parseInt(req.params.id, 10);
const { role } = req.body; const { role } = req.body;
console.log('[INVITE] Update invitation:', { userId, invitationId, role }); console.log('[INVITE] Update invitation:', { userId, invitationId, role });
if (!role || !['caretaker', 'guardian'].includes(role)) {
return res.status(400).json({ error: 'Valid role is required (caretaker or guardian)' });
}
// Check invitation belongs to user // Check invitation belongs to user
const { data: invitation, error: findError } = await supabase const { data: invitation, error: findError } = await supabase
.from('invitations') .from('invitations')

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const Stripe = require('stripe'); const Stripe = require('stripe');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
@ -9,8 +10,42 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
* POST /api/stripe/create-checkout-session * POST /api/stripe/create-checkout-session
* Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription * Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription
*/ */
router.post('/create-checkout-session', async (req, res) => { router.post('/create-checkout-session',
[
body('userId')
.notEmpty().withMessage('userId is required')
.isString().withMessage('userId must be a string'),
body('beneficiaryName')
.notEmpty().withMessage('beneficiaryName is required')
.isString().withMessage('beneficiaryName must be a string')
.trim(),
body('beneficiaryAddress')
.notEmpty().withMessage('beneficiaryAddress is required')
.isString().withMessage('beneficiaryAddress must be a string')
.trim(),
body('beneficiaryPhone')
.optional({ nullable: true })
.isString().withMessage('beneficiaryPhone must be a string')
.trim(),
body('beneficiaryNotes')
.optional({ nullable: true })
.isString().withMessage('beneficiaryNotes must be a string')
.trim(),
body('includePremium')
.optional()
.isBoolean().withMessage('includePremium must be a boolean'),
body('email')
.optional()
.isEmail().withMessage('email must be a valid email address')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { const {
userId, userId,
beneficiaryName, beneficiaryName,
@ -21,14 +56,6 @@ router.post('/create-checkout-session', async (req, res) => {
includePremium = true includePremium = true
} = req.body; } = req.body;
if (!userId) {
return res.status(400).json({ error: 'userId is required' });
}
if (!beneficiaryName || !beneficiaryAddress) {
return res.status(400).json({ error: 'Beneficiary name and address are required' });
}
// Build line items // Build line items
const lineItems = [ const lineItems = [
{ {
@ -111,14 +138,23 @@ router.post('/create-checkout-session', async (req, res) => {
* POST /api/stripe/create-portal-session * POST /api/stripe/create-portal-session
* Creates a Stripe Customer Portal session for managing subscriptions * Creates a Stripe Customer Portal session for managing subscriptions
*/ */
router.post('/create-portal-session', async (req, res) => { router.post('/create-portal-session',
[
body('customerId')
.notEmpty().withMessage('customerId is required')
.isString().withMessage('customerId must be a string')
.trim()
],
async (req, res) => {
try { try {
const { customerId } = req.body; // Check validation errors
const errors = validationResult(req);
if (!customerId) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'customerId is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { customerId } = req.body;
const session = await stripe.billingPortal.sessions.create({ const session = await stripe.billingPortal.sessions.create({
customer: customerId, customer: customerId,
return_url: `${process.env.FRONTEND_URL}/settings`, return_url: `${process.env.FRONTEND_URL}/settings`,
@ -136,8 +172,23 @@ router.post('/create-portal-session', async (req, res) => {
* POST /api/stripe/create-payment-sheet * POST /api/stripe/create-payment-sheet
* Creates PaymentIntent for in-app Payment Sheet (React Native) * Creates PaymentIntent for in-app Payment Sheet (React Native)
*/ */
router.post('/create-payment-sheet', async (req, res) => { router.post('/create-payment-sheet',
[
body('email')
.optional()
.isEmail().withMessage('email must be a valid email address'),
body('amount')
.optional()
.isInt({ min: 1 }).withMessage('amount must be a positive integer')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { email, amount = 24900 } = req.body; // $249.00 default (Starter Kit) const { email, amount = 24900 } = req.body; // $249.00 default (Starter Kit)
// Create or retrieve customer // Create or retrieve customer
@ -249,14 +300,26 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
* Creates a Stripe Subscription for a beneficiary * Creates a Stripe Subscription for a beneficiary
* Uses Stripe as the source of truth - no local subscription table needed! * Uses Stripe as the source of truth - no local subscription table needed!
*/ */
router.post('/create-subscription', async (req, res) => { router.post('/create-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'),
body('paymentMethodId')
.optional()
.isString().withMessage('paymentMethodId must be a string')
.trim()
],
async (req, res) => {
try { try {
const { beneficiaryId, paymentMethodId } = req.body; // Check validation errors
const errors = validationResult(req);
if (!beneficiaryId) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'beneficiaryId is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { beneficiaryId, paymentMethodId } = req.body;
// Get or create Stripe customer for this beneficiary // Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId); const customerId = await getOrCreateStripeCustomer(beneficiaryId);
@ -421,15 +484,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
* POST /api/stripe/cancel-subscription * POST /api/stripe/cancel-subscription
* Cancels subscription at period end * Cancels subscription at period end
*/ */
router.post('/cancel-subscription', async (req, res) => { router.post('/cancel-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try { try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { beneficiaryId } = req.body; const { beneficiaryId } = req.body;
console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId); console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId);
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
// Get beneficiary's stripe_customer_id // Get beneficiary's stripe_customer_id
const { data: beneficiary, error: dbError } = await supabase const { data: beneficiary, error: dbError } = await supabase
.from('beneficiaries') .from('beneficiaries')
@ -482,14 +553,22 @@ router.post('/cancel-subscription', async (req, res) => {
* POST /api/stripe/reactivate-subscription * POST /api/stripe/reactivate-subscription
* Reactivates a subscription that was set to cancel * Reactivates a subscription that was set to cancel
*/ */
router.post('/reactivate-subscription', async (req, res) => { router.post('/reactivate-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try { try {
const { beneficiaryId } = req.body; // Check validation errors
const errors = validationResult(req);
if (!beneficiaryId) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'beneficiaryId is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { beneficiaryId } = req.body;
const { data: beneficiary } = await supabase const { data: beneficiary } = await supabase
.from('beneficiaries') .from('beneficiaries')
.select('stripe_customer_id') .select('stripe_customer_id')
@ -532,14 +611,22 @@ router.post('/reactivate-subscription', async (req, res) => {
* Creates a SetupIntent for collecting payment method in React Native app * Creates a SetupIntent for collecting payment method in React Native app
* Then creates subscription with that payment method * Then creates subscription with that payment method
*/ */
router.post('/create-subscription-payment-sheet', async (req, res) => { router.post('/create-subscription-payment-sheet',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try { try {
const { beneficiaryId } = req.body; // Check validation errors
const errors = validationResult(req);
if (!beneficiaryId) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'beneficiaryId is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { beneficiaryId } = req.body;
// Get or create Stripe customer for this beneficiary // Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId); const customerId = await getOrCreateStripeCustomer(beneficiaryId);
@ -644,14 +731,23 @@ router.post('/create-subscription-payment-sheet', async (req, res) => {
* POST /api/stripe/confirm-subscription-payment * POST /api/stripe/confirm-subscription-payment
* Confirms the latest invoice PaymentIntent for a subscription if needed * Confirms the latest invoice PaymentIntent for a subscription if needed
*/ */
router.post('/confirm-subscription-payment', async (req, res) => { router.post('/confirm-subscription-payment',
[
body('subscriptionId')
.notEmpty().withMessage('subscriptionId is required')
.isString().withMessage('subscriptionId must be a string')
.trim()
],
async (req, res) => {
try { try {
const { subscriptionId } = req.body; // Check validation errors
const errors = validationResult(req);
if (!subscriptionId) { if (!errors.isEmpty()) {
return res.status(400).json({ error: 'subscriptionId is required' }); return res.status(400).json({ error: errors.array()[0].msg });
} }
const { subscriptionId } = req.body;
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['latest_invoice.payment_intent'] expand: ['latest_invoice.payment_intent']
}); });