Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b5a0e6c
feat(api): add Zod request validation middleware
woahwhattheheck Oct 5, 2026
638dddc
feat(api): validate auth request bodies
woahwhattheheck Oct 5, 2026
a46d392
feat(api): validate employee route inputs
woahwhattheheck Oct 5, 2026
08b0095
feat(api): validate schedule route inputs
woahwhattheheck Oct 5, 2026
9a9e6ea
feat(api): validate benefits route inputs
woahwhattheheck Oct 5, 2026
316133c
feat(api): validate tax route inputs
woahwhattheheck Oct 5, 2026
c3b51bd
feat(api): validate asset route inputs
woahwhattheheck Oct 5, 2026
4785ae6
feat(api): validate freeze route inputs
woahwhattheheck Oct 5, 2026
cb728b9
feat(api): validate multi-sig route inputs
woahwhattheheck Oct 5, 2026
39c0df2
Align numeric request validation with controller parsing
woahwhattheheck Oct 5, 2026
9b575de
Validate audit route inputs with shared field-level errors
woahwhattheheck Oct 5, 2026
f417973
Validate forecast windows and certificate queries before dispatch
woahwhattheheck Oct 5, 2026
27e305f
ci: remove unsupported root retention settings
woahwhattheheck Oct 5, 2026
4fd0273
Validate balance preflight request
woahwhattheheck Oct 6, 2026
6def0e8
Validate forecast settings request
woahwhattheheck Oct 6, 2026
f81cd39
Validate admin mutation requests
woahwhattheheck Oct 6, 2026
f303452
Validate audit metric requests
woahwhattheheck Oct 6, 2026
a47b3ea
Validate smart rate limit requests
woahwhattheheck Oct 6, 2026
97406a0
Validate tenant security mutations
woahwhattheheck Oct 6, 2026
4c0f021
Validate trustline mutation requests
woahwhattheheck Oct 6, 2026
9b806b3
Validate webhook mutation requests
woahwhattheheck Oct 6, 2026
948bca0
Validate contract upgrade mutations
woahwhattheheck Oct 6, 2026
15c5ddf
Validate payment mutation requests
woahwhattheheck Oct 6, 2026
65f7f64
Validate payroll bonus mutation requests
woahwhattheheck Oct 6, 2026
8e53857
Validate payroll cache mutation request
woahwhattheheck Oct 6, 2026
5309c5e
fix(backend): validate throttling config updates
woahwhattheheck Oct 6, 2026
e13cc03
fix(validation): validate admin query parameters
woahwhattheheck Oct 6, 2026
c35f6d5
fix(validation): validate audit analytics query parameters
woahwhattheheck Oct 6, 2026
f3734de
fix(validation): validate payroll query parameters
woahwhattheheck Oct 6, 2026
3fa418b
fix(validation): validate rate-limit query parameters
woahwhattheheck Oct 6, 2026
49b6686
fix(validation): validate tenant-security query parameters
woahwhattheheck Oct 6, 2026
c789aab
fix(validation): validate admin path parameters
woahwhattheheck Oct 6, 2026
4c0ddbc
fix(validation): validate payroll path parameters
woahwhattheheck Oct 6, 2026
8417a17
fix(validation): validate rate-limit path parameters
woahwhattheheck Oct 6, 2026
d737af4
fix(validation): validate tenant-security path parameters
woahwhattheheck Oct 6, 2026
dfce242
fix(api): validate audit analytics organization ids
woahwhattheheck Oct 6, 2026
f9b48fe
chore: restore PR-scoped workflow baseline
woahwhattheheck Oct 6, 2026
c57c049
chore: restore PR-scoped workflow baseline
woahwhattheheck Oct 6, 2026
3406a67
chore: restore PR-scoped workflow baseline
woahwhattheheck Oct 6, 2026
ecfd847
chore: restore PR-scoped workflow baseline
woahwhattheheck Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/src/middleware/validateRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { z } from 'zod';

export interface RequestValidationSchemas {
body?: z.ZodType<unknown>;
query?: z.ZodType<unknown>;
params?: z.ZodType<unknown>;
}

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();
};
}
73 changes: 73 additions & 0 deletions backend/src/routes/__tests__/auditValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
104 changes: 104 additions & 0 deletions backend/src/routes/__tests__/forecastCertificateValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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<string, string>, 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();
});
});
48 changes: 43 additions & 5 deletions backend/src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
@@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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;
Expand All @@ -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' });
Expand All @@ -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' });
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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; }

Expand All @@ -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; }

Expand Down
36 changes: 32 additions & 4 deletions backend/src/routes/assetRoutes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,19 +10,45 @@ 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);
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;

Loading