From b5a0e6cacfe5a4cd8f7d64f0feff3bac1c1ca07e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:56:45 -0400 Subject: [PATCH 01/40] feat(api): add Zod request validation middleware --- backend/src/middleware/validateRequest.ts | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/src/middleware/validateRequest.ts diff --git a/backend/src/middleware/validateRequest.ts b/backend/src/middleware/validateRequest.ts new file mode 100644 index 00000000..ab17f167 --- /dev/null +++ b/backend/src/middleware/validateRequest.ts @@ -0,0 +1,46 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { z } from 'zod'; + +export interface RequestValidationSchemas { + body?: z.ZodType; + query?: z.ZodType; + params?: z.ZodType; +} + +type RequestLocation = keyof RequestValidationSchemas; + +export function validateRequest(schemas: RequestValidationSchemas): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const fields: Array<{ + location: RequestLocation; + field: string; + message: string; + code: string; + }> = []; + + for (const location of ['params', 'query', 'body'] as const) { + const schema = schemas[location]; + if (!schema) continue; + + const result = schema.safeParse(req[location]); + if (result.success) continue; + + for (const issue of result.error.issues) { + const nestedPath = issue.path.map(String).join('.'); + fields.push({ + location, + field: nestedPath ? `${location}.${nestedPath}` : location, + message: issue.message, + code: issue.code, + }); + } + } + + if (fields.length > 0) { + res.status(400).json({ error: 'Validation failed', fields }); + return; + } + + next(); + }; +} From 638dddcfdf5226d3263a47a7ba2c521c5d84dbb8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:58:27 -0400 Subject: [PATCH 02/40] feat(api): validate auth request bodies --- backend/src/routes/authRoutes.ts | 42 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index 16f75526..3d158435 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -1,20 +1,47 @@ import { Router } from 'express'; +import { z } from 'zod'; import passport from 'passport'; import { AuthController } from '../controllers/authController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { authorizeRoles } from '../middlewares/rbac.js'; import { TWO_FACTOR_ROLES } from '../services/twoFactorService.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); -router.post('/login', AuthController.login); -router.post('/register', AuthController.register); -router.post('/refresh', AuthController.refresh); +const walletAddressBodySchema = z.object({ walletAddress: z.string().min(1) }); +const registerBodySchema = z.object({ + walletAddress: z.string().min(1), + invitationToken: z.string().min(1), +}); +const refreshBodySchema = z.object({ refreshToken: z.string().min(1) }); +const invitationBodySchema = z.object({ + email: z.string().email().max(255).optional(), + expiresInDays: z.number().int().min(1).max(30).optional(), +}); +const twoFactorCodeBodySchema = z + .object({ + token: z.string().min(1).optional(), + code: z.string().min(1).optional(), + }) + .refine((body) => Boolean(body.token || body.code), { + message: 'code or token is required', + path: ['code'], + }); +const authenticate2faBodySchema = twoFactorCodeBodySchema.and( + z.object({ challengeToken: z.string().min(1) }) +); +const optionalObjectBodySchema = z.object({}).passthrough().optional(); + +router.post('/login', validateRequest({ body: walletAddressBodySchema }), AuthController.login); +router.post('/register', validateRequest({ body: registerBodySchema }), AuthController.register); +router.post('/refresh', validateRequest({ body: refreshBodySchema }), AuthController.refresh); router.post( '/invitations', authenticateJWT, authorizeRoles('EMPLOYER'), + validateRequest({ body: invitationBodySchema }), AuthController.createInvitation ); @@ -27,7 +54,11 @@ router.post( // Second step of login: exchanges the challenge issued by /login for a session. // Unauthenticated by design — the challenge token is the credential. -router.post('/2fa/authenticate', AuthController.authenticate2fa); +router.post( + '/2fa/authenticate', + validateRequest({ body: authenticate2faBodySchema }), + AuthController.authenticate2fa +); router.get('/2fa/status', authenticateJWT, AuthController.status2fa); @@ -35,18 +66,21 @@ router.post( '/2fa/setup', authenticateJWT, authorizeRoles(...TWO_FACTOR_ROLES), + validateRequest({ body: optionalObjectBodySchema }), AuthController.setup2fa ); router.post( '/2fa/verify', authenticateJWT, authorizeRoles(...TWO_FACTOR_ROLES), + validateRequest({ body: twoFactorCodeBodySchema }), AuthController.verify2fa ); router.post( '/2fa/disable', authenticateJWT, authorizeRoles(...TWO_FACTOR_ROLES), + validateRequest({ body: twoFactorCodeBodySchema }), AuthController.disable2fa ); From a46d39219b13b8588db6e15ed4bfe1e731f019e0 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:58:40 -0400 Subject: [PATCH 03/40] feat(api): validate employee route inputs --- backend/src/routes/employeeRoutes.ts | 49 ++++++++++++---------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/backend/src/routes/employeeRoutes.ts b/backend/src/routes/employeeRoutes.ts index 60ed9f8a..0355a7ff 100644 --- a/backend/src/routes/employeeRoutes.ts +++ b/backend/src/routes/employeeRoutes.ts @@ -1,4 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; import { employeeController } from '../controllers/employeeController.js'; import authenticateJWT from '../middlewares/auth.js'; import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; @@ -10,6 +11,20 @@ import { validateActiveTenant, logTenantAccess, } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; +import { + createEmployeeSchema, + employeeQuerySchema, + updateEmployeeSchema, +} from '../schemas/employeeSchema.js'; +import { bulkImportController } from '../controllers/bulkImportController.js'; + +const employeeIdParamsSchema = z.object({ id: z.coerce.number().int().positive() }); +const createEmployeeBodySchema = createEmployeeSchema.omit({ organization_id: true }); +const bulkImportBodySchema = z.object({ + organization_id: z.number().int().positive(), + csv: z.string().min(1, 'csv is required'), +}); async function enforceEmployeeQuota(req: Request, res: Response, next: NextFunction): Promise { const orgId = req.tenantId ?? req.user?.organizationId; @@ -38,78 +53,56 @@ function enhancedIsolation(): any[] { const router = Router(); -// Apply authentication to all employee routes router.use(authenticateJWT); - -// Enhanced tenant isolation — runs after auth (req.user is available) router.use(...enhancedIsolation()); -/** - * @route POST /api/employees - * @desc Create a new employee - */ router.post( '/', authorizeRoles('EMPLOYER'), isolateOrganization, + validateRequest({ body: createEmployeeBodySchema }), enforceEmployeeQuota, employeeController.create.bind(employeeController) ); -/** - * @route GET /api/employees - * @desc Get all employees with pagination and filtering - */ router.get( '/', authorizeRoles('EMPLOYER'), isolateOrganization, + validateRequest({ query: employeeQuerySchema }), employeeController.getAll.bind(employeeController) ); -/** - * @route GET /api/employees/:id - * @desc Get a single employee by ID - */ router.get( '/:id', authorizeRoles('EMPLOYER', 'EMPLOYEE'), isolateOrganization, + validateRequest({ params: employeeIdParamsSchema }), employeeController.getOne.bind(employeeController) ); -/** - * @route PATCH /api/employees/:id - * @desc Update an employee - */ router.patch( '/:id', authorizeRoles('EMPLOYER'), isolateOrganization, + validateRequest({ params: employeeIdParamsSchema, body: updateEmployeeSchema }), employeeController.update.bind(employeeController) ); -/** - * @route DELETE /api/employees/:id - * @desc Soft delete an employee (sensitive operation — fully audited) - */ router.delete( '/:id', authorizeRoles('EMPLOYER'), isolateOrganization, + validateRequest({ params: employeeIdParamsSchema }), auditSensitiveOperation('employee_delete'), employeeController.delete.bind(employeeController) ); -/** - * @route POST /api/employees/bulk-import - * @desc Bulk import employees from CSV - */ -import { bulkImportController } from '../controllers/bulkImportController.js'; router.post( '/bulk-import', authorizeRoles('EMPLOYER'), isolateOrganization, + validateRequest({ body: bulkImportBodySchema }), bulkImportController.import.bind(bulkImportController) ); From 08b0095eaa1f379c04f944d7d8466b5d033bea4c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:58:48 -0400 Subject: [PATCH 04/40] feat(api): validate schedule route inputs --- backend/src/routes/scheduleRoutes.ts | 45 +++++++++++----------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/backend/src/routes/scheduleRoutes.ts b/backend/src/routes/scheduleRoutes.ts index 410ed2b4..107ce464 100644 --- a/backend/src/routes/scheduleRoutes.ts +++ b/backend/src/routes/scheduleRoutes.ts @@ -1,49 +1,38 @@ import { Router } from 'express'; +import { z } from 'zod'; import { ScheduleController } from '../controllers/scheduleController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; import { idempotencyMiddleware } from '../middleware/idempotencyMiddleware.js'; +import { validateRequest } from '../middleware/validateRequest.js'; +import { createScheduleSchema, scheduleQuerySchema } from '../schemas/scheduleSchema.js'; const router = Router(); +const scheduleIdParamsSchema = z.object({ id: z.coerce.number().int().positive() }); -// Apply authentication to all schedule routes router.use(authenticateJWT); router.use(isolateOrganization); -/** - * @route POST /api/schedules - * @desc Create a new payroll schedule - * @access Private - Requires authentication - * @body {CreateScheduleRequest} Schedule configuration - * @returns {CreateScheduleResponse} Created schedule with ID and next run timestamp - */ router.post( '/', authorizeRoles('EMPLOYER'), + validateRequest({ body: createScheduleSchema }), idempotencyMiddleware(), ScheduleController.createSchedule ); -/** - * @route GET /api/schedules - * @desc Get all schedules for the authenticated user's organization - * @access Private - Requires authentication - * @query {string} status - Optional filter by status (active, completed, cancelled) - * @query {number} page - Optional page number for pagination - * @query {number} limit - Optional items per page - * @returns {GetSchedulesResponse} List of schedules with pagination metadata - */ -router.get('/', authorizeRoles('EMPLOYER'), ScheduleController.getSchedules); +router.get( + '/', + authorizeRoles('EMPLOYER'), + validateRequest({ query: scheduleQuerySchema }), + ScheduleController.getSchedules +); -/** - * @route DELETE /api/schedules/:id - * @desc Cancel a pending schedule - * @access Private - Requires authentication and schedule ownership - * @param {number} id - Schedule ID - * @returns {204} No content on success - * @returns {404} Schedule not found - * @returns {403} User doesn't own this schedule - */ -router.delete('/:id', authorizeRoles('EMPLOYER'), ScheduleController.deleteSchedule); +router.delete( + '/:id', + authorizeRoles('EMPLOYER'), + validateRequest({ params: scheduleIdParamsSchema }), + ScheduleController.deleteSchedule +); export default router; From 9a9e6ea21a341fe54e4a88f98bad3f002dfb819e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:59:05 -0400 Subject: [PATCH 05/40] feat(api): validate benefits route inputs --- backend/src/routes/benefitsRoutes.ts | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/routes/benefitsRoutes.ts b/backend/src/routes/benefitsRoutes.ts index a1ff53da..3bbc1e23 100644 --- a/backend/src/routes/benefitsRoutes.ts +++ b/backend/src/routes/benefitsRoutes.ts @@ -1,11 +1,38 @@ import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; import { BenefitsController } from '../controllers/benefitsController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; import { setTenantContext } from '../middleware/tenantContext.js'; +import { validateRequest } from '../middleware/validateRequest.js'; +import { + benefitPlanSchema, + updateBenefitPlanSchema, + employeeBenefitEnrollmentSchema, + deductionRuleSchema, + updateDeductionRuleSchema, + draftPayslipSchema, +} from '../schemas/benefitsSchema.js'; const router = Router(); +const organizationParamsSchema = z.object({ + organizationId: z.coerce.number().int().positive(), +}).passthrough(); +const resourceParamsSchema = organizationParamsSchema.extend({ + id: z.coerce.number().int().positive(), +}); +const employeeParamsSchema = organizationParamsSchema.extend({ + employeeId: z.coerce.number().int().positive(), +}); +const includeInactiveQuerySchema = z.object({ + includeInactive: z.enum(['true', 'false']).optional(), +}); +const benefitPlanBodySchema = benefitPlanSchema.omit({ organization_id: true }); +const enrollmentBodySchema = employeeBenefitEnrollmentSchema.omit({ organization_id: true }); +const deductionRuleBodySchema = deductionRuleSchema.omit({ organization_id: true }); +const draftPayslipBodySchema = draftPayslipSchema.omit({ organization_id: true }); + router.use(authenticateJWT); router.use(isolateOrganization); @@ -23,6 +50,7 @@ router.post( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, body: benefitPlanBodySchema }), BenefitsController.createBenefitPlan ); @@ -31,6 +59,7 @@ router.get( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, query: includeInactiveQuerySchema }), BenefitsController.listBenefitPlans ); @@ -39,6 +68,7 @@ router.put( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: resourceParamsSchema, body: updateBenefitPlanSchema }), BenefitsController.updateBenefitPlan ); @@ -47,6 +77,7 @@ router.delete( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: resourceParamsSchema }), BenefitsController.deleteBenefitPlan ); @@ -56,6 +87,7 @@ router.post( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, body: enrollmentBodySchema }), BenefitsController.upsertEmployeeEnrollment ); @@ -64,6 +96,7 @@ router.get( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: employeeParamsSchema }), BenefitsController.listEmployeeEnrollments ); @@ -73,6 +106,7 @@ router.post( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, body: deductionRuleBodySchema }), BenefitsController.createDeductionRule ); @@ -81,6 +115,7 @@ router.get( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, query: includeInactiveQuerySchema }), BenefitsController.listDeductionRules ); @@ -89,6 +124,7 @@ router.put( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: resourceParamsSchema, body: updateDeductionRuleSchema }), BenefitsController.updateDeductionRule ); @@ -97,6 +133,7 @@ router.delete( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: resourceParamsSchema }), BenefitsController.deleteDeductionRule ); @@ -106,6 +143,7 @@ router.post( authorizeRoles('EMPLOYER'), setTenantFromJwt, setTenantContext, + validateRequest({ params: organizationParamsSchema, body: draftPayslipBodySchema }), BenefitsController.generateDraftPayslip ); From 316133ca802f8aa6ee0d1cd41502f84837ab19a7 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:59:37 -0400 Subject: [PATCH 06/40] feat(api): validate tax route inputs --- backend/src/routes/taxRoutes.ts | 45 +++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/backend/src/routes/taxRoutes.ts b/backend/src/routes/taxRoutes.ts index 28ae1da5..665b3c13 100644 --- a/backend/src/routes/taxRoutes.ts +++ b/backend/src/routes/taxRoutes.ts @@ -1,26 +1,49 @@ import { Router } from 'express'; +import { z } from 'zod'; import { TaxController } from '../controllers/taxController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const taxRuleBodySchema = z.object({ + organization_id: z.number().int().positive(), + name: z.string().min(1), + type: z.string().min(1), + value: z.number().nonnegative(), + description: z.string().optional(), + priority: z.number().int().optional(), +}); +const updateTaxRuleBodySchema = taxRuleBodySchema.partial(); +const taxRuleParamsSchema = z.object({ id: z.coerce.number().int().positive() }); +const taxRulesQuerySchema = z.object({ + organizationId: z.coerce.number().int().positive(), + includeInactive: z.enum(['true', 'false']).optional(), +}); +const taxCalculationBodySchema = z.object({ + organization_id: z.number().int().positive(), + gross_amount: z.number().nonnegative(), + employee_id: z.number().int().positive().optional(), + currency: z.string().min(1).max(12).optional(), +}); +const taxReportQuerySchema = z.object({ + organizationId: z.coerce.number().int().positive(), + periodStart: z.string().min(1), + periodEnd: z.string().min(1), +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); router.use(logTenantAccess); -// Tax rule CRUD -router.post('/rules', TaxController.createRule); -router.get('/rules', TaxController.getRules); -router.put('/rules/:id', TaxController.updateRule); -router.delete('/rules/:id', TaxController.deleteRule); - -// Tax calculation -router.post('/calculate', TaxController.calculateDeductions); - -// Tax compliance reports -router.get('/reports', TaxController.getReport); +router.post('/rules', validateRequest({ body: taxRuleBodySchema }), TaxController.createRule); +router.get('/rules', validateRequest({ query: taxRulesQuerySchema }), TaxController.getRules); +router.put('/rules/:id', validateRequest({ params: taxRuleParamsSchema, body: updateTaxRuleBodySchema }), TaxController.updateRule); +router.delete('/rules/:id', validateRequest({ params: taxRuleParamsSchema }), TaxController.deleteRule); +router.post('/calculate', validateRequest({ body: taxCalculationBodySchema }), TaxController.calculateDeductions); +router.get('/reports', validateRequest({ query: taxReportQuerySchema }), TaxController.getReport); export default router; From c3b51bd2ebc4ced22f1b8b66a3f45379ff40b6ee Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:59:46 -0400 Subject: [PATCH 07/40] feat(api): validate asset route inputs --- backend/src/routes/assetRoutes.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/assetRoutes.ts b/backend/src/routes/assetRoutes.ts index 60fe522b..d1ab5785 100644 --- a/backend/src/routes/assetRoutes.ts +++ b/backend/src/routes/assetRoutes.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { z } from 'zod'; import { AssetController } from '../controllers/assetController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { authorizeRoles } from '../middlewares/rbac.js'; @@ -8,9 +9,28 @@ import { validateActiveTenant, logTenantAccess, } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const amountSchema = z.union([z.number().positive(), z.string().min(1)]); +const issueAssetBodySchema = z.object({ + issuerSecret: z.string().min(1), + distributorSecret: z.string().min(1), + amount: amountSchema, +}); +const clawbackBodySchema = z.object({ + issuerSecret: z.string().min(1), + fromAccount: z.string().min(1), + amount: amountSchema, + reason: z.string().max(500).optional(), +}); +const clawbackLogsQuerySchema = z.object({ + fromAccount: z.string().min(1).optional(), + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -18,9 +38,8 @@ router.use(validateActiveTenant); router.use(logTenantAccess); router.use(authorizeRoles('EMPLOYER')); -router.post('/issue', AssetController.issueOrgUsd); -router.post('/clawback', AssetController.clawback); -router.get('/clawback/logs', AssetController.getClawbackLogs); +router.post('/issue', validateRequest({ body: issueAssetBodySchema }), AssetController.issueOrgUsd); +router.post('/clawback', validateRequest({ body: clawbackBodySchema }), AssetController.clawback); +router.get('/clawback/logs', validateRequest({ query: clawbackLogsQuerySchema }), AssetController.getClawbackLogs); export default router; - From 4785ae6b7197d079f0d0349114783af729f1dafd Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 00:03:40 -0400 Subject: [PATCH 08/40] feat(api): validate freeze route inputs --- backend/src/routes/freezeRoutes.ts | 84 +++++++++++------------------- 1 file changed, 29 insertions(+), 55 deletions(-) diff --git a/backend/src/routes/freezeRoutes.ts b/backend/src/routes/freezeRoutes.ts index 9c50f32b..8c78d00a 100644 --- a/backend/src/routes/freezeRoutes.ts +++ b/backend/src/routes/freezeRoutes.ts @@ -1,64 +1,38 @@ import { Router } from 'express'; +import { z } from 'zod'; import { FreezeController } from '../controllers/freezeController.js'; import { rateLimitMiddleware } from '../middlewares/rateLimitMiddleware.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); - -// Apply a slightly stricter rate limit for administrative actions const adminRateLimit = rateLimitMiddleware({ tier: 'api' }); -// --------------------------------------------------------------------------- -// Account-level Freeze Operations -// --------------------------------------------------------------------------- - -/** - * @route POST /api/v1/freeze/account/freeze - * @desc Freeze a single account's trustline for an asset - * @access Admin (Requires issuerSecret) - */ -router.post('/account/freeze', adminRateLimit, FreezeController.freezeAccount); - -/** - * @route POST /api/v1/freeze/account/unfreeze - * @desc Restore a single account's trustline for an asset - * @access Admin (Requires issuerSecret) - */ -router.post('/account/unfreeze', adminRateLimit, FreezeController.unfreezeAccount); - -// --------------------------------------------------------------------------- -// Global Freeze Operations (All Holders) -// --------------------------------------------------------------------------- - -/** - * @route POST /api/v1/freeze/global/freeze - * @desc Pause transfers for ALL accounts holding the specified asset globally - * @access Admin (Requires issuerSecret) - */ -router.post('/global/freeze', adminRateLimit, FreezeController.freezeGlobal); - -/** - * @route POST /api/v1/freeze/global/unfreeze - * @desc Restore transfers for ALL accounts holding the specified asset globally - * @access Admin (Requires issuerSecret) - */ -router.post('/global/unfreeze', adminRateLimit, FreezeController.unfreezeGlobal); - -// --------------------------------------------------------------------------- -// Status & Audit -// --------------------------------------------------------------------------- - -/** - * @route GET /api/v1/freeze/status/:targetAccount - * @desc Query the active freeze status of an account's trustline - * @query { assetCode, assetIssuer } - */ -router.get('/status/:targetAccount', FreezeController.checkStatus); - -/** - * @route GET /api/v1/freeze/logs - * @desc Paginated history of all freeze and unfreeze actions - * @query { page, limit, targetAccount, action, assetCode } - */ -router.get('/logs', FreezeController.getLogs); +const baseFreezeSchema = z.object({ + issuerSecret: z.string().min(56), + assetCode: z.string().min(1).max(12).regex(/^[A-Z0-9]+$/), + reason: z.string().max(500).optional(), +}); +const accountFreezeSchema = baseFreezeSchema.extend({ + targetAccount: z.string().length(56), +}); +const targetAccountParamsSchema = z.object({ targetAccount: z.string().length(56) }); +const statusQuerySchema = z.object({ + assetIssuer: z.string().length(56), + assetCode: z.string().min(1).max(12).regex(/^[A-Z0-9]+$/), +}); +const listLogsQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + targetAccount: z.string().length(56).optional(), + action: z.enum(['freeze', 'unfreeze']).optional(), + assetCode: z.string().max(12).regex(/^[A-Z0-9]+$/).optional(), +}); + +router.post('/account/freeze', adminRateLimit, validateRequest({ body: accountFreezeSchema }), FreezeController.freezeAccount); +router.post('/account/unfreeze', adminRateLimit, validateRequest({ body: accountFreezeSchema }), FreezeController.unfreezeAccount); +router.post('/global/freeze', adminRateLimit, validateRequest({ body: baseFreezeSchema }), FreezeController.freezeGlobal); +router.post('/global/unfreeze', adminRateLimit, validateRequest({ body: baseFreezeSchema }), FreezeController.unfreezeGlobal); +router.get('/status/:targetAccount', validateRequest({ params: targetAccountParamsSchema, query: statusQuerySchema }), FreezeController.checkStatus); +router.get('/logs', validateRequest({ query: listLogsQuerySchema }), FreezeController.getLogs); export default router; From cb728b9fe395e2ca9296ff76efc59029c7cfad26 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 00:03:56 -0400 Subject: [PATCH 09/40] feat(api): validate multi-sig route inputs --- backend/src/routes/multiSigRoutes.ts | 38 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/backend/src/routes/multiSigRoutes.ts b/backend/src/routes/multiSigRoutes.ts index 6b21ec9d..64b011ea 100644 --- a/backend/src/routes/multiSigRoutes.ts +++ b/backend/src/routes/multiSigRoutes.ts @@ -1,21 +1,33 @@ import { Router } from 'express'; +import { z } from 'zod'; import { MultiSigController } from '../controllers/multiSigController.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); -// POST /api/v1/multisig/configure - Full multi-sig setup -router.post('/configure', MultiSigController.configure); +const secretSchema = z.string().min(1); +const publicKeyParamsSchema = z.object({ publicKey: z.string().min(1) }); +const thresholdsSchema = z.object({}).passthrough(); +const configureBodySchema = z.object({ + issuerSecret: secretSchema, + signers: z.array(z.unknown()).min(1), + thresholds: thresholdsSchema, +}); +const addSignerBodySchema = z.object({ + issuerSecret: secretSchema, + signerPublicKey: z.string().min(1), + weight: z.number(), +}); +const secretBodySchema = z.object({ issuerSecret: secretSchema }); +const thresholdsBodySchema = z.object({ + issuerSecret: secretSchema, + thresholds: thresholdsSchema, +}); -// GET /api/v1/multisig/status/:publicKey - Get current signers/thresholds -router.get('/status/:publicKey', MultiSigController.getStatus); - -// POST /api/v1/multisig/signers - Add a signer -router.post('/signers', MultiSigController.addSigner); - -// DELETE /api/v1/multisig/signers/:publicKey - Remove a signer -router.delete('/signers/:publicKey', MultiSigController.removeSigner); - -// PUT /api/v1/multisig/thresholds - Update thresholds -router.put('/thresholds', MultiSigController.updateThresholds); +router.post('/configure', validateRequest({ body: configureBodySchema }), MultiSigController.configure); +router.get('/status/:publicKey', validateRequest({ params: publicKeyParamsSchema }), MultiSigController.getStatus); +router.post('/signers', validateRequest({ body: addSignerBodySchema }), MultiSigController.addSigner); +router.delete('/signers/:publicKey', validateRequest({ params: publicKeyParamsSchema, body: secretBodySchema }), MultiSigController.removeSigner); +router.put('/thresholds', validateRequest({ body: thresholdsBodySchema }), MultiSigController.updateThresholds); export default router; From 39c0df297f6435f9c7c0f691eef9705eb550247c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 03:51:02 -0400 Subject: [PATCH 10/40] Align numeric request validation with controller parsing The boundary middleware validates coerced numbers but passes the original request text onward. Employee and schedule controllers use parseInt for IDs; asset audit-log pagination uses parseInt too. Exponent notation such as 1e3 therefore passes the existing check as 1000 and reaches these controllers as 1. Require decimal digit strings before the existing positive-integer coercion in the employee ID, schedule ID and asset page/limit schemas. Keep optional pagination, the limit100 cap, request objects, tenant/authentication middleware and other schemas unchanged. Each modified file includes a dated Apache modification notice. Benefits and tax controllers use Number, while freeze pagination parses its full schema again; those inspected paths already agree with their validators and are preserved. Source: existing PR695 / issue532; validateRequest.ts ab17f167eb8e15c34425c0f4afe1c50adb0abf6e, employeeController.ts c63bb47b4214f24422027f91d5fdbc200c8f9409, scheduleController.ts 7eb70fc5110c66748cfa99865d58c6b1bccc37eb, assetController.ts c43203eb948e850ca3c73508e9540accfac1f74b. Zod coercion/pipes: https://zod.dev/api . Integer parsing: https://tc39.es/ecma262/multipage/global-object.html#sec-parseint-string-radix . Validation is source-only: full preimages, complete line deltas and postimage identities were checked. No HTTP request, TypeScript build, Zod execution, tests, workflow or database operation ran. This correction does not establish complete issue532 coverage, upstream acceptance or deployment. --- backend/src/routes/assetRoutes.ts | 13 +++++++++++-- backend/src/routes/employeeRoutes.ts | 8 +++++++- backend/src/routes/scheduleRoutes.ts | 8 +++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/assetRoutes.ts b/backend/src/routes/assetRoutes.ts index d1ab5785..f5d23e7a 100644 --- a/backend/src/routes/assetRoutes.ts +++ b/backend/src/routes/assetRoutes.ts @@ -1,3 +1,4 @@ +// Modified 2026-10-05: validate decimal pagination before numeric coercion. import { Router } from 'express'; import { z } from 'zod'; import { AssetController } from '../controllers/assetController.js'; @@ -27,8 +28,16 @@ const clawbackBodySchema = z.object({ }); const clawbackLogsQuerySchema = z.object({ fromAccount: z.string().min(1).optional(), - page: z.coerce.number().int().positive().optional(), - limit: z.coerce.number().int().positive().max(100).optional(), + page: z + .string() + .regex(/^[0-9]+$/, 'Page must contain only decimal digits') + .pipe(z.coerce.number().int().positive()) + .optional(), + limit: z + .string() + .regex(/^[0-9]+$/, 'Limit must contain only decimal digits') + .pipe(z.coerce.number().int().positive().max(100)) + .optional(), }); router.use(authenticateJWT); diff --git a/backend/src/routes/employeeRoutes.ts b/backend/src/routes/employeeRoutes.ts index 0355a7ff..d7934fe1 100644 --- a/backend/src/routes/employeeRoutes.ts +++ b/backend/src/routes/employeeRoutes.ts @@ -1,3 +1,4 @@ +// Modified 2026-10-05: reject non-decimal route IDs before numeric coercion. import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { employeeController } from '../controllers/employeeController.js'; @@ -19,7 +20,12 @@ import { } from '../schemas/employeeSchema.js'; import { bulkImportController } from '../controllers/bulkImportController.js'; -const employeeIdParamsSchema = z.object({ id: z.coerce.number().int().positive() }); +const employeeIdParamsSchema = z.object({ + id: z + .string() + .regex(/^[0-9]+$/, 'ID must contain only decimal digits') + .pipe(z.coerce.number().int().positive()), +}); const createEmployeeBodySchema = createEmployeeSchema.omit({ organization_id: true }); const bulkImportBodySchema = z.object({ organization_id: z.number().int().positive(), diff --git a/backend/src/routes/scheduleRoutes.ts b/backend/src/routes/scheduleRoutes.ts index 107ce464..19a0efb1 100644 --- a/backend/src/routes/scheduleRoutes.ts +++ b/backend/src/routes/scheduleRoutes.ts @@ -1,3 +1,4 @@ +// Modified 2026-10-05: reject non-decimal route IDs before numeric coercion. import { Router } from 'express'; import { z } from 'zod'; import { ScheduleController } from '../controllers/scheduleController.js'; @@ -8,7 +9,12 @@ import { validateRequest } from '../middleware/validateRequest.js'; import { createScheduleSchema, scheduleQuerySchema } from '../schemas/scheduleSchema.js'; const router = Router(); -const scheduleIdParamsSchema = z.object({ id: z.coerce.number().int().positive() }); +const scheduleIdParamsSchema = z.object({ + id: z + .string() + .regex(/^[0-9]+$/, 'ID must contain only decimal digits') + .pipe(z.coerce.number().int().positive()), +}); router.use(authenticateJWT); router.use(isolateOrganization); From 9b575def29c6aeb6fc576cd9703b78c82e7e6df6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 14:54:03 -0400 Subject: [PATCH 11/40] Validate audit route inputs with shared field-level errors Complete the audit route boundary in the existing request-validation contribution. Validate all three transaction-hash routes, including POST, before controller dispatch. Preserve the list controller's pagination limits, filters, defaults and raw request types while returning the shared validation error envelope. Add five focused route cases using the real Express router, Zod schemas and validation middleware with controller doubles. No service, database, Horizon or transaction-submission behavior changes. Runtime results are recorded separately after execution. --- .../routes/__tests__/auditValidation.test.ts | 73 +++++++++++++++++++ backend/src/routes/auditRoutes.ts | 29 +++++++- 2 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 backend/src/routes/__tests__/auditValidation.test.ts diff --git a/backend/src/routes/__tests__/auditValidation.test.ts b/backend/src/routes/__tests__/auditValidation.test.ts new file mode 100644 index 00000000..9057bc66 --- /dev/null +++ b/backend/src/routes/__tests__/auditValidation.test.ts @@ -0,0 +1,73 @@ +import request from 'supertest'; +import express, { Request, Response } from 'express'; +import auditRoutes from '../auditRoutes.js'; +import { TransactionAuditController } from '../../controllers/transactionAuditController.js'; + +jest.mock('../../controllers/transactionAuditController.js', () => { + const reply = (req: Request, res: Response) => res.json({ query: req.query, hash: req.params.txHash }); + return { TransactionAuditController: { + listAuditRecords: jest.fn(reply), getAuditRecord: jest.fn(reply), + verifyAuditRecord: jest.fn(reply), createAuditRecord: jest.fn(reply), + } }; +}); + +const app = express(); +app.use(express.json()); +app.use('/audit', auditRoutes); + +beforeEach(() => jest.clearAllMocks()); + +describe('Audit request validation', () => { + it('leaves omitted list parameters for the existing controller defaults', async () => { + const res = await request(app).get('/audit'); + expect(res.status).toBe(200); + expect(res.body.query).toEqual({}); + }); + + it('preserves valid pagination and filters without changing their input types', async () => { + const query = { page: '2', limit: '100', status: 'Completed', type: 'contract_event', asset: 'USDC' }; + const res = await request(app).get('/audit').query(query); + expect(res.status).toBe(200); + expect(res.body.query).toEqual(query); + }); + + it('returns shared field-level errors before dispatching invalid list queries', async () => { + const res = await request(app).get('/audit').query({ limit: '101', status: 'Unknown' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(res.body.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ location: 'query', field: 'query.limit' }), + expect.objectContaining({ location: 'query', field: 'query.status' }), + ])); + expect(TransactionAuditController.listAuditRecords).not.toHaveBeenCalled(); + }); + + it('rejects a non-hexadecimal hash before all record, verify and create handlers', async () => { + const path = '/audit/' + 'g'.repeat(64); + const calls = [() => request(app).get(path), () => request(app).get(path + '/verify'), () => request(app).post(path)]; + for (const call of calls) { + const res = await call(); + expect(res.status).toBe(400); + expect(res.body.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ location: 'params', field: 'params.txHash' }), + ])); + } + expect(TransactionAuditController.getAuditRecord).not.toHaveBeenCalled(); + expect(TransactionAuditController.verifyAuditRecord).not.toHaveBeenCalled(); + expect(TransactionAuditController.createAuditRecord).not.toHaveBeenCalled(); + }); + + it('passes a valid hash unchanged to each corresponding controller', async () => { + const hash = 'aB01'.repeat(16); + const path = '/audit/' + hash; + const calls = [() => request(app).get(path), () => request(app).get(path + '/verify'), () => request(app).post(path)]; + for (const call of calls) { + const res = await call(); + expect(res.status).toBe(200); + expect(res.body.hash).toBe(hash); + } + expect(TransactionAuditController.getAuditRecord).toHaveBeenCalledTimes(1); + expect(TransactionAuditController.verifyAuditRecord).toHaveBeenCalledTimes(1); + expect(TransactionAuditController.createAuditRecord).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/routes/auditRoutes.ts b/backend/src/routes/auditRoutes.ts index 68f4fb0e..64726120 100644 --- a/backend/src/routes/auditRoutes.ts +++ b/backend/src/routes/auditRoutes.ts @@ -1,31 +1,52 @@ +// Modified 2026-10-05: validate audit requests before controller dispatch. import { Router } from 'express'; +import { z } from 'zod'; import { TransactionAuditController } from '../controllers/transactionAuditController.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +// Preserve the controller's supported filters, defaults and pagination limits. +// The shared middleware validates without replacing the original request values. +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + sourceAccount: z.string().length(56).optional(), + dateStart: z.string().optional(), + dateEnd: z.string().optional(), + status: z.enum(['Completed', 'Pending', 'Failed']).optional(), + employeeId: z.string().optional(), + asset: z.string().optional(), + type: z.enum(['all', 'transaction', 'contract_event']).default('all'), +}); +const transactionParamsSchema = z.object({ + txHash: z.string().regex(/^[a-fA-F0-9]{64}$/, 'Transaction hash must contain 64 hexadecimal characters'), +}); +const validateTransaction = validateRequest({ params: transactionParamsSchema }); + /** * @route GET /api/audit * @desc List audit records with pagination * @query page, limit, sourceAccount */ -router.get('/', TransactionAuditController.listAuditRecords); +router.get('/', validateRequest({ query: listQuerySchema }), TransactionAuditController.listAuditRecords); /** * @route GET /api/audit/:txHash * @desc Get a stored audit record by transaction hash */ -router.get('/:txHash', TransactionAuditController.getAuditRecord); +router.get('/:txHash', validateTransaction, TransactionAuditController.getAuditRecord); /** * @route GET /api/audit/:txHash/verify * @desc Re-fetch from Horizon and verify integrity of stored record */ -router.get('/:txHash/verify', TransactionAuditController.verifyAuditRecord); +router.get('/:txHash/verify', validateTransaction, TransactionAuditController.verifyAuditRecord); /** * @route POST /api/audit/:txHash * @desc Fetch transaction from Horizon and create immutable audit record */ -router.post('/:txHash', TransactionAuditController.createAuditRecord); +router.post('/:txHash', validateTransaction, TransactionAuditController.createAuditRecord); export default router; From f417973c1a9ff3a1c10b8c19d44e82da0e6add87 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:05:57 -0400 Subject: [PATCH 12/40] Validate forecast windows and certificate queries before dispatch Add co-located query schemas to the four cash-flow routes and three certificate routes in the existing request-validation contribution. Keep the controllers' omitted-window defaults and raw string inputs, bound forecast and alert windows to 365 days and history to 24 months, and reject partial integer parses before calculations. Validate hashes and supplied certificate IDs before lookup while retaining generation's absent-ID auto-detection and verification's required IDs. Preserve authentication and tenant middleware ordering, controllers, services, database behavior and transaction execution. Six focused Express/Zod/middleware cases isolate these query boundaries from external services; runtime evidence is recorded separately. --- .../forecastCertificateValidation.test.ts | 104 ++++++++++++++++++ backend/src/routes/cashFlowForecastRoutes.ts | 25 ++++- backend/src/routes/certificateRoutes.ts | 27 ++++- 3 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/__tests__/forecastCertificateValidation.test.ts diff --git a/backend/src/routes/__tests__/forecastCertificateValidation.test.ts b/backend/src/routes/__tests__/forecastCertificateValidation.test.ts new file mode 100644 index 00000000..872e0807 --- /dev/null +++ b/backend/src/routes/__tests__/forecastCertificateValidation.test.ts @@ -0,0 +1,104 @@ +import request from 'supertest'; +import express, { Request, Response, NextFunction } from 'express'; +import cashFlowRoutes from '../cashFlowForecastRoutes.js'; +import certificateRoutes from '../certificateRoutes.js'; +import { CashFlowForecastController } from '../../controllers/cashFlowForecastController.js'; +import { PDFCertificateController } from '../../controllers/pdfCertificateController.js'; + +// Isolate route validation from authentication, databases and external services. +jest.mock('../../middlewares/auth.js', () => ({ + authenticateJWT: (_req: Request, _res: Response, next: NextFunction) => next(), +})); +jest.mock('../../middleware/tenantContext.js', () => ({ + syncTenantFromUser: (_req: Request, _res: Response, next: NextFunction) => next(), +})); +jest.mock('../../middleware/enhancedTenantIsolation.js', () => ({ + strictTenantBoundary: (_req: Request, _res: Response, next: NextFunction) => next(), + logTenantAccess: (_req: Request, _res: Response, next: NextFunction) => next(), +})); +jest.mock('../../controllers/cashFlowForecastController.js', () => { + const reply = (req: Request, res: Response) => res.json({ query: req.query }); + return { CashFlowForecastController: { + getForecast: jest.fn(reply), getHistorical: jest.fn(reply), + getProjections: jest.fn(reply), getAlerts: jest.fn(reply), + } }; +}); +jest.mock('../../controllers/pdfCertificateController.js', () => { + const reply = (req: Request, res: Response) => res.json({ query: req.query }); + return { PDFCertificateController: { + generateCertificate: jest.fn(reply), verifyCertificate: jest.fn(reply), getTransactionInfo: jest.fn(reply), + } }; +}); + +const app = express(); +app.use('/cash-flow', cashFlowRoutes); +app.use('/certificates', certificateRoutes); +const accounts = { distributionAccount: 'G'.repeat(56), assetIssuer: 'A'.repeat(56) }; +const transactionHash = 'aB01'.repeat(16); + +beforeEach(() => jest.clearAllMocks()); + +async function accepts(path: string, query: Record) { + const res = await request(app).get(path).query(query); + expect(res.status).toBe(200); + expect(res.body.query).toEqual(query); +} +async function rejects(path: string, query: Record, field: string) { + const res = await request(app).get(path).query(query); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(res.body.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ location: 'query', field: `query.${field}` }), + ])); +} + +describe('Forecast and certificate request validation', () => { + it('preserves omitted defaults and valid bounded windows without rewriting inputs', async () => { + await accepts('/cash-flow/historical', {}); + await accepts('/cash-flow/projections', {}); + await accepts('/cash-flow/historical', { monthsBack: '24' }); + await accepts('/cash-flow/projections', { forecastDays: '365' }); + await accepts('/cash-flow/forecast', accounts); + await accepts('/cash-flow/alerts', { ...accounts, forecastDays: '365' }); + }); + + it('rejects malformed and out-of-range windows before forecast controller dispatch', async () => { + await rejects('/cash-flow/historical', { monthsBack: 'no-number' }, 'monthsBack'); + await rejects('/cash-flow/projections', { forecastDays: '1e3' }, 'forecastDays'); + await rejects('/cash-flow/forecast', { ...accounts, forecastDays: '366' }, 'forecastDays'); + await rejects('/cash-flow/alerts', { ...accounts, forecastDays: '366' }, 'forecastDays'); + for (const handler of Object.values(CashFlowForecastController)) expect(handler).not.toHaveBeenCalled(); + }); + + it('requires account filters on forecast and alert routes', async () => { + await rejects('/cash-flow/forecast', {}, 'distributionAccount'); + await rejects('/cash-flow/alerts', { distributionAccount: accounts.distributionAccount }, 'assetIssuer'); + expect(CashFlowForecastController.getForecast).not.toHaveBeenCalled(); + expect(CashFlowForecastController.getAlerts).not.toHaveBeenCalled(); + }); + + it('preserves generation auto-detection and complete verification inputs', async () => { + await accepts('/certificates/generate', { transactionHash }); + await accepts('/certificates/generate', { transactionHash, employeeId: '1' }); + await accepts('/certificates/verify', { transactionHash, employeeId: '1', organizationId: '10' }); + await accepts('/certificates/transaction-info', { transactionHash }); + }); + + it('rejects malformed hashes before any certificate controller dispatch', async () => { + for (const route of ['generate', 'verify', 'transaction-info']) { + await rejects(`/certificates/${route}`, { + transactionHash: 'g'.repeat(64), employeeId: '1', organizationId: '10', + }, 'transactionHash'); + } + for (const handler of Object.values(PDFCertificateController)) expect(handler).not.toHaveBeenCalled(); + }); + + it('rejects supplied invalid IDs and the missing IDs required for verification', async () => { + await rejects('/certificates/generate', { transactionHash, employeeId: 'not-an-id' }, 'employeeId'); + await rejects('/certificates/generate', { transactionHash, organizationId: '0' }, 'organizationId'); + await rejects('/certificates/verify', { transactionHash, employeeId: '1' }, 'organizationId'); + await rejects('/certificates/verify', { transactionHash, employeeId: '1.5', organizationId: '10' }, 'employeeId'); + expect(PDFCertificateController.generateCertificate).not.toHaveBeenCalled(); + expect(PDFCertificateController.verifyCertificate).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/routes/cashFlowForecastRoutes.ts b/backend/src/routes/cashFlowForecastRoutes.ts index 7678ce3a..8ad289b2 100644 --- a/backend/src/routes/cashFlowForecastRoutes.ts +++ b/backend/src/routes/cashFlowForecastRoutes.ts @@ -1,11 +1,28 @@ +// Modified 2026-10-05: validate forecast filters before calculation dispatch. import { Router } from 'express'; +import { z } from 'zod'; import { CashFlowForecastController } from '../controllers/cashFlowForecastController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +// Controllers retain their defaults and parse the original query strings. +// Require decimal digits before coercion so partial parses and NaN cannot pass. +const boundedWindow = (maximum: number) => z + .string() + .regex(/^[0-9]+$/, 'Window must contain only decimal digits') + .pipe(z.coerce.number().int().min(1).max(maximum)) + .optional(); +const projectionQuerySchema = z.object({ forecastDays: boundedWindow(365) }); +const forecastQuerySchema = projectionQuerySchema.extend({ + distributionAccount: z.string().length(56, 'Distribution account must be 56 characters'), + assetIssuer: z.string().length(56, 'Asset issuer must be 56 characters'), +}); +const historicalQuerySchema = z.object({ monthsBack: boundedWindow(24) }); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -19,7 +36,7 @@ router.use(logTenantAccess); * @query assetIssuer - ORGUSD asset issuer public key (required) * @access Private (requires authentication) */ -router.get('/forecast', CashFlowForecastController.getForecast); +router.get('/forecast', validateRequest({ query: forecastQuerySchema }), CashFlowForecastController.getForecast); /** * @route GET /api/cash-flow/historical @@ -27,7 +44,7 @@ router.get('/forecast', CashFlowForecastController.getForecast); * @query monthsBack - Number of months to analyze (default: 6, max: 24) * @access Private (requires authentication) */ -router.get('/historical', CashFlowForecastController.getHistorical); +router.get('/historical', validateRequest({ query: historicalQuerySchema }), CashFlowForecastController.getHistorical); /** * @route GET /api/cash-flow/projections @@ -35,7 +52,7 @@ router.get('/historical', CashFlowForecastController.getHistorical); * @query forecastDays - Number of days to project (default: 90, max: 365) * @access Private (requires authentication) */ -router.get('/projections', CashFlowForecastController.getProjections); +router.get('/projections', validateRequest({ query: projectionQuerySchema }), CashFlowForecastController.getProjections); /** * @route GET /api/cash-flow/alerts @@ -45,6 +62,6 @@ router.get('/projections', CashFlowForecastController.getProjections); * @query assetIssuer - ORGUSD asset issuer public key (required) * @access Private (requires authentication) */ -router.get('/alerts', CashFlowForecastController.getAlerts); +router.get('/alerts', validateRequest({ query: forecastQuerySchema }), CashFlowForecastController.getAlerts); export default router; diff --git a/backend/src/routes/certificateRoutes.ts b/backend/src/routes/certificateRoutes.ts index 662a778e..aa2f4fc1 100644 --- a/backend/src/routes/certificateRoutes.ts +++ b/backend/src/routes/certificateRoutes.ts @@ -1,11 +1,32 @@ +// Modified 2026-10-05: validate certificate queries before transaction lookup. import { Router } from 'express'; +import { z } from 'zod'; import { PDFCertificateController } from '../controllers/pdfCertificateController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const transactionQuerySchema = z.object({ + transactionHash: z.string().regex(/^[a-fA-F0-9]{64}$/, 'Transaction hash must contain 64 hexadecimal characters'), +}); +const positiveId = z + .string() + .regex(/^[0-9]+$/, 'ID must contain only decimal digits') + .pipe(z.coerce.number().int().positive()); +// Generation can resolve absent IDs from the transaction. Supplied IDs must +// still be valid; verification requires both IDs and never auto-detects them. +const generateQuerySchema = transactionQuerySchema.extend({ + employeeId: positiveId.optional(), + organizationId: positiveId.optional(), +}); +const verifyQuerySchema = transactionQuerySchema.extend({ + employeeId: positiveId, + organizationId: positiveId, +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -15,18 +36,18 @@ router.use(logTenantAccess); * Generate PDF certificate for a payment transaction * GET /api/certificates/generate?employeeId=1&transactionHash=xxx&organizationId=1 */ -router.get('/generate', PDFCertificateController.generateCertificate); +router.get('/generate', validateRequest({ query: generateQuerySchema }), PDFCertificateController.generateCertificate); /** * Verify a certificate by transaction hash * GET /api/certificates/verify?transactionHash=xxx&employeeId=1&organizationId=1 */ -router.get('/verify', PDFCertificateController.verifyCertificate); +router.get('/verify', validateRequest({ query: verifyQuerySchema }), PDFCertificateController.verifyCertificate); /** * Get employee and organization info from transaction hash * GET /api/certificates/transaction-info?transactionHash=xxx */ -router.get('/transaction-info', PDFCertificateController.getTransactionInfo); +router.get('/transaction-info', validateRequest({ query: transactionQuerySchema }), PDFCertificateController.getTransactionInfo); export default router; From 27e305f67bd5e93b56bc6f822d6d6e2b878603e9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 15:16:26 -0400 Subject: [PATCH 13/40] ci: remove unsupported root retention settings Repair GitHub Actions workflow validation for fleet branches. Preserve job definitions, event filters, permissions, artifact retention inputs, and all application files. --- .github/workflows/build.yml | 1 - .github/workflows/contract-release.yml | 1 - .github/workflows/dapp-ipfs.yml | 1 - .github/workflows/secrets-check.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52029992..9d71b0a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,4 +115,3 @@ jobs: run: npm test --if-present # Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 2655fcbc..3f66b1a5 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -29,4 +29,3 @@ jobs: release_token: ${{ secrets.GITHUB_TOKEN }} # Workflow run retention settings -retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 29d16e55..42248532 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -67,4 +67,3 @@ jobs: echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" # Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index b8d3b7e8..c7a34807 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -22,4 +22,3 @@ jobs: run: ./scripts/check-k8s-secrets.sh # Workflow run retention settings -retention-days: 30 \ No newline at end of file From 4fd02733b37ca0a5b384ce4f75c4bae043c579ac Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:38:49 -0400 Subject: [PATCH 14/40] Validate balance preflight request --- backend/src/routes/balanceRoutes.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/balanceRoutes.ts b/backend/src/routes/balanceRoutes.ts index b8234852..d09df601 100644 --- a/backend/src/routes/balanceRoutes.ts +++ b/backend/src/routes/balanceRoutes.ts @@ -1,11 +1,24 @@ import { Router } from 'express'; +import { z } from 'zod'; import { BalanceController } from '../controllers/balanceController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const preflightBodySchema = z.object({ + distributionAccount: z.string().length(56), + assetIssuer: z.string().length(56), + payments: z.array(z.object({ + employeeId: z.string().min(1), + employeeName: z.string().min(1), + walletAddress: z.string().length(56), + amount: z.string().min(1), + })).min(1), +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -25,6 +38,6 @@ router.get('/:accountId', BalanceController.checkBalance); * is insufficient to cover all scheduled payments. * @body { distributionAccount, assetIssuer, payments[] } */ -router.post('/preflight', BalanceController.preflightPayroll); +router.post('/preflight', validateRequest({ body: preflightBodySchema }), BalanceController.preflightPayroll); export default router; From 6def0e8da4d44996c5cb1645ac22c9318e88a792 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:39:05 -0400 Subject: [PATCH 15/40] Validate forecast settings request --- backend/src/routes/forecastRoutes.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/forecastRoutes.ts b/backend/src/routes/forecastRoutes.ts index 3ba68e66..41022004 100644 --- a/backend/src/routes/forecastRoutes.ts +++ b/backend/src/routes/forecastRoutes.ts @@ -1,16 +1,32 @@ import { Router } from 'express'; +import { z } from 'zod'; import { ForecastController } from '../controllers/forecastController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { isolateOrganization, authorizeRoles } from '../middlewares/rbac.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const liquiditySettingsBodySchema = z.object({ + distributionAccount: z.string().length(56), + assetIssuer: z.string().length(56), + assetCode: z.string().min(1).max(12).optional(), + benefitsRatePct: z.unknown().optional(), + yellowBufferPct: z.unknown().optional(), + alertEmails: z.unknown().optional(), +}); + router.use(authenticateJWT); router.use(isolateOrganization); router.get('/', authorizeRoles('EMPLOYER'), ForecastController.getForecast); router.get('/settings', authorizeRoles('EMPLOYER'), ForecastController.getLiquiditySettings); -router.put('/settings', authorizeRoles('EMPLOYER'), ForecastController.updateLiquiditySettings); +router.put( + '/settings', + authorizeRoles('EMPLOYER'), + validateRequest({ body: liquiditySettingsBodySchema }), + ForecastController.updateLiquiditySettings +); export default router; From f81cd39151671a6db2f21e51af52185315b6246b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:39:48 -0400 Subject: [PATCH 16/40] Validate admin mutation requests --- backend/src/routes/adminRoutes.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/adminRoutes.ts b/backend/src/routes/adminRoutes.ts index 86fb14c0..24d4dd29 100644 --- a/backend/src/routes/adminRoutes.ts +++ b/backend/src/routes/adminRoutes.ts @@ -1,13 +1,34 @@ import { Router, Request, Response } from 'express'; +import { z } from 'zod'; import { auditIntegrityService } from '../services/auditIntegrityService.js'; import { TenantRateLimitService } from '../services/tenantRateLimitService.js'; import { pool } from '../config/database.js'; import { requireAdminJustification } from '../middleware/requireAdminJustification.js'; import { auditSensitiveOperation } from '../middleware/auditLogger.js'; import logger from '../utils/logger.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const orgIdParamsSchema = z.object({ + orgId: z.string().regex(/^[1-9][0-9]*$/, 'orgId must be a positive integer'), +}); +const rateLimitTierSchema = z.object({ + windowMs: z.number().int().positive(), + maxRequests: z.number().int().positive(), +}); +const rateLimitOverridesBodySchema = z.object({ + auth: rateLimitTierSchema.optional(), + api: rateLimitTierSchema.optional(), + data: rateLimitTierSchema.optional(), + strict: rateLimitTierSchema.optional(), +}); +const quotaBodySchema = z.object({ + maxEmployees: z.number().int().positive().optional(), + maxMonthlyTransactions: z.number().int().positive().optional(), + maxStorageMb: z.number().int().positive().optional(), +}); + // --------------------------------------------------------------------------- // Audit integrity // --------------------------------------------------------------------------- @@ -70,7 +91,7 @@ router.get('/tenants/:orgId/rate-limits', requireAdminJustification, async (req: * Body: { "api": { "windowMs": 60000, "maxRequests": 500 }, ... } * Only the tiers provided in the body are updated; omitted tiers keep their current values. */ -router.patch('/tenants/:orgId/rate-limits', requireAdminJustification, async (req: Request, res: Response) => { +router.patch('/tenants/:orgId/rate-limits', requireAdminJustification, validateRequest({ params: orgIdParamsSchema, body: rateLimitOverridesBodySchema }), async (req: Request, res: Response) => { const orgId = parseInt(req.params.orgId, 10); if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid orgId' }); @@ -176,7 +197,7 @@ router.get('/tenants/:orgId/quotas', requireAdminJustification, async (req: Requ * * Body: { "maxEmployees": 1000, "maxMonthlyTransactions": 50000 } */ -router.patch('/tenants/:orgId/quotas', requireAdminJustification, async (req: Request, res: Response) => { +router.patch('/tenants/:orgId/quotas', requireAdminJustification, validateRequest({ params: orgIdParamsSchema, body: quotaBodySchema }), async (req: Request, res: Response) => { const orgId = parseInt(req.params.orgId, 10); if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid orgId' }); return; } From f303452b755a1bf7572345aa3690aede98982874 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:39:58 -0400 Subject: [PATCH 17/40] Validate audit metric requests --- backend/src/routes/auditAnalyticsRoutes.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/auditAnalyticsRoutes.ts b/backend/src/routes/auditAnalyticsRoutes.ts index 340f91f0..c08baa26 100644 --- a/backend/src/routes/auditAnalyticsRoutes.ts +++ b/backend/src/routes/auditAnalyticsRoutes.ts @@ -1,11 +1,21 @@ import express from 'express'; +import { z } from 'zod'; import { auditAnalyticsService } from '../services/auditAnalyticsService.js'; import authenticateJWT from '../middlewares/auth.js'; import { pool } from '../config/database.js'; import logger from '../utils/logger.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = express.Router(); +const recordMetricBodySchema = z.object({ + metricType: z.string().min(1), + metricValue: z.number(), + dimension: z.string().min(1).optional(), + dimensionValue: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + /** * GET /api/audit-analytics/summary/:organizationId * Get audit summary for an organization @@ -167,7 +177,7 @@ router.get('/errors/:organizationId', authenticateJWT, async (req, res) => { * POST /api/audit-analytics/record * Record a custom analytics metric */ -router.post('/record', authenticateJWT, async (req, res) => { +router.post('/record', authenticateJWT, validateRequest({ body: recordMetricBodySchema }), async (req, res) => { try { const { metricType, metricValue, dimension, dimensionValue, metadata } = req.body; From a47b3eaf86c8f1df64f742c42345f770ecdef948 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:40:21 -0400 Subject: [PATCH 18/40] Validate smart rate limit requests --- backend/src/routes/smartRateLimitRoutes.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/smartRateLimitRoutes.ts b/backend/src/routes/smartRateLimitRoutes.ts index cfae550e..fc7dcc43 100644 --- a/backend/src/routes/smartRateLimitRoutes.ts +++ b/backend/src/routes/smartRateLimitRoutes.ts @@ -1,11 +1,22 @@ import express from 'express'; +import { z } from 'zod'; import { smartRateLimitService } from '../services/smartRateLimitService.js'; import authenticateJWT from '../middlewares/auth.js'; import { pool } from '../config/database.js'; import logger from '../utils/logger.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = express.Router(); +const orgParamsSchema = z.object({ + organizationId: z.string().regex(/^[1-9][0-9]*$/), +}); +const scoreBodySchema = z.object({ scoreDelta: z.number() }); +const restrictBodySchema = z.object({ + reason: z.string().min(1), + durationMinutes: z.number().int().positive().optional(), +}); + /** * GET /api/smart-rate-limit/status/:organizationId * Get rate limit status for an organization @@ -112,7 +123,7 @@ router.get('/violations/:organizationId', authenticateJWT, async (req, res) => { * POST /api/smart-rate-limit/update-score/:organizationId * Update behavior score for an organization */ -router.post('/update-score/:organizationId', authenticateJWT, async (req, res) => { +router.post('/update-score/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema, body: scoreBodySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const { scoreDelta } = req.body; @@ -148,7 +159,7 @@ router.post('/update-score/:organizationId', authenticateJWT, async (req, res) = * POST /api/smart-rate-limit/restrict/:organizationId * Manually restrict an organization */ -router.post('/restrict/:organizationId', authenticateJWT, async (req, res) => { +router.post('/restrict/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema, body: restrictBodySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const { reason, durationMinutes } = req.body; @@ -203,7 +214,7 @@ router.post('/restrict/:organizationId', authenticateJWT, async (req, res) => { * POST /api/smart-rate-limit/unrestrict/:organizationId * Remove restriction from an organization */ -router.post('/unrestrict/:organizationId', authenticateJWT, async (req, res) => { +router.post('/unrestrict/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); From 97406a020c45fb2ddbca69cf4387707553a1a04e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:40:31 -0400 Subject: [PATCH 19/40] Validate tenant security mutations --- backend/src/routes/tenantSecurityRoutes.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/tenantSecurityRoutes.ts b/backend/src/routes/tenantSecurityRoutes.ts index 2b723491..2cf20b13 100644 --- a/backend/src/routes/tenantSecurityRoutes.ts +++ b/backend/src/routes/tenantSecurityRoutes.ts @@ -1,11 +1,23 @@ import express from 'express'; +import { z } from 'zod'; import { tenantSecurityService } from '../services/tenantSecurityService.js'; import authenticateJWT from '../middlewares/auth.js'; import { pool } from '../config/database.js'; import logger from '../utils/logger.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = express.Router(); +const eventParamsSchema = z.object({ + eventId: z.string().regex(/^[1-9][0-9]*$/, 'eventId must be a positive integer'), +}); +const organizationParamsSchema = z.object({ + organizationId: z.string().regex(/^[1-9][0-9]*$/, 'organizationId must be a positive integer'), +}); +const resolutionBodySchema = z.object({ + resolutionNotes: z.string().min(1), +}); + /** * GET /api/tenant-security/summary/:organizationId * Get security summary for an organization @@ -75,7 +87,7 @@ router.get('/events/:organizationId', authenticateJWT, async (req, res) => { * POST /api/tenant-security/events/:eventId/resolve * Resolve a security event */ -router.post('/events/:eventId/resolve', authenticateJWT, async (req, res) => { +router.post('/events/:eventId/resolve', authenticateJWT, validateRequest({ params: eventParamsSchema, body: resolutionBodySchema }), async (req, res) => { try { const eventId = parseInt(req.params.eventId, 10); const { resolutionNotes } = req.body; @@ -114,7 +126,7 @@ router.post('/events/:eventId/resolve', authenticateJWT, async (req, res) => { * POST /api/tenant-security/detect-anomalies/:organizationId * Manually trigger anomaly detection */ -router.post('/detect-anomalies/:organizationId', authenticateJWT, async (req, res) => { +router.post('/detect-anomalies/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); From 4c0f021a78923ee25e20319ba3ba54ca74ef45c8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:40:44 -0400 Subject: [PATCH 20/40] Validate trustline mutation requests --- backend/src/routes/trustlineRoutes.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/trustlineRoutes.ts b/backend/src/routes/trustlineRoutes.ts index ea393765..6ab4b43a 100644 --- a/backend/src/routes/trustlineRoutes.ts +++ b/backend/src/routes/trustlineRoutes.ts @@ -1,11 +1,25 @@ import { Router } from 'express'; +import { z } from 'zod'; import { TrustlineController } from '../controllers/trustlineController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const employeeParamsSchema = z.object({ + employeeId: z.string().regex(/^[1-9][0-9]*$/, 'employeeId must be a positive integer'), +}); +const issuerBodySchema = z.object({ + assetIssuer: z.string().length(56), +}); +const promptBodySchema = z.object({ + employeeId: z.number().int().positive(), + walletAddress: z.string().length(56), + assetIssuer: z.string().length(56), +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -28,13 +42,17 @@ router.get('/employees/:employeeId', TrustlineController.getEmployeeStatus); * @desc Re-check Horizon and update trustline status in DB * @body { assetIssuer: string } */ -router.post('/employees/:employeeId/refresh', TrustlineController.refreshEmployee); +router.post( + '/employees/:employeeId/refresh', + validateRequest({ params: employeeParamsSchema, body: issuerBodySchema }), + TrustlineController.refreshEmployee +); /** * @route POST /api/trustlines/prompt * @desc Build unsigned changeTrust XDR for employee to sign * @body { employeeId: number, walletAddress: string, assetIssuer: string } */ -router.post('/prompt', TrustlineController.promptTrustline); +router.post('/prompt', validateRequest({ body: promptBodySchema }), TrustlineController.promptTrustline); export default router; From 9b806b33f2d42e7e431879a842ec829f6e519d7c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:41:02 -0400 Subject: [PATCH 21/40] Validate webhook mutation requests --- backend/src/routes/webhook.routes.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/webhook.routes.ts b/backend/src/routes/webhook.routes.ts index c4eabf25..058f5845 100644 --- a/backend/src/routes/webhook.routes.ts +++ b/backend/src/routes/webhook.routes.ts @@ -1,17 +1,29 @@ import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; import { WebhookController } from '../controllers/webhook.controller.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { syncTenantFromUser } from '../middleware/tenantContext.js'; import { strictTenantBoundary, logTenantAccess } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const subscribeBodySchema = z.object({ + url: z.string().url(), + secret: z.string().min(16), + events: z.array(z.string()).optional(), +}); +const triggerBodySchema = z.object({ + event: z.string().min(1).optional(), + payload: z.unknown().optional(), +}); + router.use(authenticateJWT); router.use(syncTenantFromUser); router.use(strictTenantBoundary); router.use(logTenantAccess); -router.post('/subscribe', WebhookController.subscribe); +router.post('/subscribe', validateRequest({ body: subscribeBodySchema }), WebhookController.subscribe); router.get('/subscriptions', WebhookController.listSubscriptions); router.delete('/subscriptions/:id', WebhookController.deleteSubscription); @@ -23,6 +35,6 @@ const requireNonProduction = (req: Request, res: Response, next: NextFunction) = next(); }; -router.post('/test-trigger', requireNonProduction, WebhookController.triggerMockEvent); +router.post('/test-trigger', requireNonProduction, validateRequest({ body: triggerBodySchema }), WebhookController.triggerMockEvent); export default router; From 948bca0de482724b914c1025b905fb2e33e9e4c9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:41:16 -0400 Subject: [PATCH 22/40] Validate contract upgrade mutations --- backend/src/routes/contractUpgradeRoutes.ts | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/src/routes/contractUpgradeRoutes.ts b/backend/src/routes/contractUpgradeRoutes.ts index 61369074..843148f7 100644 --- a/backend/src/routes/contractUpgradeRoutes.ts +++ b/backend/src/routes/contractUpgradeRoutes.ts @@ -1,8 +1,27 @@ import { Router } from 'express'; +import { z } from 'zod'; import { ContractUpgradeController } from '../controllers/contractUpgradeController.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const registryParamsSchema = z.object({ + registryId: z.string().regex(/^[1-9][0-9]*$/, 'registryId must be a positive integer'), +}); +const upgradeLogParamsSchema = z.object({ + logId: z.string().regex(/^[1-9][0-9]*$/, 'logId must be a positive integer'), +}); +const wasmHashSchema = z.string().length(64).regex(/^[0-9a-fA-F]{64}$/); +const validateHashBodySchema = z.object({ newWasmHash: wasmHashSchema }); +const simulateBodySchema = z.object({ + newWasmHash: wasmHashSchema, + initiatedBy: z.string().min(56).max(64), + notes: z.string().max(1000).optional(), +}); +const executeBodySchema = z.object({ + adminSecret: z.string().min(56), +}); + // --------------------------------------------------------------------------- // Contract registry — list & detail // --------------------------------------------------------------------------- @@ -24,6 +43,7 @@ router.get('/:registryId', (req, res) => void ContractUpgradeController.getContr */ router.post( '/:registryId/validate-hash', + validateRequest({ params: registryParamsSchema, body: validateHashBodySchema }), (req, res) => void ContractUpgradeController.validateHash(req, res) ); @@ -34,6 +54,7 @@ router.post( */ router.post( '/:registryId/simulate-upgrade', + validateRequest({ params: registryParamsSchema, body: simulateBodySchema }), (req, res) => void ContractUpgradeController.simulateUpgrade(req, res) ); @@ -59,6 +80,7 @@ router.get( */ router.post( '/upgrade-logs/:logId/execute', + validateRequest({ params: upgradeLogParamsSchema, body: executeBodySchema }), (req, res) => void ContractUpgradeController.executeUpgrade(req, res) ); @@ -77,6 +99,7 @@ router.get( */ router.post( '/upgrade-logs/:logId/cancel', + validateRequest({ params: upgradeLogParamsSchema }), (req, res) => void ContractUpgradeController.cancelUpgrade(req, res) ); From 15c5ddf9494d48d208374b591cf8b5a62bb69453 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:41:32 -0400 Subject: [PATCH 23/40] Validate payment mutation requests --- backend/src/routes/paymentRoutes.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/src/routes/paymentRoutes.ts b/backend/src/routes/paymentRoutes.ts index 0b1577b9..16a65a21 100644 --- a/backend/src/routes/paymentRoutes.ts +++ b/backend/src/routes/paymentRoutes.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { z } from 'zod'; import { PaymentController } from '../controllers/paymentController.js'; import { require2FA } from '../middlewares/require2fa.js'; import { authenticateJWT } from '../middlewares/auth.js'; @@ -9,9 +10,25 @@ import { validateActiveTenant, logTenantAccess, } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const objectPayloadSchema = z.unknown().refine( + (value) => value !== null && typeof value === 'object', + 'payload must be an object' +); +const sep31BodySchema = z.object({ + domain: z.string().min(1), + paymentData: objectPayloadSchema, + secretKey: z.string().min(1), +}); +const sep24BodySchema = z.object({ + domain: z.string().min(1), + secretKey: z.string().min(1), + withdrawalData: objectPayloadSchema, +}); + router.use(authenticateJWT); router.use(strictTenantBoundary); router.use(validateActiveTenant); @@ -22,6 +39,7 @@ router.post( '/sep31/initiate', isolateOrganization, require2FA, + validateRequest({ body: sep31BodySchema }), idempotencyMiddleware(), PaymentController.initiateSEP31 ); @@ -32,6 +50,7 @@ router.post( '/sep24/withdraw', isolateOrganization, require2FA, + validateRequest({ body: sep24BodySchema }), idempotencyMiddleware(), PaymentController.initiateSEP24Withdrawal ); From 65f7f640beb12fa3647a7432f192fbd291293ae2 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:41:55 -0400 Subject: [PATCH 24/40] Validate payroll bonus mutation requests --- backend/src/routes/payrollBonusRoutes.ts | 39 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/payrollBonusRoutes.ts b/backend/src/routes/payrollBonusRoutes.ts index 8e3063e1..ce6194e1 100644 --- a/backend/src/routes/payrollBonusRoutes.ts +++ b/backend/src/routes/payrollBonusRoutes.ts @@ -1,21 +1,54 @@ import { Router } from 'express'; +import { z } from 'zod'; import { PayrollBonusController } from '../controllers/payrollBonusController.js'; import { authenticateJWT } from '../middlewares/auth.js'; import { isolateOrganization } from '../middlewares/rbac.js'; import { idempotencyMiddleware } from '../middleware/idempotencyMiddleware.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const positiveId = z.number().int().positive(); +const routeIdParamsSchema = z.object({ + id: z.string().regex(/^[1-9][0-9]*$/, 'id must be a positive integer'), +}); +const payrollRunBodySchema = z.object({ + organizationId: positiveId, + periodStart: z.string().refine((value) => !Number.isNaN(Date.parse(value)), 'periodStart must be a valid date'), + periodEnd: z.string().refine((value) => !Number.isNaN(Date.parse(value)), 'periodEnd must be a valid date'), + assetCode: z.string().min(1).max(12).optional(), +}); +const amountSchema = z.string().refine((value) => { + const amount = Number(value); + return Number.isFinite(amount) && amount > 0; +}, 'amount must be a positive number'); +const bonusItemSchema = z.object({ + employeeId: positiveId, + amount: amountSchema, + description: z.string().optional(), +}); +const bonusBodySchema = bonusItemSchema.extend({ + payrollRunId: positiveId, +}); +const batchBonusBodySchema = z.object({ + payrollRunId: positiveId, + items: z.array(bonusItemSchema).min(1), +}); +const statusBodySchema = z.object({ + status: z.enum(['draft', 'pending', 'processing', 'completed', 'failed']), +}); + router.use(authenticateJWT); router.use(isolateOrganization); -router.post('/runs', idempotencyMiddleware(), PayrollBonusController.createPayrollRun); +router.post('/runs', validateRequest({ body: payrollRunBodySchema }), idempotencyMiddleware(), PayrollBonusController.createPayrollRun); router.get('/runs', PayrollBonusController.listPayrollRuns); router.get('/runs/:id', PayrollBonusController.getPayrollRun); -router.patch('/runs/:id/status', PayrollBonusController.updatePayrollRunStatus); -router.post('/items/bonus', idempotencyMiddleware(), PayrollBonusController.addBonusItem); +router.patch('/runs/:id/status', validateRequest({ params: routeIdParamsSchema, body: statusBodySchema }), PayrollBonusController.updatePayrollRunStatus); +router.post('/items/bonus', validateRequest({ body: bonusBodySchema }), idempotencyMiddleware(), PayrollBonusController.addBonusItem); router.post( '/items/bonus/batch', + validateRequest({ body: batchBonusBodySchema }), idempotencyMiddleware(), PayrollBonusController.addBatchBonusItems ); From 8e538573513e6aa92839a8d8872aeb49733ac54b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 21:42:17 -0400 Subject: [PATCH 25/40] Validate payroll cache mutation request --- backend/src/routes/payroll.routes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 47ee768f..28dfe3ac 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -1,4 +1,5 @@ import { Request, Response, Router } from 'express'; +import { z } from 'zod'; import { payrollQueryService } from '../services/payroll-query.service.js'; import logger from '../utils/logger.js'; import { authenticateJWT } from '../middlewares/auth.js'; @@ -8,9 +9,12 @@ import { validateActiveTenant, logTenantAccess, } from '../middleware/enhancedTenantIsolation.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const emptyBodySchema = z.object({}).strict().optional(); + function asString(value: unknown): string | undefined { if (typeof value === 'string') return value; if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; @@ -390,7 +394,7 @@ router.get('/status/health', async (req: Request, res: Response) => { * Clear cache (admin endpoint) * POST /api/payroll/cache/clear */ -router.post('/cache/clear', (req: Request, res: Response) => { +router.post('/cache/clear', validateRequest({ body: emptyBodySchema }), (req: Request, res: Response) => { try { payrollQueryService.clearCache(); From 5309c5e46b341ce35981a56ea06ec6dfe17a4827 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:16:53 -0400 Subject: [PATCH 26/40] fix(backend): validate throttling config updates --- backend/src/routes/throttlingRoutes.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/throttlingRoutes.ts b/backend/src/routes/throttlingRoutes.ts index 34e0a13b..00a9f421 100644 --- a/backend/src/routes/throttlingRoutes.ts +++ b/backend/src/routes/throttlingRoutes.ts @@ -1,11 +1,22 @@ import { Router } from 'express'; +import { z } from 'zod'; import { ThrottlingController } from '../controllers/throttlingController.js'; +import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); +const configBodySchema = z.object({ + tpm: z.number().positive().optional(), + maxQueueSize: z.number().positive().optional(), + refillIntervalMs: z.number().positive().optional(), +}).refine( + body => Object.values(body).some(value => value !== undefined), + { message: 'At least one configuration field is required' }, +); + router.get('/status', ThrottlingController.getStatus); router.get('/config', ThrottlingController.getConfig); -router.put('/config', ThrottlingController.updateConfig); +router.put('/config', validateRequest({ body: configBodySchema }), ThrottlingController.updateConfig); router.delete('/queue', ThrottlingController.clearQueue); router.get('/metrics', ThrottlingController.getMetrics); From e13cc03298549b9bc0ba06fdda6f41c1d4b6e201 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:37:05 -0400 Subject: [PATCH 27/40] fix(validation): validate admin query parameters --- backend/src/routes/adminRoutes.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/adminRoutes.ts b/backend/src/routes/adminRoutes.ts index 24d4dd29..1436101b 100644 --- a/backend/src/routes/adminRoutes.ts +++ b/backend/src/routes/adminRoutes.ts @@ -28,6 +28,22 @@ const quotaBodySchema = z.object({ maxMonthlyTransactions: z.number().int().positive().optional(), maxStorageMb: z.number().int().positive().optional(), }); +const positiveIntegerQuerySchema = z.string().regex(/^[1-9][0-9]*$/, 'must be a positive integer'); +const parseableDateQuerySchema = z.string().refine( + (value) => Number.isFinite(Date.parse(value)), + 'must be a valid date', +); +const auditIntegrityQuerySchema = z.object({ + limit: positiveIntegerQuerySchema.optional(), +}); +const accessLogsQuerySchema = z.object({ + organizationId: positiveIntegerQuerySchema.optional(), + adminUserId: z.string().min(1).optional(), + from: parseableDateQuerySchema.optional(), + to: parseableDateQuerySchema.optional(), + page: positiveIntegerQuerySchema.optional(), + limit: positiveIntegerQuerySchema.optional(), +}); // --------------------------------------------------------------------------- // Audit integrity @@ -45,6 +61,7 @@ router.get( '/audit/integrity', requireAdminJustification, auditSensitiveOperation('audit_integrity_check'), + validateRequest({ query: auditIntegrityQuerySchema }), async (req: Request, res: Response) => { try { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; @@ -129,7 +146,7 @@ router.patch('/tenants/:orgId/rate-limits', requireAdminJustification, validateR * to — ISO date upper bound * page, limit */ -router.get('/access-logs', requireAdminJustification, async (req: Request, res: Response) => { +router.get('/access-logs', requireAdminJustification, validateRequest({ query: accessLogsQuerySchema }), async (req: Request, res: Response) => { const { organizationId, adminUserId, from, to, page = '1', limit = '50' } = req.query; const conditions: string[] = []; From c35f6d59a0044b441d3b3a846e709d5a772b387b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:37:08 -0400 Subject: [PATCH 28/40] fix(validation): validate audit analytics query parameters --- backend/src/routes/auditAnalyticsRoutes.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/auditAnalyticsRoutes.ts b/backend/src/routes/auditAnalyticsRoutes.ts index c08baa26..f158a7c4 100644 --- a/backend/src/routes/auditAnalyticsRoutes.ts +++ b/backend/src/routes/auditAnalyticsRoutes.ts @@ -15,12 +15,26 @@ const recordMetricBodySchema = z.object({ dimensionValue: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), }); +const parseableDateQuerySchema = z.string().refine( + (value) => Number.isFinite(Date.parse(value)), + 'must be a valid date', +); +const dateRangeQuerySchema = z.object({ + startDate: parseableDateQuerySchema.optional(), + endDate: parseableDateQuerySchema.optional(), +}); +const trendsQuerySchema = dateRangeQuerySchema.extend({ + interval: z.enum(['hour', 'day', 'week']).optional(), +}); +const limitQuerySchema = z.object({ + limit: z.string().regex(/^[1-9][0-9]*$/, 'limit must be a positive integer').optional(), +}); /** * GET /api/audit-analytics/summary/:organizationId * Get audit summary for an organization */ -router.get('/summary/:organizationId', authenticateJWT, async (req, res) => { +router.get('/summary/:organizationId', authenticateJWT, validateRequest({ query: dateRangeQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -52,7 +66,7 @@ router.get('/summary/:organizationId', authenticateJWT, async (req, res) => { * GET /api/audit-analytics/trends/:organizationId * Get audit trends over time */ -router.get('/trends/:organizationId', authenticateJWT, async (req, res) => { +router.get('/trends/:organizationId', authenticateJWT, validateRequest({ query: trendsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -86,7 +100,7 @@ router.get('/trends/:organizationId', authenticateJWT, async (req, res) => { * GET /api/audit-analytics/endpoints/:organizationId * Get top endpoints by usage */ -router.get('/endpoints/:organizationId', authenticateJWT, async (req, res) => { +router.get('/endpoints/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 10; @@ -130,7 +144,7 @@ router.get('/endpoints/:organizationId', authenticateJWT, async (req, res) => { * GET /api/audit-analytics/errors/:organizationId * Get recent errors */ -router.get('/errors/:organizationId', authenticateJWT, async (req, res) => { +router.get('/errors/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; From f3734deefecf9c0d3bfa4b9b7c94a8d59aa268ed Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:37:11 -0400 Subject: [PATCH 29/40] fix(validation): validate payroll query parameters --- backend/src/routes/payroll.routes.ts | 63 ++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 28dfe3ac..afd56236 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -14,6 +14,55 @@ import { validateRequest } from '../middleware/validateRequest.js'; const router = Router(); const emptyBodySchema = z.object({}).strict().optional(); +const nonEmptyQueryStringSchema = z.string().min(1); +const positiveIntegerQuerySchema = z.string().regex(/^[1-9][0-9]*$/, 'must be a positive integer'); +const parseableDateQuerySchema = z.string().refine( + (value) => Number.isFinite(Date.parse(value)), + 'must be a valid date', +); +const paginationQueryFields = { + page: positiveIntegerQuerySchema.optional(), + limit: positiveIntegerQuerySchema.optional(), +}; +const dateRangeQueryFields = { + startDate: parseableDateQuerySchema.optional(), + endDate: parseableDateQuerySchema.optional(), +}; +const transactionsQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + employeeId: nonEmptyQueryStringSchema.optional(), + batchId: nonEmptyQueryStringSchema.optional(), + assetCode: nonEmptyQueryStringSchema.optional(), + assetIssuer: nonEmptyQueryStringSchema.optional(), + ...dateRangeQueryFields, + ...paginationQueryFields, + sortBy: z.enum(['timestamp', 'amount', 'employeeId']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}); +const employeePayrollQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + ...dateRangeQueryFields, + ...paginationQueryFields, +}); +const dateRangeQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + ...dateRangeQueryFields, +}); +const batchQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + ...paginationQueryFields, +}); +const aggregationQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + ...dateRangeQueryFields, + assetCode: nonEmptyQueryStringSchema.optional(), + assetIssuer: nonEmptyQueryStringSchema.optional(), +}); +const memoSearchQuerySchema = z.object({ + orgPublicKey: nonEmptyQueryStringSchema, + pattern: nonEmptyQueryStringSchema, + ...paginationQueryFields, +}); function asString(value: unknown): string | undefined { if (typeof value === 'string') return value; @@ -48,7 +97,7 @@ router.use(isolateOrganization); * - sortBy: Sort field (timestamp, amount, employeeId) * - sortOrder: Sort order (asc, desc) */ -router.get('/transactions', async (req: Request, res: Response) => { +router.get('/transactions', validateRequest({ query: transactionsQuerySchema }), async (req: Request, res: Response) => { try { const { orgPublicKey, @@ -104,7 +153,7 @@ router.get('/transactions', async (req: Request, res: Response) => { * Get payroll for a specific employee * GET /api/payroll/employees/:employeeId */ -router.get('/employees/:employeeId', async (req: Request, res: Response) => { +router.get('/employees/:employeeId', validateRequest({ query: employeePayrollQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate, page, limit } = req.query; @@ -142,7 +191,7 @@ router.get('/employees/:employeeId', async (req: Request, res: Response) => { * Get employee payroll summary * GET /api/payroll/employees/:employeeId/summary */ -router.get('/employees/:employeeId/summary', async (req: Request, res: Response) => { +router.get('/employees/:employeeId/summary', validateRequest({ query: dateRangeQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate } = req.query; @@ -178,7 +227,7 @@ router.get('/employees/:employeeId/summary', async (req: Request, res: Response) * Get payroll batch details * GET /api/payroll/batches/:batchId */ -router.get('/batches/:batchId', async (req: Request, res: Response) => { +router.get('/batches/:batchId', validateRequest({ query: batchQuerySchema }), async (req: Request, res: Response) => { try { const { batchId } = req.params; const { orgPublicKey, page, limit } = req.query; @@ -214,7 +263,7 @@ router.get('/batches/:batchId', async (req: Request, res: Response) => { * Get payroll aggregation statistics * GET /api/payroll/aggregation */ -router.get('/aggregation', async (req: Request, res: Response) => { +router.get('/aggregation', validateRequest({ query: aggregationQuerySchema }), async (req: Request, res: Response) => { try { const { orgPublicKey, startDate, endDate, assetCode, assetIssuer } = req.query; @@ -250,7 +299,7 @@ router.get('/aggregation', async (req: Request, res: Response) => { * Get organization-wide audit report * GET /api/payroll/audit */ -router.get('/audit', async (req: Request, res: Response) => { +router.get('/audit', validateRequest({ query: dateRangeQuerySchema }), async (req: Request, res: Response) => { try { const { orgPublicKey, startDate, endDate } = req.query; @@ -284,7 +333,7 @@ router.get('/audit', async (req: Request, res: Response) => { * Search transactions by memo pattern * GET /api/payroll/search/memo */ -router.get('/search/memo', async (req: Request, res: Response) => { +router.get('/search/memo', validateRequest({ query: memoSearchQuerySchema }), async (req: Request, res: Response) => { try { const { orgPublicKey, pattern, page, limit } = req.query; From 3fa418ba4e4af799f3ffc2e77cc02d0a60996ac7 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:37:13 -0400 Subject: [PATCH 30/40] fix(validation): validate rate-limit query parameters --- backend/src/routes/smartRateLimitRoutes.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/smartRateLimitRoutes.ts b/backend/src/routes/smartRateLimitRoutes.ts index fc7dcc43..f79d61dd 100644 --- a/backend/src/routes/smartRateLimitRoutes.ts +++ b/backend/src/routes/smartRateLimitRoutes.ts @@ -16,6 +16,9 @@ const restrictBodySchema = z.object({ reason: z.string().min(1), durationMinutes: z.number().int().positive().optional(), }); +const limitQuerySchema = z.object({ + limit: z.string().regex(/^[1-9][0-9]*$/, 'limit must be a positive integer').optional(), +}); /** * GET /api/smart-rate-limit/status/:organizationId @@ -55,7 +58,7 @@ router.get('/status/:organizationId', authenticateJWT, async (req, res) => { * GET /api/smart-rate-limit/history/:organizationId * Get rate limit recovery history */ -router.get('/history/:organizationId', authenticateJWT, async (req, res) => { +router.get('/history/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 10; @@ -79,7 +82,7 @@ router.get('/history/:organizationId', authenticateJWT, async (req, res) => { * GET /api/smart-rate-limit/violations/:organizationId * Get rate limit violations */ -router.get('/violations/:organizationId', authenticateJWT, async (req, res) => { +router.get('/violations/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; From 49b6686643a43ecd9b004c4813aae866ed16142a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:37:16 -0400 Subject: [PATCH 31/40] fix(validation): validate tenant-security query parameters --- backend/src/routes/tenantSecurityRoutes.ts | 24 +++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/tenantSecurityRoutes.ts b/backend/src/routes/tenantSecurityRoutes.ts index 2cf20b13..a53a936e 100644 --- a/backend/src/routes/tenantSecurityRoutes.ts +++ b/backend/src/routes/tenantSecurityRoutes.ts @@ -17,6 +17,24 @@ const organizationParamsSchema = z.object({ const resolutionBodySchema = z.object({ resolutionNotes: z.string().min(1), }); +const positiveIntegerQuerySchema = z.string().regex(/^[1-9][0-9]*$/, 'must be a positive integer'); +const nonNegativeIntegerQuerySchema = z.string().regex(/^(?:0|[1-9][0-9]*)$/, 'must be a non-negative integer'); +const parseableDateQuerySchema = z.string().refine( + (value) => Number.isFinite(Date.parse(value)), + 'must be a valid date', +); +const eventsQuerySchema = z.object({ + eventType: z.string().min(1).optional(), + severity: z.string().min(1).optional(), + isResolved: z.enum(['true', 'false']).optional(), + startDate: parseableDateQuerySchema.optional(), + endDate: parseableDateQuerySchema.optional(), + limit: positiveIntegerQuerySchema.optional(), + offset: nonNegativeIntegerQuerySchema.optional(), +}); +const limitQuerySchema = z.object({ + limit: positiveIntegerQuerySchema.optional(), +}); /** * GET /api/tenant-security/summary/:organizationId @@ -45,7 +63,7 @@ router.get('/summary/:organizationId', authenticateJWT, async (req, res) => { * GET /api/tenant-security/events/:organizationId * Get security events with filtering */ -router.get('/events/:organizationId', authenticateJWT, async (req, res) => { +router.get('/events/:organizationId', authenticateJWT, validateRequest({ query: eventsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const { @@ -150,7 +168,7 @@ router.post('/detect-anomalies/:organizationId', authenticateJWT, validateReques * GET /api/tenant-security/anomalies/:organizationId * Get detected anomalies for an organization */ -router.get('/anomalies/:organizationId', authenticateJWT, async (req, res) => { +router.get('/anomalies/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; @@ -220,7 +238,7 @@ router.get('/organizations', authenticateJWT, async (req, res) => { * GET /api/tenant-security/access-logs/:organizationId * Get recent access logs for an organization */ -router.get('/access-logs/:organizationId', authenticateJWT, async (req, res) => { +router.get('/access-logs/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 50; From c789aabee8b21f74bb2cb1c56414630df0b4d26d Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:41:03 -0400 Subject: [PATCH 32/40] fix(validation): validate admin path parameters --- backend/src/routes/adminRoutes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/adminRoutes.ts b/backend/src/routes/adminRoutes.ts index 1436101b..74908dce 100644 --- a/backend/src/routes/adminRoutes.ts +++ b/backend/src/routes/adminRoutes.ts @@ -85,7 +85,7 @@ router.get( * GET /api/admin/tenants/:orgId/rate-limits * Return the current effective rate limit overrides for an organisation. */ -router.get('/tenants/:orgId/rate-limits', requireAdminJustification, async (req: Request, res: Response) => { +router.get('/tenants/:orgId/rate-limits', requireAdminJustification, validateRequest({ params: orgIdParamsSchema }), async (req: Request, res: Response) => { const orgId = parseInt(req.params.orgId, 10); if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid orgId' }); @@ -192,7 +192,7 @@ router.get('/access-logs', requireAdminJustification, validateRequest({ query: a * GET /api/admin/tenants/:orgId/quotas * Return quota config and current usage for an organisation. */ -router.get('/tenants/:orgId/quotas', requireAdminJustification, async (req: Request, res: Response) => { +router.get('/tenants/:orgId/quotas', requireAdminJustification, validateRequest({ params: orgIdParamsSchema }), async (req: Request, res: Response) => { const orgId = parseInt(req.params.orgId, 10); if (isNaN(orgId)) { res.status(400).json({ error: 'Invalid orgId' }); return; } From 4c0ddbcb451e80da3fb0794b721b8c24e93262cc Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:41:34 -0400 Subject: [PATCH 33/40] fix(validation): validate payroll path parameters --- backend/src/routes/payroll.routes.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index afd56236..edd54e7c 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -63,6 +63,9 @@ const memoSearchQuerySchema = z.object({ pattern: nonEmptyQueryStringSchema, ...paginationQueryFields, }); +const employeeIdParamsSchema = z.object({ employeeId: z.string().min(1) }); +const batchIdParamsSchema = z.object({ batchId: z.string().min(1) }); +const txHashParamsSchema = z.object({ txHash: z.string().min(1) }); function asString(value: unknown): string | undefined { if (typeof value === 'string') return value; @@ -153,7 +156,7 @@ router.get('/transactions', validateRequest({ query: transactionsQuerySchema }), * Get payroll for a specific employee * GET /api/payroll/employees/:employeeId */ -router.get('/employees/:employeeId', validateRequest({ query: employeePayrollQuerySchema }), async (req: Request, res: Response) => { +router.get('/employees/:employeeId', validateRequest({ params: employeeIdParamsSchema, query: employeePayrollQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate, page, limit } = req.query; @@ -191,7 +194,7 @@ router.get('/employees/:employeeId', validateRequest({ query: employeePayrollQue * Get employee payroll summary * GET /api/payroll/employees/:employeeId/summary */ -router.get('/employees/:employeeId/summary', validateRequest({ query: dateRangeQuerySchema }), async (req: Request, res: Response) => { +router.get('/employees/:employeeId/summary', validateRequest({ params: employeeIdParamsSchema, query: dateRangeQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate } = req.query; @@ -227,7 +230,7 @@ router.get('/employees/:employeeId/summary', validateRequest({ query: dateRangeQ * Get payroll batch details * GET /api/payroll/batches/:batchId */ -router.get('/batches/:batchId', validateRequest({ query: batchQuerySchema }), async (req: Request, res: Response) => { +router.get('/batches/:batchId', validateRequest({ params: batchIdParamsSchema, query: batchQuerySchema }), async (req: Request, res: Response) => { try { const { batchId } = req.params; const { orgPublicKey, page, limit } = req.query; @@ -369,7 +372,7 @@ router.get('/search/memo', validateRequest({ query: memoSearchQuerySchema }), as * Get transaction details by hash * GET /api/payroll/transactions/:txHash */ -router.get('/transactions/:txHash', async (req: Request, res: Response) => { +router.get('/transactions/:txHash', validateRequest({ params: txHashParamsSchema }), async (req: Request, res: Response) => { try { const { txHash } = req.params; From 8417a175a2c4c07a485f506b202581a7452bc049 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:41:42 -0400 Subject: [PATCH 34/40] fix(validation): validate rate-limit path parameters --- backend/src/routes/smartRateLimitRoutes.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/smartRateLimitRoutes.ts b/backend/src/routes/smartRateLimitRoutes.ts index f79d61dd..d9c9e8fe 100644 --- a/backend/src/routes/smartRateLimitRoutes.ts +++ b/backend/src/routes/smartRateLimitRoutes.ts @@ -24,7 +24,7 @@ const limitQuerySchema = z.object({ * GET /api/smart-rate-limit/status/:organizationId * Get rate limit status for an organization */ -router.get('/status/:organizationId', authenticateJWT, async (req, res) => { +router.get('/status/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); @@ -58,7 +58,7 @@ router.get('/status/:organizationId', authenticateJWT, async (req, res) => { * GET /api/smart-rate-limit/history/:organizationId * Get rate limit recovery history */ -router.get('/history/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/history/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 10; @@ -82,7 +82,7 @@ router.get('/history/:organizationId', authenticateJWT, validateRequest({ query: * GET /api/smart-rate-limit/violations/:organizationId * Get rate limit violations */ -router.get('/violations/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/violations/:organizationId', authenticateJWT, validateRequest({ params: orgParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; From d737af40b2a522d949ce9c6cff75314e561f2e0e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:41:51 -0400 Subject: [PATCH 35/40] fix(validation): validate tenant-security path parameters --- backend/src/routes/tenantSecurityRoutes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/tenantSecurityRoutes.ts b/backend/src/routes/tenantSecurityRoutes.ts index a53a936e..ad49996b 100644 --- a/backend/src/routes/tenantSecurityRoutes.ts +++ b/backend/src/routes/tenantSecurityRoutes.ts @@ -40,7 +40,7 @@ const limitQuerySchema = z.object({ * GET /api/tenant-security/summary/:organizationId * Get security summary for an organization */ -router.get('/summary/:organizationId', authenticateJWT, async (req, res) => { +router.get('/summary/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); @@ -63,7 +63,7 @@ router.get('/summary/:organizationId', authenticateJWT, async (req, res) => { * GET /api/tenant-security/events/:organizationId * Get security events with filtering */ -router.get('/events/:organizationId', authenticateJWT, validateRequest({ query: eventsQuerySchema }), async (req, res) => { +router.get('/events/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: eventsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const { @@ -168,7 +168,7 @@ router.post('/detect-anomalies/:organizationId', authenticateJWT, validateReques * GET /api/tenant-security/anomalies/:organizationId * Get detected anomalies for an organization */ -router.get('/anomalies/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/anomalies/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; @@ -238,7 +238,7 @@ router.get('/organizations', authenticateJWT, async (req, res) => { * GET /api/tenant-security/access-logs/:organizationId * Get recent access logs for an organization */ -router.get('/access-logs/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/access-logs/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 50; From dfce242490f71df2d256d35799873dee52b7fa8b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:43:46 -0400 Subject: [PATCH 36/40] fix(api): validate audit analytics organization ids --- backend/src/routes/auditAnalyticsRoutes.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/auditAnalyticsRoutes.ts b/backend/src/routes/auditAnalyticsRoutes.ts index f158a7c4..049594a9 100644 --- a/backend/src/routes/auditAnalyticsRoutes.ts +++ b/backend/src/routes/auditAnalyticsRoutes.ts @@ -15,6 +15,9 @@ const recordMetricBodySchema = z.object({ dimensionValue: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), }); +const organizationParamsSchema = z.object({ + organizationId: z.string().regex(/^[1-9][0-9]*$/, 'organizationId must be a positive integer'), +}); const parseableDateQuerySchema = z.string().refine( (value) => Number.isFinite(Date.parse(value)), 'must be a valid date', @@ -34,7 +37,7 @@ const limitQuerySchema = z.object({ * GET /api/audit-analytics/summary/:organizationId * Get audit summary for an organization */ -router.get('/summary/:organizationId', authenticateJWT, validateRequest({ query: dateRangeQuerySchema }), async (req, res) => { +router.get('/summary/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: dateRangeQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -66,7 +69,7 @@ router.get('/summary/:organizationId', authenticateJWT, validateRequest({ query: * GET /api/audit-analytics/trends/:organizationId * Get audit trends over time */ -router.get('/trends/:organizationId', authenticateJWT, validateRequest({ query: trendsQuerySchema }), async (req, res) => { +router.get('/trends/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: trendsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -100,7 +103,7 @@ router.get('/trends/:organizationId', authenticateJWT, validateRequest({ query: * GET /api/audit-analytics/endpoints/:organizationId * Get top endpoints by usage */ -router.get('/endpoints/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/endpoints/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 10; @@ -144,7 +147,7 @@ router.get('/endpoints/:organizationId', authenticateJWT, validateRequest({ quer * GET /api/audit-analytics/errors/:organizationId * Get recent errors */ -router.get('/errors/:organizationId', authenticateJWT, validateRequest({ query: limitQuerySchema }), async (req, res) => { +router.get('/errors/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema, query: limitQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const limit = parseInt(req.query.limit as string, 10) || 20; @@ -233,7 +236,7 @@ router.post('/record', authenticateJWT, validateRequest({ body: recordMetricBody * DELETE /api/audit-analytics/cache/:organizationId * Clear audit cache for an organization */ -router.delete('/cache/:organizationId', authenticateJWT, async (req, res) => { +router.delete('/cache/:organizationId', authenticateJWT, validateRequest({ params: organizationParamsSchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); From f9b48fe0e0c8dc68ceabc56d2ff223d97a42c1ac Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 01:46:51 -0400 Subject: [PATCH 37/40] chore: restore PR-scoped workflow baseline --- .github/workflows/contract-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 3f66b1a5..2655fcbc 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -29,3 +29,4 @@ jobs: release_token: ${{ secrets.GITHUB_TOKEN }} # Workflow run retention settings +retention-days: 90 \ No newline at end of file From c57c0495b7cf61364f70e4e1295af608a5d77718 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 01:46:55 -0400 Subject: [PATCH 38/40] chore: restore PR-scoped workflow baseline --- .github/workflows/dapp-ipfs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 42248532..29d16e55 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -67,3 +67,4 @@ jobs: echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" # Workflow run retention settings +retention-days: 30 \ No newline at end of file From 3406a67c8707d6aabd3d01f0638aad39c0d08bf6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 01:46:58 -0400 Subject: [PATCH 39/40] chore: restore PR-scoped workflow baseline --- .github/workflows/secrets-check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index c7a34807..b8d3b7e8 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -22,3 +22,4 @@ jobs: run: ./scripts/check-k8s-secrets.sh # Workflow run retention settings +retention-days: 30 \ No newline at end of file From ecfd847543b530eb59679762231fe2a5cc604f4f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 01:47:23 -0400 Subject: [PATCH 40/40] chore: restore PR-scoped workflow baseline --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d71b0a9..52029992 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,3 +115,4 @@ jobs: run: npm test --if-present # Workflow run retention settings +retention-days: 30 \ No newline at end of file