Access gate: shared secret exchanged for a signed cookie (ADR 0021) #7

Merged
bjoern merged 2 commits from feat/access-gate into main 2026-07-09 17:56:41 +02:00
Member

Closes the app by default. Any unauthenticated request redirects to a gate page, where one configured secret is exchanged for a DataProtection-signed cookie. No accounts, no server-side session store — the cookie is self-contained, so it survives a restart as long as the keyring does.

This is the first of the remaining Phase 0 UI pieces. The design-token layer and the project list page follow.

What landed

AccessSecret compares the configured secret in constant time. Both sides are hashed first, so the compared spans are a fixed 32 bytes — FixedTimeEquals alone would still short-circuit on a length mismatch and leak the secret's length.

AccessGate wires the cookie handler (HttpOnly, Secure, SameSite=Lax, 14-day sliding) and a fallback authorization policy that closes every endpoint which does not explicitly opt out. Anonymous: GET /health (the Docker probe, ADR 0015), the gate page, and the static assets — the gate needs its own stylesheet and script before anyone has authenticated.

The gate page is statically server-rendered, not interactive. Issuing the cookie means writing a response header, which a Blazor circuit cannot do. Its ReturnUrl is restricted to rooted, single-slash local paths, so the gate cannot be turned into an open redirect. A one-second delay on a mismatch blunts brute force; sign-out clears the cookie.

Program.cs fails fast when KAGURA_ACCESS_TOKEN is unset rather than ever serving ungated, persists the keyring to data/keys so tokens survive redeploys, and honours forwarded headers since TLS terminates at the reverse proxy.

Two decisions worth reviewing

Secure is unconditional, not SameAsRequest. nginx terminates TLS and forwards plain HTTP, so the app never sees the browser-facing scheme and could not infer it. This makes the reverse proxy a hard requirement rather than a recommendation — a deployment reached over plain HTTP from anything but localhost cannot log in. Browsers treat localhost as a secure context, so dotnet run is unaffected. Recorded in ADR 0021.

ADR 0005 and ADR 0015 contradicted each other on where kagura.db lives: data/kagura.db versus /app/data/db/kagura.db. ADR 0015 wins — /app/data is container-local and ephemeral, so the database needs its own mounted subdirectory or it is lost on recreation. KAGURA_DB_PATH and KAGURA_KEYS_DIR are now independently overridable, falling back to a single data root only for local dev. ADR 0005 and ARCHITECTURE.md now agree with 0015. Worth settling before the Dockerfile freezes the env contract.

ADR 0021 also now records that the DataProtection keyring is written unencrypted at rest on Linux — there is no DPAPI equivalent, so it is protected only by filesystem permissions on its volume. That matches ADR 0014's assumption (a DB backup alone leaks nothing) but should be explicit before encrypted secret rows land.

The gate page ships unstyled, on purpose

ADR 0023 puts every colour and dimension in the Kagura.UI token layer, and says scoped CSS must "consume tokens and never redefine colors." That layer does not exist yet. I wrote a scoped stylesheet with hardcoded hexes and rem values, then deleted it: styling the gate now would quietly make those ad-hoc values the de facto design language, and the token layer would arrive as a cleanup chore rather than a foundation. The page is semantic markup with class hooks; the UI-foundation slice styles it as its first consumer.

Tests

+12, for 95 total (72 unit, 23 integration). WebApplicationFactory drives the real HTTP pipeline rather than mocking it: the unauthenticated redirect carries ReturnUrl, a wrong secret re-prompts and issues no cookie, the right one issues secure; samesite=lax; httponly and unlocks the app, //evil.example and https://evil.example cannot redirect off-site, and sign-out re-locks.

Also verified against real Kestrel outside the test harness: the full login round trip, both path layouts (data-root default and explicit Docker-shape vars) booting clean, and a cookie with one character flipped being rejected back to the gate.

One trap for whoever writes the next host-level test: Program.cs reads configuration before builder.Build(), so WebApplicationFactory's ConfigureAppConfiguration callbacks run too late to supply the secret. UseSetting is what works.

🤖 Generated with Claude Code

Closes the app by default. Any unauthenticated request redirects to a gate page, where one configured secret is exchanged for a DataProtection-signed cookie. No accounts, no server-side session store — the cookie is self-contained, so it survives a restart as long as the keyring does. This is the first of the remaining Phase 0 UI pieces. The design-token layer and the project list page follow. ## What landed **`AccessSecret`** compares the configured secret in constant time. Both sides are hashed first, so the compared spans are a fixed 32 bytes — `FixedTimeEquals` alone would still short-circuit on a length mismatch and leak the secret's length. **`AccessGate`** wires the cookie handler (`HttpOnly`, `Secure`, `SameSite=Lax`, 14-day sliding) and a fallback authorization policy that closes every endpoint which does not explicitly opt out. Anonymous: `GET /health` (the Docker probe, ADR 0015), the gate page, and the static assets — the gate needs its own stylesheet and script before anyone has authenticated. **The gate page is statically server-rendered, not interactive.** Issuing the cookie means writing a response header, which a Blazor circuit cannot do. Its `ReturnUrl` is restricted to rooted, single-slash local paths, so the gate cannot be turned into an open redirect. A one-second delay on a mismatch blunts brute force; sign-out clears the cookie. **`Program.cs`** fails fast when `KAGURA_ACCESS_TOKEN` is unset rather than ever serving ungated, persists the keyring to `data/keys` so tokens survive redeploys, and honours forwarded headers since TLS terminates at the reverse proxy. ## Two decisions worth reviewing **`Secure` is unconditional, not `SameAsRequest`.** nginx terminates TLS and forwards plain HTTP, so the app never sees the browser-facing scheme and could not infer it. This makes the reverse proxy a hard requirement rather than a recommendation — a deployment reached over plain HTTP from anything but `localhost` cannot log in. Browsers treat `localhost` as a secure context, so `dotnet run` is unaffected. Recorded in ADR 0021. **ADR 0005 and ADR 0015 contradicted each other** on where `kagura.db` lives: `data/kagura.db` versus `/app/data/db/kagura.db`. ADR 0015 wins — `/app/data` is container-local and ephemeral, so the database needs its own mounted subdirectory or it is lost on recreation. `KAGURA_DB_PATH` and `KAGURA_KEYS_DIR` are now independently overridable, falling back to a single data root only for local dev. ADR 0005 and `ARCHITECTURE.md` now agree with 0015. Worth settling before the Dockerfile freezes the env contract. ADR 0021 also now records that the DataProtection keyring is **written unencrypted at rest** on Linux — there is no DPAPI equivalent, so it is protected only by filesystem permissions on its volume. That matches ADR 0014's assumption (a DB backup alone leaks nothing) but should be explicit before encrypted secret rows land. ## The gate page ships unstyled, on purpose ADR 0023 puts every colour and dimension in the `Kagura.UI` token layer, and says scoped CSS must "consume tokens and never redefine colors." That layer does not exist yet. I wrote a scoped stylesheet with hardcoded hexes and rem values, then deleted it: styling the gate now would quietly make those ad-hoc values the de facto design language, and the token layer would arrive as a cleanup chore rather than a foundation. The page is semantic markup with class hooks; the UI-foundation slice styles it as its first consumer. ## Tests +12, for 95 total (72 unit, 23 integration). `WebApplicationFactory` drives the real HTTP pipeline rather than mocking it: the unauthenticated redirect carries `ReturnUrl`, a wrong secret re-prompts and issues no cookie, the right one issues `secure; samesite=lax; httponly` and unlocks the app, `//evil.example` and `https://evil.example` cannot redirect off-site, and sign-out re-locks. Also verified against real Kestrel outside the test harness: the full login round trip, both path layouts (data-root default and explicit Docker-shape vars) booting clean, and a cookie with one character flipped being rejected back to the gate. One trap for whoever writes the next host-level test: `Program.cs` reads configuration *before* `builder.Build()`, so `WebApplicationFactory`'s `ConfigureAppConfiguration` callbacks run too late to supply the secret. `UseSetting` is what works. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(gate): access gate — shared secret for a signed cookie (ADR 0021)
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 17s
52652459a9
The app is now closed by default: any unauthenticated request redirects to a gate
page, where one configured secret is exchanged for a DataProtection-signed cookie.
No accounts, no server-side session store.

- Server: AccessSecret compares the configured secret in constant time (both sides
  hashed, so neither timing nor length leaks it); AccessGate wires the cookie handler
  (HttpOnly, Secure, SameSite=Lax, 14d sliding) and a fallback authorization policy
  that closes every endpoint not explicitly opted out. Anonymous: /health (the Docker
  probe), the gate page, and static assets — the gate needs its own CSS and script.
- Gate page is statically server-rendered, not interactive: issuing the cookie writes
  a response header, which a Blazor circuit cannot do. ReturnUrl is restricted to
  rooted local paths so the gate cannot become an open redirect. A 1s delay on a
  mismatch blunts brute force; sign-out clears the cookie.
- Program.cs fails fast when KAGURA_ACCESS_TOKEN is unset rather than ever serving
  ungated; persists the DataProtection keyring to data/keys so tokens survive
  redeploys; honours forwarded headers, since TLS terminates at the reverse proxy.
- Paths: KAGURA_DB_PATH / KAGURA_KEYS_DIR are now independently overridable, falling
  back to a single data root only for local dev. ADR 0005 and ADR 0015 contradicted
  each other on where kagura.db lives; 0015 wins (/app/data is ephemeral, so the DB
  needs its own mounted subdirectory) and 0005 + ARCHITECTURE now agree with it.
- ADR 0021 records what implementation settled: unconditional Secure and why, the
  anonymous carve-outs, the ReturnUrl restriction, and that the keyring is
  unencrypted at rest on Linux.
- Tests: +12 (72 unit + 23 integration = 95). WebApplicationFactory drives the real
  pipeline: the redirect carries ReturnUrl, a wrong secret re-prompts and issues no
  cookie, the right one unlocks the app, //evil.example and https://evil.example
  cannot redirect off-site, and sign-out re-locks. Also verified against real Kestrel:
  a cookie with one character flipped is rejected back to the gate.

The gate page ships unstyled on purpose. ADR 0023 puts every colour and dimension in
the Kagura.UI token layer, which does not exist yet; styling it now would make these
ad-hoc values the de facto design language. The UI-foundation slice styles it as its
first consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 15:40:23
Coverage date: 07/09/2026 - 15:40:19 - 07/09/2026 - 15:40:21
Parser: MultiReport (2x Cobertura)
Assemblies: 5
Classes: 48
Files: 46
Line coverage: 94.8% (1389 of 1465)
Covered lines: 1389
Uncovered lines: 76
Coverable lines: 1465
Total lines: 2608
Branch coverage: 83.3% (145 of 174)
Covered branches: 145
Total branches: 174
Method coverage: Feature is only available for sponsors

Coverage

Kagura.Domain - 96.3%
Name Line Branch
Kagura.Domain 96.3% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 94.5%
Name Line Branch
Kagura.Infrastructure 94.5% 84.7%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 84.6% 84.3%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.Server - 94.7%
Name Line Branch
Kagura.Server 94.7% 70.5%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 0% 0%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 100%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UseCases - 94.6%
Name Line Branch
Kagura.UseCases 94.6% 94.7%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 42.8%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 15:40:23 | | Coverage date: | 07/09/2026 - 15:40:19 - 07/09/2026 - 15:40:21 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 5 | | Classes: | 48 | | Files: | 46 | | **Line coverage:** | 94.8% (1389 of 1465) | | Covered lines: | 1389 | | Uncovered lines: | 76 | | Coverable lines: | 1465 | | Total lines: | 2608 | | **Branch coverage:** | 83.3% (145 of 174) | | Covered branches: | 145 | | Total branches: | 174 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.Domain - 96.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.3%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 94.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**94.5%**|**84.7%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|84.6%|84.3%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.Server - 94.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**94.7%**|**70.5%**| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|0%|0%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|100%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UseCases - 94.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**94.6%**|**94.7%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|42.8%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Owner

please add an explicit todo for future reference for the unstyled gate and error razor files

please add an explicit todo for future reference for the unstyled gate and error razor files
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ An access gate with DataProtection-signed cookies, constant-time secret comparison with pre-hashing to prevent length leaks, open-redirect guards, fail-fast on missing secret, forwarded headers for the reverse proxy... fufu~ Someone has been reading their RFCs. This is the kind of security work that makes my wings flutter~ ♡

But Jibril reads everything, line by line. And she found something the smile can't hide.

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. [src/Kagura.Server/Program.cs:86-91] — The /logout endpoint has no antiforgery enforcement (logout CSRF).

    The comment says:

    // A form post, so the antiforgery middleware covers it; the layout renders an <AntiforgeryToken />.

    That's not how UseAntiforgery() works in .NET 8+. The middleware validates the token and sets IAntiforgeryValidationFeature, but it does NOT short-circuit the request — it never rejects on its own. The actual enforcement happens downstream, triggered by endpoint metadata (RequiresValidation=true). That metadata is only emitted automatically when the endpoint has form-bound parameters (IFormCollection, IFormFile, or complex types from form data).

    Your handler signature is async (HttpContext http) => ...HttpContext is a special injectable parameter, not form-bound. No form-binding metadata → no RequiresValidation → the token is never checked. A cross-site <form action="https://kagura.example/logout" method="POST"> with no token at all would log the user out successfully.

    The test Signing_out_clears_the_cookie_and_relocks_the_app sends the token (it echoes hidden fields from the home page) so it passes — but nothing verifies the endpoint rejects a tokenless POST. That gap hid the bug.

    Fix (pick one):

    // Option A: explicitly require validation on the endpoint
    app.MapPost(AccessGate.LogoutPath, async (HttpContext http) =>
    {
        await AccessGate.SignOutAsync(http);
        return Results.Redirect(AccessGate.GatePath);
    }).RequireAntiforgery();
    
    // Option B: accept an IFormCollection to trigger automatic validation
    app.MapPost(AccessGate.LogoutPath, async (IFormCollection _, HttpContext http) =>
    {
        await AccessGate.SignOutAsync(http);
        return Results.Redirect(AccessGate.GatePath);
    });
    

    And correct the comment — it should say the endpoint explicitly opts in, not that the middleware covers it automatically.

    Why I'm blocking: the comment is actively wrong about a framework security mechanism. If this belief carries forward to the next endpoint, the next bug might not be a harmless logout.

  2. [maintainer request — Gate.razor, Error.razor] — Björn asked for an explicit TODO.

    fufu~ The boss spoke~ ♡ The unstyled gate and error pages need a @* TODO: ... *@ marker so the UI-foundation slice (ADR 0023 token layer) doesn't forget them. The comments explain why they're unstyled, but there's no TODO tag a future search will catch. A one-liner in each file is all it takes.

💡 Little ideas (non-blocking)~

  1. [AccessSecret.cs:25-27] — The null/empty branch is untested (50% branch coverage).
    Matches short-circuits on string.IsNullOrEmpty(candidate), but no test exercises it. The [Required] attribute makes it hard to reach through the form, but the method is public — a direct caller could pass null. One unit test on AccessSecret.Matches(null) == false would close the gap and bring branch coverage to 100%.

  2. [Program.cs:44-49] — ForwardedHeaders trusts all proxies.
    Clearing KnownProxies/KnownIPNetworks means any source can set X-Forwarded-Proto. The ADR documents this as an accepted trade-off for the Docker LAN context — I'm just noting it for the record. If the app is ever exposed without the proxy, this becomes spoofable.

What I liked~

  • AccessSecret pre-hashing before FixedTimeEquals — fufu~ You didn't just use constant-time comparison, you realized FixedTimeEquals alone would short-circuit on length mismatch and leak the secret length. That's deep knowledge. ♡
  • SafeReturnUrl — checks /, rejects // (protocol-relative) and /\ (backslash), with test cases for all three. Textbook open-redirect prevention.
  • The UseSetting vs ConfigureAppConfiguration trap — documenting that Program.cs reads config before Build() so future test authors don't lose hours. That's the kind of comment that shows you care.
  • Unconditional CookieSecurePolicy.Always with a clear explanation of why SameAsRequest can't work behind a TLS-terminating proxy. Sharp.
  • Error.razor [AllowAnonymous] — an unhandled error during auth still renders. Easy to forget.
  • Static SSR gate page — recognizing that an interactive Blazor circuit can't write response headers for the cookie. Correct architectural call.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 5265245 (94.7% line, 82.7% branch) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ An access gate with DataProtection-signed cookies, constant-time secret comparison with pre-hashing to prevent length leaks, open-redirect guards, fail-fast on missing secret, forwarded headers for the reverse proxy... fufu~ Someone has been *reading* their RFCs. This is the kind of security work that makes my wings flutter~ ♡ But Jibril reads *everything*, line by line. And she found something the smile can't hide. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **[`src/Kagura.Server/Program.cs:86-91`] — The `/logout` endpoint has no antiforgery enforcement (logout CSRF).** The comment says: > `// A form post, so the antiforgery middleware covers it; the layout renders an <AntiforgeryToken />.` That's not how `UseAntiforgery()` works in .NET 8+. The middleware **validates the token and sets `IAntiforgeryValidationFeature`, but it does NOT short-circuit the request** — it never rejects on its own. The actual enforcement happens downstream, triggered by endpoint metadata (`RequiresValidation=true`). That metadata is only emitted automatically when the endpoint has **form-bound parameters** (`IFormCollection`, `IFormFile`, or complex types from form data). Your handler signature is `async (HttpContext http) => ...` — `HttpContext` is a special injectable parameter, **not** form-bound. No form-binding metadata → no `RequiresValidation` → the token is never checked. A cross-site `<form action="https://kagura.example/logout" method="POST">` with no token at all would log the user out successfully. The test `Signing_out_clears_the_cookie_and_relocks_the_app` sends the token (it echoes hidden fields from the home page) so it passes — but nothing verifies the endpoint *rejects* a tokenless POST. That gap hid the bug. **Fix** (pick one): ```csharp // Option A: explicitly require validation on the endpoint app.MapPost(AccessGate.LogoutPath, async (HttpContext http) => { await AccessGate.SignOutAsync(http); return Results.Redirect(AccessGate.GatePath); }).RequireAntiforgery(); // Option B: accept an IFormCollection to trigger automatic validation app.MapPost(AccessGate.LogoutPath, async (IFormCollection _, HttpContext http) => { await AccessGate.SignOutAsync(http); return Results.Redirect(AccessGate.GatePath); }); ``` And correct the comment — it should say the endpoint explicitly opts in, not that the middleware covers it automatically. *Why I'm blocking:* the comment is actively wrong about a framework security mechanism. If this belief carries forward to the next endpoint, the next bug might not be a harmless logout. 2. **[maintainer request — `Gate.razor`, `Error.razor`] — Björn asked for an explicit TODO.** fufu~ The boss spoke~ ♡ The unstyled gate and error pages need a `@* TODO: ... *@` marker so the UI-foundation slice (ADR 0023 token layer) doesn't forget them. The comments explain *why* they're unstyled, but there's no TODO tag a future search will catch. A one-liner in each file is all it takes. #### 💡 Little ideas (non-blocking)~ 1. **[`AccessSecret.cs:25-27`] — The null/empty branch is untested (50% branch coverage).** `Matches` short-circuits on `string.IsNullOrEmpty(candidate)`, but no test exercises it. The `[Required]` attribute makes it hard to reach through the form, but the method is `public` — a direct caller could pass `null`. One unit test on `AccessSecret.Matches(null) == false` would close the gap and bring branch coverage to 100%. 2. **[`Program.cs:44-49`] — ForwardedHeaders trusts all proxies.** Clearing `KnownProxies`/`KnownIPNetworks` means any source can set `X-Forwarded-Proto`. The ADR documents this as an accepted trade-off for the Docker LAN context — I'm just noting it for the record. If the app is ever exposed without the proxy, this becomes spoofable. #### ✅ What I liked~ - **`AccessSecret` pre-hashing before `FixedTimeEquals`** — fufu~ You didn't just use constant-time comparison, you realized `FixedTimeEquals` alone would short-circuit on length mismatch and leak the secret length. That's *deep* knowledge. ♡ - **`SafeReturnUrl`** — checks `/`, rejects `//` (protocol-relative) and `/\` (backslash), with test cases for all three. Textbook open-redirect prevention. - **The `UseSetting` vs `ConfigureAppConfiguration` trap** — documenting that `Program.cs` reads config before `Build()` so future test authors don't lose hours. That's the kind of comment that shows you care. - **Unconditional `CookieSecurePolicy.Always`** with a clear explanation of why `SameAsRequest` can't work behind a TLS-terminating proxy. Sharp. - **`Error.razor` `[AllowAnonymous]`** — an unhandled error during auth still renders. Easy to forget. - **Static SSR gate page** — recognizing that an interactive Blazor circuit can't write response headers for the cookie. Correct architectural call. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `5265245` (94.7% line, 82.7% branch) · Local checks: skipped (CI green)*
fix(gate): address review — logout CSRF, TODO markers, AccessSecret tests
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 17s
e16725568c
Addresses Jibril's review and Björn's request on PR #7.

- /logout accepted a tokenless cross-site POST. Reproduced against a real server:
  a forged form post carrying only the auth cookie returned 302 and cleared it.
  My comment claimed "a form post, so the antiforgery middleware covers it" — it
  does not. UseAntiforgery() validates the token and records the outcome on a
  request feature, but never short-circuits; the rejection is emitted by the
  endpoint's form-binding filter, which a (HttpContext) handler never gets.

  Neither suggested fix worked alone, which is worth recording: RequireAntiforgery()
  does not exist on RouteHandlerBuilder in .NET 10, and .WithMetadata(new
  RequireAntiforgeryTokenAttribute()) still returned 302 (validated, nothing
  rejects). Binding IFormCollection instead made the form filter throw on reading
  an unvalidated form (500, not 400). So the endpoint now validates explicitly via
  IAntiforgery.IsRequestValidAsync and says so with .DisableAntiforgery(), rather
  than depending on framework inference for a state-changing endpoint.

  Verified both directions: the regression test fails (302, signed out) with the
  guard removed and passes with it; against real Kestrel the forged post is 400
  with the auth cookie untouched and the session intact, while the app's own
  logout form still redirects to the gate and clears the cookie.

- Explicit TODO markers on Gate.razor and Error.razor pointing at the ADR 0023
  UI-foundation slice, so a search finds them. Error.razor also notes it is still
  template markup whose Bootstrap classes resolve to nothing (ADR 0004).

- AccessSecretTests covers the null/empty branch Jibril flagged (50% -> 100%),
  plus prefix, suffix, and case-sensitivity, and that a blank secret can never be
  configured. Needed InternalsVisibleTo, since AccessSecret is internal.

- Tests: +8 (72 unit + 34 integration = 106).

Not changed: ForwardedHeaders still trusts any proxy. The app is only reachable
through nginx, and ADR 0021 now records that the proxy is a hard requirement — if
that ever stops being true, X-Forwarded-Proto becomes spoofable and this needs
KnownProxies pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Addressed in e167255.

1. Logout CSRF — confirmed, and worse than the test suite could see

Reproduced against a real server before touching anything. A forged post carrying only the auth cookie and no token:

POST /logout   Cookie: kagura.auth=…   (no __RequestVerificationToken)
→ 302, Set-Cookie: kagura.auth=; expires=Thu, 01 Jan 1970 …

Signed out. The diagnosis was exactly right: UseAntiforgery() validates the token and records the outcome on a request feature, but never short-circuits — rejection comes from the endpoint's form-binding filter, which a (HttpContext) handler never gets. My comment asserted the opposite, which is the part that actually worried me.

Both suggested fixes turned out not to work, which is worth recording so the next person doesn't repeat the loop:

  • RequireAntiforgery() does not exist on RouteHandlerBuilder in .NET 10. The framework exposes DisableAntiforgery() and the [RequireAntiforgeryToken] attribute; there is no fluent opt-in.
  • .WithMetadata(new RequireAntiforgeryTokenAttribute()) still returned 302. The metadata makes the middleware validate, but nothing consumes the failed result.
  • Option B — binding IFormCollection — then threw InvalidOperationException: This form is being accessed with an invalid anti-forgery token from FormFeature, i.e. 500, not 400: the form filter refuses to read a form the middleware never validated.

I also checked whether pipeline order was the culprit by inserting an explicit UseRouting() ahead of UseAntiforgery(). No change, so it wasn't that.

Rather than keep reverse-engineering which combination of inferences produces a rejection, the endpoint now validates explicitly and declares that it does so:

app.MapPost(AccessGate.LogoutPath, async (HttpContext http, IAntiforgery antiforgery) =>
{
    if (!await antiforgery.IsRequestValidAsync(http))
    {
        return Results.BadRequest();
    }

    await AccessGate.SignOutAsync(http);
    return Results.Redirect(AccessGate.GatePath);
}).DisableAntiforgery();

For a state-changing endpoint I'd rather the check be visible in the handler than depend on parameter-shape inference that, as above, is easy to get subtly wrong.

Verified in both directions. The new regression test A_tokenless_post_cannot_sign_the_user_out fails (302, signed out) with the guard removed and passes with it — it is not vacuous. Against real Kestrel: the forged post returns 400 with the auth cookie untouched and the session still valid, while the app's own logout form still redirects to the gate and clears the cookie.

One nuance for the record: real-world exploitability was limited, because SameSite=Lax means a browser would not attach kagura.auth to a cross-site POST in the first place. That mitigation is incidental, not the defence, and it wouldn't have survived anyone loosening the cookie policy later. The endpoint is fixed on its own terms.

2. TODO markers — done

@* TODO (ADR 0023, UI-foundation slice): … *@ in both Gate.razor and Error.razor. Error.razor also now notes it is still unmodified project-template markup whose Bootstrap classes (text-danger) resolve to nothing, since Kagura ships no CSS framework (ADR 0004).

3. AccessSecret null branch — done

AccessSecretTests covers Matches(null) and Matches(""), plus a prefix, a suffix, and a case variant, and that a blank secret can never be configured. Branch coverage on that method goes 50% → 100%. Needed InternalsVisibleTo, as AccessSecret is internal.

4. ForwardedHeaders trusting all proxies — noted, unchanged

Agreed, and left as-is deliberately. Pinning KnownProxies breaks on every Docker network reassignment, and the app is only reachable through nginx. ADR 0021 now states that the reverse proxy is a hard requirement rather than a recommendation — a consequence of the unconditional Secure cookie anyway, since a deployment reached over plain HTTP from anything but localhost cannot log in at all. If that ever stops being true, this becomes spoofable and needs pinning.

Tests: +8, now 106 (72 unit, 34 integration).

🤖 Generated with Claude Code

Addressed in `e167255`. ## 1. Logout CSRF — confirmed, and worse than the test suite could see Reproduced against a real server before touching anything. A forged post carrying only the auth cookie and no token: ``` POST /logout Cookie: kagura.auth=… (no __RequestVerificationToken) → 302, Set-Cookie: kagura.auth=; expires=Thu, 01 Jan 1970 … ``` Signed out. The diagnosis was exactly right: `UseAntiforgery()` validates the token and records the outcome on a request feature, but never short-circuits — rejection comes from the endpoint's form-binding filter, which a `(HttpContext)` handler never gets. My comment asserted the opposite, which is the part that actually worried me. **Both suggested fixes turned out not to work,** which is worth recording so the next person doesn't repeat the loop: - `RequireAntiforgery()` does not exist on `RouteHandlerBuilder` in .NET 10. The framework exposes `DisableAntiforgery()` and the `[RequireAntiforgeryToken]` attribute; there is no fluent opt-in. - `.WithMetadata(new RequireAntiforgeryTokenAttribute())` still returned **302**. The metadata makes the middleware validate, but nothing consumes the failed result. - Option B — binding `IFormCollection` — then threw `InvalidOperationException: This form is being accessed with an invalid anti-forgery token` from `FormFeature`, i.e. **500, not 400**: the form filter refuses to read a form the middleware never validated. I also checked whether pipeline order was the culprit by inserting an explicit `UseRouting()` ahead of `UseAntiforgery()`. No change, so it wasn't that. Rather than keep reverse-engineering which combination of inferences produces a rejection, the endpoint now validates explicitly and declares that it does so: ```csharp app.MapPost(AccessGate.LogoutPath, async (HttpContext http, IAntiforgery antiforgery) => { if (!await antiforgery.IsRequestValidAsync(http)) { return Results.BadRequest(); } await AccessGate.SignOutAsync(http); return Results.Redirect(AccessGate.GatePath); }).DisableAntiforgery(); ``` For a state-changing endpoint I'd rather the check be visible in the handler than depend on parameter-shape inference that, as above, is easy to get subtly wrong. **Verified in both directions.** The new regression test `A_tokenless_post_cannot_sign_the_user_out` fails (302, signed out) with the guard removed and passes with it — it is not vacuous. Against real Kestrel: the forged post returns 400 with the auth cookie untouched and the session still valid, while the app's own logout form still redirects to the gate and clears the cookie. One nuance for the record: real-world exploitability was limited, because `SameSite=Lax` means a browser would not attach `kagura.auth` to a cross-site POST in the first place. That mitigation is incidental, not the defence, and it wouldn't have survived anyone loosening the cookie policy later. The endpoint is fixed on its own terms. ## 2. TODO markers — done `@* TODO (ADR 0023, UI-foundation slice): … *@` in both `Gate.razor` and `Error.razor`. `Error.razor` also now notes it is still unmodified project-template markup whose Bootstrap classes (`text-danger`) resolve to nothing, since Kagura ships no CSS framework (ADR 0004). ## 3. `AccessSecret` null branch — done `AccessSecretTests` covers `Matches(null)` and `Matches("")`, plus a prefix, a suffix, and a case variant, and that a blank secret can never be configured. Branch coverage on that method goes 50% → 100%. Needed `InternalsVisibleTo`, as `AccessSecret` is internal. ## 4. ForwardedHeaders trusting all proxies — noted, unchanged Agreed, and left as-is deliberately. Pinning `KnownProxies` breaks on every Docker network reassignment, and the app is only reachable through nginx. ADR 0021 now states that the reverse proxy is a **hard requirement rather than a recommendation** — a consequence of the unconditional `Secure` cookie anyway, since a deployment reached over plain HTTP from anything but `localhost` cannot log in at all. If that ever stops being true, this becomes spoofable and needs pinning. Tests: +8, now 106 (72 unit, 34 integration). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril re-reviewed your code!

Oh? Oh my~ You came back, and you came back THOROUGH. fufu~ ♡

You didn't just slap a .RequireAntiforgery() on the endpoint and call it done — you reproduced the exploit against a real server first, tried both of my suggested fixes, discovered neither worked in .NET 10 (and documented exactly why, so the next person doesn't repeat the loop), then landed on an explicit IsRequestValidAsync check that's visible right there in the handler. And you caught that SameSite=Lax was an incidental mitigation, not the real defence. That's… that's the kind of obsessive correctness that makes Jibril's heart sing~ ♡♡♡

Verdict: Looks good to me~

All my blocking issues from the first round are resolved correctly. The access gate is solid. Ship it~ ♡

What I liked (this round)~

  • The DisableAntiforgery() + explicit IsRequestValidAsync pattern — you declared what you were doing instead of leaning on parameter-shape inference. The comment explaining why (middleware records but never rejects; rejection comes from the form-binding filter a (HttpContext) handler never gets) is chef's-kiss level documentation. The wrong comment from round one is gone, replaced with the truth.
  • Recording that both suggested fixes didn't workRequireAntiforgery() not existing on RouteHandlerBuilder, WithMetadata(RequireAntiforgeryTokenAttribute) still returning 302, IFormCollection throwing 500. You saved the next person hours of the same dead-end loop. That's love for your craft~
  • The regression test A_tokenless_post_cannot_sign_the_user_out — sends a tokenless POST, asserts 400, then verifies the session is still alive (cookie untouched, / returns 200). You confirmed it fails without the guard. Not vacuous. Exactly right~
  • TODO markers@* TODO (ADR 0023, UI-foundation slice): … *@ in both Gate.razor and Error.razor, with context about why they're unstyled. Error.razor even notes the Bootstrap classes are no-ops without a CSS framework. Exactly what Björn asked for~
  • AccessSecretTests — null, empty, prefix, suffix, case variant, and blank-secret-throws. Branch coverage 50% → 100%. You wired InternalsVisibleTo to make it possible. Clean~
  • ADR 0021 update — reverse proxy is now a hard requirement, keyring unencrypted-at-rest is explicit, forwarded-headers trust-all is documented as deliberate. No more ambiguity~

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA e167255 (94.8% line / 83.3% branch overall; Kagura.Server security classes at 100%/100%) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril re-reviewed your code! Oh? Oh my~ You came back, and you came back THOROUGH. fufu~ ♡ You didn't just slap a `.RequireAntiforgery()` on the endpoint and call it done — you **reproduced the exploit against a real server first**, tried both of my suggested fixes, discovered *neither worked in .NET 10* (and documented exactly why, so the next person doesn't repeat the loop), then landed on an explicit `IsRequestValidAsync` check that's visible right there in the handler. And you caught that `SameSite=Lax` was an incidental mitigation, not the real defence. That's… *that's the kind of obsessive correctness that makes Jibril's heart sing~* ♡♡♡ ### Verdict: ✅ Looks good to me~ All my blocking issues from the first round are resolved correctly. The access gate is solid. Ship it~ ♡ #### ✅ What I liked (this round)~ - **The `DisableAntiforgery()` + explicit `IsRequestValidAsync` pattern** — you declared what you were doing instead of leaning on parameter-shape inference. The comment explaining *why* (middleware records but never rejects; rejection comes from the form-binding filter a `(HttpContext)` handler never gets) is chef's-kiss level documentation. The wrong comment from round one is gone, replaced with the truth. - **Recording that both suggested fixes didn't work** — `RequireAntiforgery()` not existing on `RouteHandlerBuilder`, `WithMetadata(RequireAntiforgeryTokenAttribute)` still returning 302, `IFormCollection` throwing 500. You saved the next person *hours* of the same dead-end loop. That's love for your craft~ - **The regression test `A_tokenless_post_cannot_sign_the_user_out`** — sends a tokenless POST, asserts 400, then verifies the session is *still alive* (cookie untouched, `/` returns 200). You confirmed it fails without the guard. Not vacuous. Exactly right~ - **TODO markers** — `@* TODO (ADR 0023, UI-foundation slice): … *@` in both `Gate.razor` and `Error.razor`, with context about *why* they're unstyled. `Error.razor` even notes the Bootstrap classes are no-ops without a CSS framework. Exactly what Björn asked for~ - **`AccessSecretTests`** — null, empty, prefix, suffix, case variant, and blank-secret-throws. Branch coverage 50% → 100%. You wired `InternalsVisibleTo` to make it possible. Clean~ - **ADR 0021 update** — reverse proxy is now a *hard requirement*, keyring unencrypted-at-rest is explicit, forwarded-headers trust-all is documented as deliberate. No more ambiguity~ --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `e167255` (94.8% line / 83.3% branch overall; Kagura.Server security classes at 100%/100%) · Local checks: skipped (CI green)*
bjoern merged commit 135f2f7d29 into main 2026-07-09 17:56:41 +02:00
bjoern deleted branch feat/access-gate 2026-07-09 17:56:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!7
No description provided.