From 7c244c47db8760072a1f24b1b50cb3ce79af0a34 Mon Sep 17 00:00:00 2001 From: Marvellous Akinyemi Date: Sat, 27 Jun 2026 20:50:13 +0100 Subject: [PATCH 1/3] Add risk analytics endpoint and corresponding service logic - Implemented GET /api/orgs/:orgId/analytics/risk endpoint to retrieve risk metrics for organizations. - Added logic to calculate slash rate and capital at risk based on vault data. - Introduced new OrgRiskAnalyticsVault and OrgRiskAnalyticsResponse interfaces for structured data handling. - Created unit tests to validate the new endpoint functionality and ensure accurate metric calculations. --- docs/analytics-metrics.md | 7 + src/app-bootstrap.ts | 1 + src/routes/orgAnalytics.ts | 20 +++ src/services/analytics.service.ts | 130 ++++++++++++++++- src/tests/orgAnalytics.risk.test.ts | 208 ++++++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/tests/orgAnalytics.risk.test.ts diff --git a/docs/analytics-metrics.md b/docs/analytics-metrics.md index c674f0d3..9b0a2c35 100644 --- a/docs/analytics-metrics.md +++ b/docs/analytics-metrics.md @@ -14,6 +14,13 @@ This document describes the calculations and assumptions used by the analytics e - Query: userId (required), windowDays=30, baseScorePerSuccess=5, penaltyPerFailure=2, streakBonusPerDay=1 - Response: per-user metrics and a behaviorScore +- GET /api/orgs/:orgId/analytics/risk + - Query: startDate, endDate (ISO 8601, inclusive, UTC) + - Response: slash-rate and capital-at-risk for the requested org and window + - slashRate: resolved vaults that ended in slash_on_miss divided by all resolved vaults in the range + - capitalAtRisk: sum of the net-staked amount for active vaults in the range + - resolvedVaults, slashedVaults, activeVaults, totalVaults: supporting counts + ## Data Model In-memory milestone events: diff --git a/src/app-bootstrap.ts b/src/app-bootstrap.ts index 6cfe4589..70bc2aa8 100644 --- a/src/app-bootstrap.ts +++ b/src/app-bootstrap.ts @@ -66,6 +66,7 @@ export function bootstrapApp(options: BootstrapOptions = {}) { app.use('/api/privacy', privacyRouter) app.use('/api/organizations', orgVaultsRouter) app.use('/api/organizations', orgAnalyticsRouter) + app.use('/api/orgs', orgAnalyticsRouter) app.use('/api/organizations', orgMembersRouter) app.use('/api/orgs', orgMembersRouter) app.use('/api/organizations/:orgId/graphql', graphqlRouter) diff --git a/src/routes/orgAnalytics.ts b/src/routes/orgAnalytics.ts index b97a8321..8289d93b 100644 --- a/src/routes/orgAnalytics.ts +++ b/src/routes/orgAnalytics.ts @@ -2,10 +2,30 @@ import { orgAnalyticsRateLimiter } from '../middleware/rateLimiter.js' import { Router, Request, Response } from 'express' import { authenticate } from '../middleware/auth.js' import { requireOrgAccess } from '../middleware/orgAuth.js' +import { getOrgRiskAnalytics } from '../services/analytics.service.js' import { vaults, Vault } from './vaults.js' export const orgAnalyticsRouter = Router() +orgAnalyticsRouter.get( + '/:orgId/analytics/risk', + authenticate, + requireOrgAccess('owner', 'admin'), + orgAnalyticsRateLimiter, + async (req: Request, res: Response) => { + const { orgId } = req.params + const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined + const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined + + try { + const result = getOrgRiskAnalytics(orgId, vaults, { startDate, endDate }) + res.json(result) + } catch (error: any) { + res.status(400).json({ error: error.message }) + } + } +) + orgAnalyticsRouter.get( '/:orgId/analytics', authenticate, diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index eb6d5bf5..56252a9c 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -7,9 +7,137 @@ import { getTimeRangeFilter } from '../db/database.js' import type { VaultAnalytics, VaultAnalyticsWithPeriod } from '../types/vault.js' -import { utcNow } from '../utils/timestamps.js' +import { parseAndNormalizeToUTC, utcNow } from '../utils/timestamps.js' import { getOrSet, invalidate } from '../lib/cache.js' +export interface OrgRiskAnalyticsVault { + id?: string + orgId?: string + amount?: string | number | null + status?: string | null + createdAt?: string | null + startTimestamp?: string | null + endTimestamp?: string | null + stakedAmount?: string | number | null + netStakedAmount?: string | number | null + resolution?: string | null + finalStatus?: string | null + outcome?: string | null + result?: string | null + terminationReason?: string | null + statusReason?: string | null + [key: string]: unknown +} + +export interface OrgRiskAnalyticsResponse { + orgId: string + generatedAt: string + range: { + startDate: string + endDate: string + } + analytics: { + totalVaults: number + activeVaults: number + resolvedVaults: number + slashedVaults: number + slashRate: number + capitalAtRisk: string + } +} + +function normalizeOrgRiskRange(startDate?: string, endDate?: string): { startDate: string; endDate: string } { + const normalizedStart = startDate ? parseAndNormalizeToUTC(startDate) : new Date(0).toISOString() + const normalizedEnd = endDate ? parseAndNormalizeToUTC(endDate) : utcNow() + + if (new Date(normalizedStart).getTime() > new Date(normalizedEnd).getTime()) { + throw new Error('startDate must be before or equal to endDate') + } + + return { startDate: normalizedStart, endDate: normalizedEnd } +} + +function readNumericAmount(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 + } + return 0 +} + +function getVaultAmount(vault: OrgRiskAnalyticsVault): number { + const candidates = [ + vault.stakedAmount, + vault.netStakedAmount, + vault.amount, + ] + + for (const candidate of candidates) { + const value = readNumericAmount(candidate) + if (value > 0) return value + } + + return 0 +} + +function isInRange(vault: OrgRiskAnalyticsVault, startDate: string, endDate: string): boolean { + const anchor = vault.createdAt ?? vault.startTimestamp ?? vault.endTimestamp + if (!anchor) return true + + const normalizedAnchor = parseAndNormalizeToUTC(anchor) + return normalizedAnchor >= startDate && normalizedAnchor <= endDate +} + +function isSlashOutcome(vault: OrgRiskAnalyticsVault): boolean { + const candidates = [ + vault.resolution, + vault.finalStatus, + vault.outcome, + vault.result, + vault.terminationReason, + vault.statusReason, + ] + + for (const candidate of candidates) { + const normalized = String(candidate ?? '').trim().toLowerCase() + if (normalized === 'slash_on_miss' || normalized === 'slashed' || normalized === 'slash') { + return true + } + } + + return vault.status === 'failed' +} + +export function getOrgRiskAnalytics( + orgId: string, + vaults: OrgRiskAnalyticsVault[], + options: { startDate?: string; endDate?: string } = {}, +): OrgRiskAnalyticsResponse { + const { startDate, endDate } = normalizeOrgRiskRange(options.startDate, options.endDate) + const scopedVaults = vaults.filter((vault) => vault.orgId === orgId && isInRange(vault, startDate, endDate)) + + const activeVaults = scopedVaults.filter((vault) => vault.status === 'active') + const resolvedVaults = scopedVaults.filter((vault) => vault.status === 'completed' || vault.status === 'failed') + const slashedVaults = resolvedVaults.filter((vault) => isSlashOutcome(vault)) + const capitalAtRisk = activeVaults.reduce((sum, vault) => sum + getVaultAmount(vault), 0) + const slashRate = resolvedVaults.length > 0 ? slashedVaults.length / resolvedVaults.length : 0 + + return { + orgId, + generatedAt: utcNow(), + range: { startDate, endDate }, + analytics: { + totalVaults: scopedVaults.length, + activeVaults: activeVaults.length, + resolvedVaults: resolvedVaults.length, + slashedVaults: slashedVaults.length, + slashRate, + capitalAtRisk: capitalAtRisk.toString(), + }, + } +} + export async function getOverallAnalytics(): Promise { return getOrSet('analytics:overall', 300, async () => { const summary = await readAnalyticsSummary() diff --git a/src/tests/orgAnalytics.risk.test.ts b/src/tests/orgAnalytics.risk.test.ts new file mode 100644 index 00000000..1fa2f5ff --- /dev/null +++ b/src/tests/orgAnalytics.risk.test.ts @@ -0,0 +1,208 @@ +import express from 'express' +import request from 'supertest' +import jwt from 'jsonwebtoken' +import { beforeEach, afterEach, describe, expect, it } from '@jest/globals' +import { orgAnalyticsRouter } from '../routes/orgAnalytics.js' +import { setVaults } from '../routes/vaults.js' +import { setOrganizations, setOrgMembers } from '../models/organizations.js' +import { UserRole } from '../types/user.js' + +const ORG_ID = 'org-1' +const OTHER_ORG_ID = 'org-2' + +const app = express() +app.use(express.json()) +app.use('/api/orgs', orgAnalyticsRouter) + +function authHeader(userId: string) { + const token = jwt.sign({ userId, role: UserRole.ADMIN }, process.env.JWT_SECRET ?? 'change-me-in-production') + return `Bearer ${token}` +} + +function seedVaults(vaults: Array) { + setOrganizations([ + { id: ORG_ID, name: 'Test Org', createdAt: '2025-01-01T00:00:00Z' }, + { id: OTHER_ORG_ID, name: 'Other Org', createdAt: '2025-01-01T00:00:00Z' }, + ]) + + setOrgMembers([ + { orgId: ORG_ID, userId: 'alice', role: 'owner' }, + { orgId: OTHER_ORG_ID, userId: 'alice', role: 'owner' }, + ]) + + setVaults(vaults) +} + +describe('GET /api/orgs/:orgId/analytics/risk', () => { + beforeEach(() => { + setVaults([]) + setOrganizations([]) + setOrgMembers([]) + }) + + afterEach(() => { + setVaults([]) + setOrganizations([]) + setOrgMembers([]) + }) + + it('returns zeroed metrics when there are no vaults in the org', async () => { + seedVaults([]) + + const res = await request(app) + .get(`/api/orgs/${ORG_ID}/analytics/risk`) + .set('Authorization', authHeader('alice')) + + expect(res.status).toBe(200) + expect(res.body.analytics).toEqual({ + activeVaults: 0, + capitalAtRisk: '0', + resolvedVaults: 0, + slashRate: 0, + slashedVaults: 0, + totalVaults: 0, + }) + }) + + it('excludes in-flight vaults from the slash-rate denominator and uses net staked amounts', async () => { + seedVaults([ + { + id: 'v1', + creator: 'alice', + amount: '1000', + status: 'active', + startTimestamp: '2025-01-01T00:00:00Z', + endTimestamp: '2025-02-01T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-01-01T00:00:00Z', + stakedAmount: '500', + orgId: ORG_ID, + }, + { + id: 'v2', + creator: 'alice', + amount: '4000', + status: 'completed', + startTimestamp: '2025-01-01T00:00:00Z', + endTimestamp: '2025-02-01T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-01-01T00:00:00Z', + stakedAmount: '4000', + orgId: ORG_ID, + }, + { + id: 'v3', + creator: 'alice', + amount: '2000', + status: 'failed', + startTimestamp: '2025-01-01T00:00:00Z', + endTimestamp: '2025-02-01T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-01-02T00:00:00Z', + stakedAmount: '2000', + orgId: ORG_ID, + resolution: 'slash_on_miss', + }, + { + id: 'v4', + creator: 'alice', + amount: '100', + status: 'active', + startTimestamp: '2025-02-01T00:00:00Z', + endTimestamp: '2025-03-01T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-02-01T00:00:00Z', + stakedAmount: '100', + orgId: ORG_ID, + }, + { + id: 'v5', + creator: 'alice', + amount: '6000', + status: 'failed', + startTimestamp: '2025-01-01T00:00:00Z', + endTimestamp: '2025-02-01T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-01-03T00:00:00Z', + stakedAmount: '6000', + orgId: OTHER_ORG_ID, + }, + ]) + + const res = await request(app) + .get(`/api/orgs/${ORG_ID}/analytics/risk`) + .set('Authorization', authHeader('alice')) + + expect(res.status).toBe(200) + expect(res.body.analytics).toMatchObject({ + activeVaults: 2, + capitalAtRisk: '600', + resolvedVaults: 2, + slashedVaults: 1, + slashRate: 0.5, + totalVaults: 4, + }) + }) + + it('treats the date range boundaries as inclusive UTC bounds', async () => { + const start = '2025-02-01T00:00:00.000Z' + const end = '2025-02-01T23:59:59.999Z' + + seedVaults([ + { + id: 'v1', + creator: 'alice', + amount: '1000', + status: 'completed', + startTimestamp: '2025-02-01T00:00:00Z', + endTimestamp: '2025-02-02T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: start, + orgId: ORG_ID, + }, + { + id: 'v2', + creator: 'alice', + amount: '2000', + status: 'failed', + startTimestamp: '2025-02-01T23:59:59Z', + endTimestamp: '2025-02-02T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: end, + orgId: ORG_ID, + }, + { + id: 'v3', + creator: 'alice', + amount: '3000', + status: 'completed', + startTimestamp: '2025-02-02T00:00:00Z', + endTimestamp: '2025-02-03T00:00:00Z', + successDestination: 'success', + failureDestination: 'failure', + createdAt: '2025-02-02T00:00:00Z', + orgId: ORG_ID, + }, + ]) + + const res = await request(app) + .get(`/api/orgs/${ORG_ID}/analytics/risk`) + .query({ startDate: start, endDate: end }) + .set('Authorization', authHeader('alice')) + + expect(res.status).toBe(200) + expect(res.body.analytics).toMatchObject({ + resolvedVaults: 2, + totalVaults: 2, + slashRate: 0.5, + capitalAtRisk: '0', + }) + }) +}) From 79aeec2acb16c1f78749e499e363922645d6d2b6 Mon Sep 17 00:00:00 2001 From: Marvellous Akinyemi Date: Sat, 27 Jun 2026 21:57:23 +0100 Subject: [PATCH 2/3] Implement query parameter validation and pagination enhancements --- src/middleware/queryParser.ts | 27 ++++++++++ src/tests/queryParser.injection.test.ts | 65 +++++++++++++++++++++++++ src/utils/pagination.ts | 42 +++++++++++----- 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 src/tests/queryParser.injection.test.ts diff --git a/src/middleware/queryParser.ts b/src/middleware/queryParser.ts index a3660318..fe292ae8 100644 --- a/src/middleware/queryParser.ts +++ b/src/middleware/queryParser.ts @@ -11,9 +11,36 @@ export interface QueryParserOptions { allowedFilterFields?: string[] } +const PROTECTED_QUERY_KEYS = new Set(['__proto__', 'constructor', 'prototype']) +const RESERVED_QUERY_KEYS = new Set(['page', 'pageSize', 'cursor', 'limit', 'sortBy', 'sortOrder']) + +function validateQueryParams(req: Request, options: QueryParserOptions) { + const allowedFilterFields = options.allowedFilterFields ?? [] + const allowedSortFields = options.allowedSortFields ?? [] + const allowedKeys = new Set([...RESERVED_QUERY_KEYS, ...allowedFilterFields, ...allowedSortFields]) + + for (const key of Object.keys(req.query)) { + const normalizedKey = key.toLowerCase() + + if (PROTECTED_QUERY_KEYS.has(normalizedKey)) { + throw new Error('Invalid query parameters') + } + + if (key.includes('__') || key.includes(':')) { + throw new Error('Invalid query parameters') + } + + if (!allowedKeys.has(key)) { + throw new Error('Invalid query parameters') + } + } +} + export function queryParser(options: QueryParserOptions = {}) { return (req: Request, res: Response, next: NextFunction) => { try { + validateQueryParams(req, options) + // Parse pagination req.pagination = parsePaginationParams(req) req.cursorPagination = parseCursorPaginationParams(req) diff --git a/src/tests/queryParser.injection.test.ts b/src/tests/queryParser.injection.test.ts new file mode 100644 index 00000000..d71ca864 --- /dev/null +++ b/src/tests/queryParser.injection.test.ts @@ -0,0 +1,65 @@ +import express, { Request, Response, NextFunction } from 'express' +import request from 'supertest' +import { describe, it, expect } from '@jest/globals' +import { queryParser } from '../middleware/queryParser.js' + +const app = express() +app.use(express.json()) + +app.get( + '/parse', + queryParser({ allowedSortFields: ['createdAt', 'status'], allowedFilterFields: ['status', 'creator'] }), + (req: Request, res: Response) => { + res.json({ filters: req.filters, sort: req.sort, pagination: req.pagination }) + }, +) + +describe('queryParser injection guards', () => { + it('rejects prototype pollution keys such as __proto__, constructor, and prototype', async () => { + const res = await request(app) + .get('/parse') + .query({ + __proto__: { status: 'active' }, + constructor: { status: 'active' }, + prototype: { status: 'active' }, + }) + + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/invalid query/i) + }) + + it('rejects unsupported operators and unknown fields', async () => { + const res = await request(app) + .get('/parse') + .query({ + status: 'active', + creator: 'alice', + filter: 'status:active', + sortBy: 'createdAt', + sortOrder: 'desc', + foo: 'bar', + status__gt: 'active', + }) + + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/invalid query/i) + }) + + it('accepts valid filters and sort params', async () => { + const res = await request(app) + .get('/parse') + .query({ + status: 'active', + creator: 'alice', + sortBy: 'createdAt', + sortOrder: 'desc', + page: '2', + pageSize: '10', + }) + + expect(res.status).toBe(200) + expect(res.body.filters).toEqual({ status: 'active', creator: 'alice' }) + expect(res.body.sort).toEqual({ sortBy: 'createdAt', sortOrder: 'desc' }) + expect(res.body.pagination).toEqual({ page: 2, pageSize: 10 }) + }) +}) diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index 5211d596..25e817d9 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -11,22 +11,33 @@ const DEFAULT_PAGE = 1 const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 +function parsePositiveInteger(value: unknown, fieldName: string): number { + if (value === undefined || value === null || value === '') { + return DEFAULT_PAGE_SIZE + } + + if (Array.isArray(value)) { + throw new Error(`Invalid ${fieldName}`) + } + + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Invalid ${fieldName}`) + } + + return parsed +} + export function parsePaginationParams(req: Request): PaginationParams { - const page = Math.max(1, parseInt(req.query.page as string) || DEFAULT_PAGE) - const pageSize = Math.min( - MAX_PAGE_SIZE, - Math.max(1, parseInt(req.query.pageSize as string) || DEFAULT_PAGE_SIZE) - ) + const page = parsePositiveInteger(req.query.page, 'page') + const pageSize = Math.min(MAX_PAGE_SIZE, parsePositiveInteger(req.query.pageSize, 'pageSize')) return { page, pageSize } } export function parseCursorPaginationParams(req: Request): CursorPaginationParams { const cursor = req.query.cursor as string - const limit = Math.min( - MAX_PAGE_SIZE, - Math.max(1, parseInt(req.query.limit as string) || DEFAULT_PAGE_SIZE) - ) + const limit = Math.min(MAX_PAGE_SIZE, parsePositiveInteger(req.query.limit, 'limit')) return { cursor, limit } } @@ -48,8 +59,17 @@ export function decodeCursor(cursor: string): { timestamp: Date; id: string } { } export function parseSortParams(req: Request, allowedFields: string[]): SortParams { - const sortBy = req.query.sortBy as string - const sortOrder = (req.query.sortOrder as string)?.toLowerCase() === 'desc' ? 'desc' : 'asc' + const sortBy = req.query.sortBy as string | undefined + const sortOrderValue = req.query.sortOrder as string | undefined + const sortOrder = sortOrderValue?.toLowerCase() === 'desc' + ? 'desc' + : sortOrderValue?.toLowerCase() === 'asc' + ? 'asc' + : 'asc' + + if (sortOrderValue && !['asc', 'desc'].includes(sortOrderValue.toLowerCase())) { + throw new Error('Invalid sort order') + } if (sortBy && !allowedFields.includes(sortBy)) { throw new Error(`Invalid sort field. Allowed fields: ${allowedFields.join(', ')}`) From 537ec91663c8be896c16741c0f78f1f8ccc1ec8c Mon Sep 17 00:00:00 2001 From: wonderfulmarv01 Date: Tue, 30 Jun 2026 01:03:59 +0100 Subject: [PATCH 3/3] Harden query parsing middleware against unsafe input and add regression tests --- PR_DESCRIPTION.md | 26 ++++++++++++++++++ src/services/queryParser.ts | 36 +++++++++++++++++++++++-- src/tests/queryParser.injection.test.ts | 24 +++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..f0b7bd58 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,26 @@ +## Summary + +This PR hardens the shared query parsing middleware used by list endpoints against unsafe query input. + +## What changed + +- Added explicit validation to reject prototype-pollution style keys such as `__proto__`, `constructor`, and `prototype`. +- Rejected malformed or unsupported query parameters before they can be parsed into filter/sort/pagination state. +- Tightened pagination and sort parsing to fail fast on invalid numeric values or unsupported sort order values. +- Added regression tests covering rejection of unsafe query keys and acceptance of valid filters/sort/pagination input. + +## Why + +The previous middleware accepted a broad set of query keys and did not guard against polluted or malformed input. That made it possible for unexpected query parameters to influence request handling and could create edge cases in downstream filtering logic. + +## Impact + +- Improves resilience of list/search endpoints against malformed or hostile query payloads. +- Preserves existing supported behavior for valid pagination, filtering, and sorting. +- Adds regression coverage to prevent future regressions in the query parser contract. + +## Testing + +- Added targeted regression tests in `src/tests/queryParser.injection.test.ts`. +- Verified there are no TypeScript/editor errors in the touched files. +- Attempted to run the Jest regression suite, but local execution is currently blocked by the environment’s npm/Node setup. diff --git a/src/services/queryParser.ts b/src/services/queryParser.ts index 9ff12e8c..b29cc36b 100644 --- a/src/services/queryParser.ts +++ b/src/services/queryParser.ts @@ -1,5 +1,37 @@ -import { Knex } from 'knex'; -import { sanitizeObject, isValidField } from '../lib/validation.js'; +import { Knex } from 'knex' + +const PROTECTED_QUERY_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function sanitizeObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeObject(item)) + } + + if (!isPlainObject(value)) { + return value + } + + const sanitized: Record = Object.create(null) + + for (const [key, nestedValue] of Object.entries(value)) { + const normalizedKey = key.toLowerCase() + if (PROTECTED_QUERY_KEYS.has(normalizedKey)) { + continue + } + + sanitized[key] = sanitizeObject(nestedValue) + } + + return sanitized +} + +function isValidField(column: string, allowedColumns: string[]): boolean { + return typeof column === 'string' && allowedColumns.includes(column) +} /** * Supported operators for filtering. diff --git a/src/tests/queryParser.injection.test.ts b/src/tests/queryParser.injection.test.ts index d71ca864..8e0a605e 100644 --- a/src/tests/queryParser.injection.test.ts +++ b/src/tests/queryParser.injection.test.ts @@ -2,6 +2,7 @@ import express, { Request, Response, NextFunction } from 'express' import request from 'supertest' import { describe, it, expect } from '@jest/globals' import { queryParser } from '../middleware/queryParser.js' +import { QueryParser } from '../services/queryParser.js' const app = express() app.use(express.json()) @@ -15,6 +16,29 @@ app.get( ) describe('queryParser injection guards', () => { + it('ignores prototype pollution and unsupported operators in the service parser', () => { + const parser = new QueryParser({ allowedColumns: ['status', 'creator'] }) + + const parsed = parser.parse({ + filter: { + status: { eq: 'active' }, + creator: { nope: 'alice' }, + __proto__: { eq: 'polluted' }, + constructor: { eq: 'polluted' }, + prototype: { eq: 'polluted' }, + unknown: { eq: 'ignored' }, + }, + limit: '10', + offset: '2', + sort: 'status:desc', + }) + + expect(parsed.conditions).toEqual([{ column: 'status', operator: '=', value: 'active' }]) + expect(parsed.limit).toBe(10) + expect(parsed.offset).toBe(2) + expect(parsed.sorts).toEqual([{ column: 'status', order: 'desc' }]) + }) + it('rejects prototype pollution keys such as __proto__, constructor, and prototype', async () => { const res = await request(app) .get('/parse')