Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/analytics-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/app-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions src/middleware/queryParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions src/routes/orgAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
130 changes: 129 additions & 1 deletion src/services/analytics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VaultAnalytics> {
return getOrSet('analytics:overall', 300, async () => {
const summary = await readAnalyticsSummary()
Expand Down
36 changes: 34 additions & 2 deletions src/services/queryParser.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> = 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.
Expand Down
Loading