Files
jobtrackingapp/docs/audits/2026-09-07/validation/b04-completion.md
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

20 KiB
Raw Blame History

B04 — Authenticated account rate-limit partitioning

Implementation date: 2026-09-08. B04 is complete locally; not deployed. B01, B02 and B03 are preserved. No schema, deployment, authentication redesign, distributed limiter, billing or B05 change.

Original behavior and root cause

The original F06 observation and probe remain unchanged. Before modifying application code, the new permanent HTTP test ran real Identity logins and account exports against the current API: A: 200/200; B on the same IP: 429/429. Its safe assertion failed in .artifacts/b04/before.json. This independently confirms the original audit's 200/200/429 observation.

Program.cs registered account-aware policies correctly in intent, but called UseRateLimiter before UseAuthentication. Both account-data and email-send read an empty principal and fell back to IP. Helper-only unit tests could not detect this middleware defect. Session ID, record revision and B03 resource concurrency are different contracts and were not changed.

Middleware and identity boundary

Before: endpoint routing → forwarded-header/host/HTTPS/error/logging middleware → CORS → rate limiting → existing CSRF middleware → authentication → authorization → B02 session context → controllers.

After: endpoint routing → forwarded-header/host/HTTPS/error/logging middleware → CORS → existing CSRF middleware → authentication → authorization → rate limiting → B02 session context → controllers. Routing is supplied by the minimal ASP.NET application and makes endpoint policies available before rate limiting; the existing explicit application middleware order otherwise stays intact. Forwarded headers still precede IP evaluation, and the exception handler still surrounds authentication and rate-limit processing.

Only the rate-limiter call moved. The existing smart scheme selects default authentication; local JWT signatures, issuer, audience, lifetime and database-backed session/user validation finish before account partitioning. Authorization must also precede partitioning: an endpoint's explicit local scheme can replace the default scheme's principal. Self-review identified this in the optional Google bearer + local cookie path. The first authentication-only placement was therefore superseded, even though its original shared-IP tests passed. The final HTTP regression proves invalid credentials are rejected before the account limiter, guarding authorization order as well as authentication order. No claim of a live Google mixed-credential test is made; the source-established scheme-selection boundary is handled by the framework's existing authorization middleware.

Local bearer and session-cookie credentials resolve to the same application user ID. Existing local-cookie precedence when both a local cookie and local bearer are supplied is preserved and tested. External exchanges still issue local sessions; optional external-provider configuration is unchanged, and no live OAuth provider was contacted.

The two account policies now share RateLimitPartitionKeys.Account, which calls the existing LocalAuthIdentity.GetRequiredUserId. It requires an authenticated principal and uses its name-identifier/subject. The key is {policy}:user:{validated owner} or {policy}:ip:{RemoteIpAddress} when authentication did not establish an owner. Separate user/IP namespaces prevent an identifier shaped like an IP from colliding with anonymous traffic. No body/query/header value, including X-Session-Context, selects the owner.

Rate limiting follows CSRF and authorization but remains before B02 session preconditions and controller validation. An authorized attempt can consume that authenticated account's allowance even if a later B02 check returns 409. It cannot charge the account named by the stale header. Malformed/expired/invalid-cookie/revoked/nonexistent-session credentials receive 401 before account partitioning on protected endpoints, even after repeated attempts; a CSRF failure can still return 403 earlier. Anonymous/security endpoints marked AllowAnonymous still reach their IP policies, where those same invalid credentials cannot manufacture partitions. This deliberately avoids charging an anonymous or different-scheme key for an operation that only the endpoint's local principal may perform.

Authentication/authorization now incur their normal token/session/policy validation cost before a request can be rate-rejected, including requests with credentials to IP-scoped endpoints. This resolves the actual resource principal at this boundary; it is not a pre-authentication CPU or global ingress admission limit. Invalid credentials cannot reach protected controller work, and anonymous security actions remain rate-limited. No unverified token is parsed into an account key.

Limiter inventory

All six ASP.NET policies remain fixed-window, zero-queue limiters. No permits or windows changed. Endpoints below are POST unless stated otherwise.

Policy Endpoints Classification / allowance Partition after B04 Anonymous or failed authentication
auth-login /api/auth/login, /register, /google/exchange, /microsoft/exchange; /api/auth/2fa/setup, /verify-setup, /disable, /recovery-codes/regenerate IP-scoped; default 10 / 5 minutes (RateLimits:AuthLoginPermitLimit remains configurable) login:{IP} even for authenticated callers; all listed routes share the policy Anonymous endpoints use the same IP key; protected endpoints reject before limiting if authorization fails
auth-email /api/auth/microsoft/legacy-relink/confirm; /api/auth/email-change/request, /confirm, /cancel; /api/auth/request-password-reset, /reset-password, /verify-email, /resend-verification-email; /api/users/{id}/send-password-reset, /api/users/send-test-email IP-scoped; 5 / 15 minutes email:{IP} even for authenticated/admin callers; submitted email/user identifiers do not select the key Anonymous endpoints use the same IP key; protected endpoints reject before limiting if authorization fails
auth-2fa-challenge /api/auth/2fa/challenge IP-scoped; 5 / 5 minutes 2fa:{IP} Same IP key; pending-token/code validation remains in controller
email-send /api/email/send Account-scoped; 10 / minute email-send:user:{owner} across sessions, IPs and credentials Local authorization returns 401 before rate partitioning
account-data /api/export/account, /api/account-lifecycle/delete Account-scoped; 2 / hour, shared by both routes account-data:user:{owner} across sessions, IPs and credentials Local authorization returns 401 before rate partitioning
public-pdf GET /api/public-cv/{slug}/pdf Combined IP + slug; 3 / minute public-pdf:{IP}:{slug}, regardless of authentication Same IP + slug key; only published documents are downloadable

Route prefixes in grouped cells are shared within the indicated controller. The account-key helper retains a defensive {policy}:ip:{IP} fallback if used without an authenticated owner; current account-policy endpoints require local authorization and do not admit anonymous requests to that fallback. When an actual remote address is unavailable, the existing unknown fallback remains; real HTTP tests have an address and prove independent anonymous IPs. The intentional IP-scoped policies can still be shared by legitimate users behind one NAT. B04 isolates account quotas, not every security quota.

B03 is separate: its singleton exporter admission is a process/global expensive-resource boundary, with a per-document-owner active allowance. It is not one of these fixed-window policies. Public PDF traffic passes its IP+slug rate limit before the shared exporter gate; the other PDF routes still use the same exporter. No new PDF rate policy or admission pool was added.

Proxy and response behavior

Existing ForwardedProxyConfiguration requires explicit known networks, clears implicit defaults and accepts one forwarding hop. Rate policies use Connection.RemoteIpAddress, never raw X-Forwarded-For, Forwarded or X-Real-IP. The permanent HTTP fixture trusts only 127.0.0.1/32 as a synthetic proxy, using documented test addresses as forwarded clients. Actual sockets bound to untrusted 127.0.0.2 cannot evade the login limit by changing all three forwarding headers; 127.0.0.3 retains independent capacity. No production proxy configuration changed. B06 still must establish deployed proxy/firewall/direct-ingress correctness.

Rate rejections retain 429, now with the fixed-window lease's Retry-After metadata rounded up to seconds, Cache-Control: no-store, and a safe ProblemDetails body (code: rate_limit_exceeded, generic title/detail, trace ID). No partition keys, owner IDs or credential details are returned. Existing frontend helpers already understand ProblemDetails and 429; no application frontend code changed. B03's distinct 503/504 contract is unchanged.

Permanent regression and adversarial evidence

HTTP tests use real local API middleware, authentication/session cookies, bearer tokens, owner checks and synthetic SQLite data, with test-only configuration. They add nine scenarios:

  1. A exhausts account exports; B on the same IP still has two successful exports; both then receive 429.
  2. Reverse the order: B first, with the same independent result.
  3. Four simultaneous exports: two accounts, two sessions each, cookie/bearer mixed. All four succeed; later requests from other IPs remain limited for both owners.
  4. Craft body/query/account headers and stale/forged B02 contexts under B's authentication. All ten attempts charge B, while A retains all ten email-send permits. Unconfirmed sends return 400 before provider/delivery work; no email is sent.
  5. Export plus explicitly disabled deletion share account-data capacity; another owner retains allowance. Deletion is not enabled or performed.
  6. Anonymous, malformed bearer, correctly signed expired bearer, invalid cookie, revoked bearer, bad signature and correctly signed nonexistent session each receive repeated 401s before account partitioning. The same credentials cannot escape anonymous login's IP allowance. A legitimate session on an IP with exhausted login capacity retains its account allowance.
  7. Login/registration, reset/resend and 2FA challenge preserve their separate IP policies, including authenticated traffic and a second anonymous IP.
  8. Public PDF stays IP+slug scoped across anonymous/A/B callers; distinct slugs and IPs retain their own allowance. Missing slugs avoid rendering in this rate-policy test.
  9. Real untrusted loopback peers cannot use forwarded-header forgery to select an IP partition.

Two additional backend cases in RateLimitPartitionKeysTests cover both account policies' rejection of unauthenticated claims, anonymous IP separation and user/IP namespace collision prevention. The real HTTP suite, rather than those helper tests, is the permanent middleware-order regression gate.

Bounded local scenario A B Result
Before application changes; one shared IP 2 admitted 0 admitted, 2 rejected Original failure reproduced
After, A first; one shared IP 2 admitted, 1 rejected 2 admitted, 1 rejected Independent account partitions
After, B first; one shared IP 2 admitted, 1 rejected 2 admitted, 1 rejected Same result in reverse
Concurrent, two sessions each, mixed cookie/bearer 2 admitted; 2 later rejected from other IPs 2 admitted; 2 later rejected from other IPs Four concurrent successes, no session/IP bypass

These are bounded local correctness checks, not production load tests. Original evidence is preserved; see B04 observations and results/hashes.

Verification commands and results

Final results: nine HTTP rate-policy scenarios passed, 877 selected backend tests passed, 17 browser tests passed without retries/flaky outcomes, 36 affected frontend tests / four suites passed, and B03's authenticated admission probe passed unchanged (two admitted, ten rejected, maximum two renders, recovery 200). The initial focused auth/rate/B01–B03 backend run passed 207 tests; the final full backend includes those cases. Release warnings-as-errors build passed with zero warnings/errors, targeted lint passed, and git diff --check passed. Counts overlap; do not add subset runs to full totals. Manifest and raw-output hashes.

Standalone TypeScript's final incremental run exits 1 (the initial run exited 2) with only the existing src/interview-prep.test.tsx(120,10): error TS2571: Object is of type 'unknown'. No B04 type error, suppression or unrelated typing fix was added. Frontend runtime code did not change, so affected Jest/browser checks and standalone typing were used; a new Next production build was not needed or claimed for B04.

Repository root, PowerShell:

& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter 'FullyQualifiedName~RateLimitPartitionKeysTests|FullyQualifiedName~LocalAuthIdentityTests|FullyQualifiedName~AuthSessionRevocationTests|FullyQualifiedName~SessionContextMiddlewareTests|FullyQualifiedName~ExternalOriginTests|FullyQualifiedName~ApiAuthorizationPostureTests|FullyQualifiedName~SessionsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~JobImport|FullyQualifiedName~PdfRenderAdmissionTests|FullyQualifiedName~CvExportRetentionTests' --logger 'trx;LogFileName=focused.trx' --results-directory .artifacts/b04 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --no-build --filter 'FullyQualifiedName!~ImapServiceSsrfGuardTests.ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail' --logger 'trx;LogFileName=backend-final.trx' --results-directory .artifacts/b04 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore -warnaserror
Copy-Item docs/audits/2026-09-07/validation/evidence/b02-browser-config.ts.txt .artifacts/b04/playwright.offline.config.ts
git diff --check

UI directory, PowerShell (run the three API/browser suites serially because they use port 5302):

$env:DOTNET_HOST_PATH='C:/Users/Connor/.dotnet/dotnet.exe'
$env:NEXT_TELEMETRY_DISABLED='1'
node node_modules/@playwright/test/cli.js test --config=playwright.rate-limits.config.ts --reporter=json > ../.artifacts/b04/api-final.json
node node_modules/@playwright/test/cli.js test --config=playwright.pdf.config.ts --reporter=json > ../.artifacts/b04/pdf-admission-final.json
$env:PLAYWRIGHT_STATIC_EXPORT='true'
$env:PLAYWRIGHT_PREBUILT_EXPORT='true'
node node_modules/@playwright/test/cli.js test --config=../.artifacts/b04/playwright.offline.config.ts --reporter=json > ../.artifacts/b04/browser-verified.json
node node_modules/jest/bin/jest.js src/login-page.test.tsx src/session-boundary.test.ts src/account-scoped-state.test.tsx src/cv-builder-page.test.tsx src/api.test.ts --runInBand --watchAll=false --json --outputFile=../.artifacts/b04/frontend-focused.json
node node_modules/eslint/bin/eslint.js playwright.rate-limits.config.ts rate-limit-tests/isolation.spec.ts e2e/smoke.spec.ts --max-warnings=0
node node_modules/typescript/bin/tsc --noEmit --pretty false

The Jest selection resolves to four existing suites; no src/api.test.ts suite exists. Browser verification reuses B03's already verified production-static frontend targeting localhost:5302, since B04 changes no frontend runtime code. It starts a fresh API/data root and exercises the B04 backend. The test export is not a deployable production artifact. Existing local dependencies were used; no dependency downloads or external providers. The one excluded backend test performs external DNS and was not authorized; selected tests have no skips. An initial fixture-lint false positive interpreted Playwright's use callback as a React hook; naming it provide fixed the test without disabling lint rules.

The first full browser run passed 17/17 before the final authorization-order refinement. The next run passed 16/17: the anonymous public-CV fixture cleared cookies while its authenticated page was still running, so B02 correctly redirected that page to /login?sessionChanged=1 during the next navigation. The retained error context shows the session-change sign-in screen, not a rate-limit or rendering failure. The fixture now navigates to about:blank before removing cookies, stopping the old page's callbacks. This is a test-only correction; the dedicated account-switch test still verifies B02's live-page redirect. The final full rerun passed 17/17, including anonymous PDF and account switching. Failed output and .artifacts/b04/public-cv-session-race.md remain separate evidence.

Residual limits, self-review and rollout

All fixed-window partitions remain process-local. With N API instances an account can receive up to N × the per-instance allowance if traffic reaches all instances. Restarts reset counters. B04 does not claim distributed quotas or introduce Redis. Reassess shared rate budgets when horizontal scaling actually requires them.

Account quotas count authorized attempts, including later B02/session/controller-validation failures, not only successful exports/sends. Requests denied by CSRF or authorization no longer consume rate capacity; they cannot reach the protected operation. The tests explicitly prove the final ordering contract. Auth validation occurs before rate admission; middleware limits do not replace host/ingress resource controls. Intentional login/security IP sharing, missing-IP fallback and configured trusted-proxy assumptions remain explicit. Public-PDF per-IP+slug limiting and B03 process concurrency are different layers; B03's R04 production sizing/containment limitations remain open.

Self-review traced every account-policy registration and endpoint: only server-authenticated claims reach owner keys; extra sessions and auth transport switches do not change the account ID; invalid authentication cannot choose arbitrary keys; B02 is still a precondition after authorization and is not an identity source. IP policies were neither removed nor converted. Forwarding trust is tested with real local sockets. No new service, network path, schema or synchronization primitive was introduced. Existing ASP.NET partitioned limiters provide concurrency safety.

No data migration or rollback transformation is needed. Rolling back the middleware order reopens shared-IP account throttling; retain authentication and endpoint authorization before limiting during any rollback. Deployment/ingress verification remains separately authorized B06 work, not implied by this local completion.

Files and next ticket

Application: JobTrackerApi/Program.cs (limiter order, two key calls, safe rejection metadata) and JobTrackerApi/Services/RateLimitPartitionKeys.cs. Tests: JobTrackerApi.Tests/RateLimitPartitionKeysTests.cs, new job-tracker-ui/rate-limit-tests/isolation.spec.ts, new job-tracker-ui/playwright.rate-limits.config.ts; one package script; job-tracker-ui/e2e/smoke.spec.ts stops the authenticated page before the anonymous fixture clears cookies. Documentation: this report, observations/results manifest, validation README/queue/evidence and TODO. Pre-existing uncommitted B01/B02/B03 and audit work remains present; the entire working-tree diff is not exclusively B04.

Recommended next ticket: B05 — spreadsheet-safe CSV fields. Not started.

Acceptance: all ten B04 criteria are satisfied locally. Account keys use the principal established by authentication and endpoint authorization; shared-IP accounts stay isolated while same-account sessions/credentials share capacity; client-controlled identity fields cannot choose keys. Anonymous/security IP policies, B02 preconditions and B03 rendering admission remain tested. Middleware order and process-local/deployment limits are documented. The only standalone type diagnostic is the pre-existing R01a failure. No schema or architecture stop condition was encountered. This completion does not authorize deployment or B05.