feat(portal-bff): rate limiting + structured error filter #123
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/security-rate-limit-error-filter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes the phase-2 hardening list that
main.tshas been advertising since the security PR (#122). Two new middlewares + one alignment pass on the response shape so every BFF error follows a single contract.Structured error filter
A global
ExceptionFilter(registered viaapp.useGlobalFilters(...)at the top ofbootstrap()) normalises every 4xx/5xx response to a single envelope :code— stable token the SPA canswitchon. Either explicit on theHttpException's response object (new UnauthorizedException({ code: 'unauthenticated', message: '...' })) or derived from the status (STATUS_CODE_MAPfor the common cases,'http_error'fallback). 500s always use'internal'.message— safe human-readable text. 500s never leak the underlying exception (the full message + stack go to the Pinoerrorlog line aserr: exception— Pino's stack-serialiser does the rest).traceId— current OTel trace id (ornullwhen no span is active). Makes cross-correlation with the audit log + Pino lines trivial.An exported
errorResponse(code, message)helper produces the same envelope for code paths that write the response directly (raw Express middlewares like the CSRF one, the rate-limit handler) — single contract everywhere.Rate limiting
express-rate-limitmounted after the session middleware:/api/auth/login+/api/auth/callback(RATE_LIMIT_AUTH_PER_MINUTEenv), 120/min everywhere else (RATE_LIMIT_PER_MINUTE)./api/healthis skipped so orchestrator polls don't burn the user quota.{ error: { code: 'rate_limited', … } }) via the sharederrorResponse()helper.Alignment pass
{ error: 'csrf' }. Now returns the full envelope viaerrorResponse('csrf', 'CSRF token missing or invalid')./auth/me401 previously wrote{ error: 'unauthenticated' }directly. Now throwsUnauthorizedException({ code: 'unauthenticated', message: 'Unauthenticated' })so the filter formats it. Identical response shape on the wire as the CSRF path.Both spec assertions updated to the new shape.
Type-resolution fix (transitive)
@types/express@4.17.25was being pulled in transitively byhttp-proxy-middleware(Nx's webpack-dev-server).express-rate-limit's.d.tsfiles import'express'and the type resolver was matching the v4 copy, causingRequesttype mismatches with our v5-based code. Added"@types/express": "^5.0.6"topnpm.overridesso the workspace pins a single version everywhere.Notable choices
StructuredErrorFilteris the source of truth, but raw middlewares are still allowed to write responses directly (rate-limit, CSRF). The reason: Nest's filter chain only handles exceptions thrown from controllers/guards/interceptors. Express middleware short-circuits before that. Both paths now use the same envelope shape through theerrorResponse()helper.No
traceIdin non-5xx responses? It IS included. The filter writes it on every status — useful for any client-server debugging conversation ("send me your traceId from the 403 you got").500s strip the exception message. Even if a developer accidentally surfaces a sensitive detail via
throw new Error('connection to postgres://user:secret@host failed'), the response body just says "Internal server error". The full message goes to the log — visible to ops, never to clients. This is the standard secure-by-default for unhandled errors.Dynamic
maxper request, not two separaterateLimit()instances. Two instances would each maintain a separate store, so the/auth/loginbucket would be independent of the general one for the same IP. A single instance with a path-conditional max gives consistent bucket accounting.Out of scope
new RedisStore({ ... })when we scale out (ADR-0015 mentions this).RATE_LIMIT_PER_MINUTE(e.g. admins / service accounts with higher quotas). No code path for this in v1.Test plan
pnpm nx test portal-bff(clean env) → 199/199 pass (+25 specs: StructuredErrorFilter, rate-limit middleware, CSRF + /me alignments).pnpm nx test feature-auth(clean env) → 28/28 pass.pnpm nx test portal-shell(clean env) → 34/34 pass.pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell→ clean.RATE_LIMIT_*) → 261/261 pass.{ error: { code, message, traceId } }. Pino log has the full exception undererr./api/auth/mewithout a session cookie → 401 + same envelope,code: 'unauthenticated'./api/auth/login11 times in a minute → 11th returns 429 +code: 'rate_limited'./api/healthhit 100 times → all 200.X-CSRF-Token→ 403 +code: 'csrf'.