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(); + }; +} 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/__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/adminRoutes.ts b/backend/src/routes/adminRoutes.ts index 86fb14c0..74908dce 100644 --- a/backend/src/routes/adminRoutes.ts +++ b/backend/src/routes/adminRoutes.ts @@ -1,13 +1,50 @@ 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(), +}); +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 // --------------------------------------------------------------------------- @@ -24,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; @@ -47,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' }); @@ -70,7 +108,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' }); @@ -108,7 +146,7 @@ router.patch('/tenants/:orgId/rate-limits', requireAdminJustification, async (re * 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[] = []; @@ -154,7 +192,7 @@ router.get('/access-logs', requireAdminJustification, async (req: Request, res: * 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; } @@ -176,7 +214,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; } diff --git a/backend/src/routes/assetRoutes.ts b/backend/src/routes/assetRoutes.ts index 60fe522b..f5d23e7a 100644 --- a/backend/src/routes/assetRoutes.ts +++ b/backend/src/routes/assetRoutes.ts @@ -1,4 +1,6 @@ +// Modified 2026-10-05: validate decimal pagination before numeric coercion. 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 +10,36 @@ 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 + .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); router.use(syncTenantFromUser); router.use(strictTenantBoundary); @@ -18,9 +47,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; - diff --git a/backend/src/routes/auditAnalyticsRoutes.ts b/backend/src/routes/auditAnalyticsRoutes.ts index 340f91f0..049594a9 100644 --- a/backend/src/routes/auditAnalyticsRoutes.ts +++ b/backend/src/routes/auditAnalyticsRoutes.ts @@ -1,16 +1,43 @@ 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(), +}); +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', +); +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({ params: organizationParamsSchema, query: dateRangeQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -42,7 +69,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({ params: organizationParamsSchema, query: trendsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const startDate = req.query.startDate @@ -76,7 +103,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({ 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; @@ -120,7 +147,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({ 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; @@ -167,7 +194,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; @@ -209,7 +236,7 @@ router.post('/record', authenticateJWT, async (req, res) => { * 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); 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; 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 ); 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; 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 ); 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; 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) ); diff --git a/backend/src/routes/employeeRoutes.ts b/backend/src/routes/employeeRoutes.ts index 60ed9f8a..d7934fe1 100644 --- a/backend/src/routes/employeeRoutes.ts +++ b/backend/src/routes/employeeRoutes.ts @@ -1,4 +1,6 @@ +// 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'; import authenticateJWT from '../middlewares/auth.js'; import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; @@ -10,6 +12,25 @@ 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 + .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(), + 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 +59,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) ); 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; 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; 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; 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 ); diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 47ee768f..edd54e7c 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,64 @@ import { validateActiveTenant, logTenantAccess, } from '../middleware/enhancedTenantIsolation.js'; +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, +}); +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; if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; @@ -44,7 +100,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, @@ -100,7 +156,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({ params: employeeIdParamsSchema, query: employeePayrollQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate, page, limit } = req.query; @@ -138,7 +194,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({ params: employeeIdParamsSchema, query: dateRangeQuerySchema }), async (req: Request, res: Response) => { try { const { employeeId } = req.params; const { orgPublicKey, startDate, endDate } = req.query; @@ -174,7 +230,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({ params: batchIdParamsSchema, query: batchQuerySchema }), async (req: Request, res: Response) => { try { const { batchId } = req.params; const { orgPublicKey, page, limit } = req.query; @@ -210,7 +266,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; @@ -246,7 +302,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; @@ -280,7 +336,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; @@ -316,7 +372,7 @@ router.get('/search/memo', async (req: Request, res: Response) => { * 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; @@ -390,7 +446,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(); 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 ); diff --git a/backend/src/routes/scheduleRoutes.ts b/backend/src/routes/scheduleRoutes.ts index 410ed2b4..19a0efb1 100644 --- a/backend/src/routes/scheduleRoutes.ts +++ b/backend/src/routes/scheduleRoutes.ts @@ -1,49 +1,44 @@ +// 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'; 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 + .string() + .regex(/^[0-9]+$/, 'ID must contain only decimal digits') + .pipe(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; diff --git a/backend/src/routes/smartRateLimitRoutes.ts b/backend/src/routes/smartRateLimitRoutes.ts index cfae550e..d9c9e8fe 100644 --- a/backend/src/routes/smartRateLimitRoutes.ts +++ b/backend/src/routes/smartRateLimitRoutes.ts @@ -1,16 +1,30 @@ 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(), +}); +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 * 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); @@ -44,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({ 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; @@ -68,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({ 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; @@ -112,7 +126,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 +162,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 +217,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); 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; diff --git a/backend/src/routes/tenantSecurityRoutes.ts b/backend/src/routes/tenantSecurityRoutes.ts index 2b723491..ad49996b 100644 --- a/backend/src/routes/tenantSecurityRoutes.ts +++ b/backend/src/routes/tenantSecurityRoutes.ts @@ -1,16 +1,46 @@ 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), +}); +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 * 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); @@ -33,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({ params: organizationParamsSchema, query: eventsQuerySchema }), async (req, res) => { try { const organizationId = parseInt(req.params.organizationId, 10); const { @@ -75,7 +105,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 +144,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); @@ -138,7 +168,7 @@ router.post('/detect-anomalies/:organizationId', authenticateJWT, async (req, re * 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({ 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; @@ -208,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({ 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; 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); 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; 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;