Makes the application say what it is doing and when it fails
U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot silence browser errors, Umami on the admin SPA, and six security events that alert rules can actually be built on. The correlation id is the W3C trace id from the ambient Activity, enabled by one line of ActivityTrackingOptions so every entry from every category carries it without touching a call site. It propagates across the master/slave boundary via traceparent, which TraceIdentifier cannot do at all, and it is the same value ProblemDetails already returns to the browser. The security events use source-generated LoggerMessage with constant templates. Sentry groups log events by message, so interpolating an email address would give every address its own issue and "more than 20 failed logins in five minutes" could never fire — the events would arrive, be visible, be tagged, and the alerting would silently be impossible. A test asserts the rendered message is identical across argument values. Scrubbing happens in-process, before transmission, and covers Set-Cookie as well as Cookie: the login response issues the refreshToken there, so scrubbing only the request side would protect nothing. Transactions are scrubbed too, because they carry request data and are the channel nobody thinks of. The tunnel derives its destination from the DSN once at startup and reads nothing from the request, which is what separates a tunnel from a server-side request forgery primitive. Size is capped by a bounded read rather than by trusting Content-Length, and the endpoint is rate limited. Two things found along the way. Zod 4's url() hands the value to the URL constructor, which accepts any scheme — so the existing frontend validation would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA would have called a nonexistent origin. Now constrained to http(s). And the new appsettings comments are verified against the real configuration provider, because the failure mode if it rejected them is both hosts refusing to start after a release switch. One deviation. IAdminTokenValidator was meant to gain a reason-reporting overload; implemented that way, a substitute returning false by default silently inverted the access decision while both methods compiled. Two methods whose difference is invisible at the call site is the defect, so it is now a single Validate returning AdminTokenResult. Touches two files from already-committed units: DatabaseMigrationExtensions (U2) gains a flush before the rethrow, or the one Critical event in the system dies with the process; AdminTokenValidator (U1) classifies why a bypass was refused. Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests, up from 213. tsc clean, eslint clean on every changed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -877,3 +877,73 @@ Each is one line; all three are listed at the end of U4's pattern document for r
|
||||
**No blocking security findings. No new deviation.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-28 — CONSTRUCTION: Code Generation (Round 2, U3 + U4)
|
||||
|
||||
**Stage**: Code Generation for U3 HTTP Security Headers & CSP and U4 Observability Integration. Each unit was built and tested before moving on, per the standing instruction not to defer verification to the final stage.
|
||||
|
||||
### Verification
|
||||
|
||||
| | U3 | U4 |
|
||||
|---|---|---|
|
||||
| Release build | 0 errors | 0 errors |
|
||||
| Backend tests | **315** passed (from 253) | **366** passed |
|
||||
| Frontend tests | unchanged (213) | **237** passed |
|
||||
| `tsc -b` | n/a | clean |
|
||||
| eslint on changed files | n/a | 0 problems |
|
||||
|
||||
Full `pnpm run lint` remains at the pre-existing 5 errors / 1 warning — none in files this round touched. FR-21 fixes those in U5.
|
||||
|
||||
`Sentry.AspNetCore` 6.8.0 ships a native `net10.0` asset, closing the compatibility question NFR Design carried forward. `@sentry/react` 10.68.0; the lockfile diff is additions only.
|
||||
|
||||
### Two findings
|
||||
|
||||
**1. `z.string().url()` never caught the typo BR-U4-24 cites.** The rule says a malformed API base URL must not be silently accepted, and names `htp://localhost:7221`. Zod 4's `url()` validates by handing the value to the `URL` constructor, which accepts **any** scheme — verified directly: `htp://localhost:7221` and `ftp://x.nl` both pass a bare `.url()`. So the *pre-existing* frontend validation, before this unit touched it, would have accepted exactly the typo it was supposed to catch, and the SPA would have issued requests to a nonexistent origin — a failure that looks like the API being down. Now `z.url({ protocol: /^https?$/ })`. Not a regression introduced here; found because BR-U4-24 asked for a test the old schema could not have passed.
|
||||
|
||||
**2. Comments in `appsettings.json` are verified rather than assumed.** Several non-obvious values gained `//` comments. The JSON configuration provider does tolerate them, but the failure mode if it did not is *both hosts refusing to start after a release switch*, so `DeployedConfigurationTests` now loads both real, committed files through the real provider — and runs `ValidateOnStart` against the committed `SecurityHeaders` section, so a policy-name typo fails in CI rather than in a deployment.
|
||||
|
||||
### One deviation, and what it revealed
|
||||
|
||||
**`IAdminTokenValidator` collapsed to a single method.** NFR Design specified adding a reason-reporting overload alongside the existing `IsVerifiedAdmin(string?)`. Implemented that way, two existing tests failed in a revealing manner: the middleware called the new overload while the tests stubbed the old one, and an `NSubstitute` substitute returns `false` by default — so **the access decision silently inverted** while both methods existed and compiled.
|
||||
|
||||
That is the shape of the defect, not merely of the test failure. Two methods whose difference is invisible at a call site means a caller using the boolean form gets the correct access decision and silently emits no security event — precisely the class of bug this unit exists to make impossible. Replaced with one `Validate(string?) → AdminTokenResult`. Five call sites and two test files updated; behaviour otherwise identical.
|
||||
|
||||
Also noted: `MigrationFailure` uses synchronous `SentrySdk.Flush`, because `MigrateCoreDatabase` is synchronous and making it async would change a U2 signature and both hosts' startup for no benefit.
|
||||
|
||||
### Traps that were closed rather than encountered
|
||||
|
||||
- **`DefaultHttpContext.Response.OnStarting` is a no-op.** All of U3's decision logic went into a static `SecurityHeaderWriter` tested against a bare `HeaderDictionary`, so the middleware is glue whose only risk is registration order — which is carried to Build and Test rather than pretended to be unit-testable.
|
||||
- **Sentry groups log events by message template.** Six source-generated `LoggerMessage` methods with constant templates and `EventId` 5001–5006, plus a test asserting the rendered message is *identical* across different argument values. Without that, FR-19's rate-based alert rules could never fire while everything appeared to work.
|
||||
- **`StartsWithSegments`, not `string.StartsWith`.** `"/administrator".StartsWith("/admin")` is true; a public page would have inherited the strict policy and lost its inline scripts with no server-side trace at all. Asserted for `/administrator`, `/admin-tools`, `/administration/contact`, `/healthcheck` and `/api/v10/Users`.
|
||||
|
||||
### Additions beyond the functional design, each implemented
|
||||
|
||||
- `Set-Cookie` in the scrub list — the login response issues the `refreshToken` there, so scrubbing only the request cookie would protect nothing
|
||||
- `SetBeforeSendTransaction` alongside `SetBeforeSend`
|
||||
- `OnRejected` on the rate limiter — a `429` previously left no trace anywhere, making the only rate limiter in the application unobservable
|
||||
- A `sentry-tunnel` fixed-window limiter — the other controls bound what each call can do, not how many calls there can be
|
||||
- `SentrySdk.Flush` before the migration-failure rethrow
|
||||
- Origin-format validation on the CSP origin lists — a CSP source list silently ignores a malformed source, so a URL with a path would look configured and block the script anyway
|
||||
- `SecurityAuthorizationResultHandler` at `IAuthorizationMiddlewareResultHandler` rather than inside an `IAuthorizationHandler`, because a handler sees one requirement at a time and would report denials for requests that were ultimately allowed
|
||||
|
||||
### Divergences from the reference project, all deliberate
|
||||
|
||||
- The dev tunnel proxy targets the local API rather than Sentry's ingest host, so no project id is committed and nothing has to be kept in sync by hand
|
||||
- `UmamiAnalytics` has no script-removing cleanup: under StrictMode the double-invocation becomes inject → remove → inject, and removing the element does not unregister Umami's listeners, so the first page view can be counted twice. Guarded by a test asserting one injection across re-renders
|
||||
- `tanstackRouterBrowserTracingIntegration` not adopted — it needs the router instance, and importing it from `sentry.ts` inverts the startup order
|
||||
|
||||
### Artifacts generated
|
||||
- `construction/u3-security-headers/code/generation-summary.md`
|
||||
- `construction/u4-observability/code/generation-summary.md`
|
||||
- 7 production files + 4 test files for U3; 10 backend and 3 frontend production files + 5 test files for U4
|
||||
|
||||
### Security Compliance (Security Baseline extension — enabled, blocking)
|
||||
- **SECURITY-03 — compliant.** Correlation ID arrives by configuration rather than discipline; EF's `Database.Command` pinned at `Warning`, asserted against the committed files.
|
||||
- **SECURITY-04 — compliant.** All five headers, one-year HSTS with `includeSubDomains`, a CSP on every HTML-serving path. `script-src 'self'` under Strict is asserted by a test, so loosening it requires deleting a test that explains why.
|
||||
- **SECURITY-11 — compliant.** The tunnel destination is parsed once at startup and no part of it can come from a request.
|
||||
- **SECURITY-14 — addressed; DEV-01 unchanged.** Six tagged event types, all `Warning` or above by construction.
|
||||
- **SECURITY-15 — compliant.** Both units fail closed at startup and neither throws into the response path.
|
||||
|
||||
**No blocking security findings. No new deviation.**
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user