BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.
Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.
RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.
New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.1 KiB
RB-15 — Swagger and the OpenAPI document behind IsDevelopment()
Status: implemented · 2026-08-27 · Source findings: 07-bio2-compliance.md BIO-015 · 99-backlog.md RB-15
What was wrong
Program.cs:145-146 (pre-change) ran app.UseSwagger(); app.UseSwaggerUI();
unconditionally — the full OpenAPI document (every route, every request/response shape)
and SwaggerUI's interactive "Try it out" were reachable in every environment, including a
real deployment, with no app.Environment.IsDevelopment() guard. BIO-015's own evidence
notes this is one line of genuine attack-surface reduction with no POC cost.
What changed
| File | Change |
|---|---|
Program.cs |
app.UseSwagger(); app.UseSwaggerUI(); now run only inside if (app.Environment.IsDevelopment()) { … } |
tests/BigRegister.Tests/SwaggerGateTests.cs |
new — asserts /swagger/v1/swagger.json is served in Development and 404s outside it |
builder.Services.AddSwaggerGen(...) and AddEndpointsApiExplorer() were left
unconditional — they only register DI services (the swagger-generation machinery),
expose nothing over HTTP by themselves, and (see below) are exactly what npm run gen:api depends on staying registered in every environment it might run against.
The hazard, checked rather than assumed
RB-09 made a non-Development environment throw during builder.Build() (no
IIdentityProvider registered for a bare/unset environment, which defaults to
Production), which crashed dotnet swagger tofile until package.json's gen:api
script was pinned to ASPNETCORE_ENVIRONMENT=Development for that one invocation
(docs/.../implementation/rb-09.md). This ticket's change sits in exactly the same
pipeline, so it needed the same empirical check, not an assumption.
Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:
dotnet swagger tofile (Swashbuckle.AspNetCore.Cli) loads the built DLL through
.NET's design-time HostFactoryResolver, builds the host, and then resolves
ISwaggerProvider directly out of the DI container to produce swagger.json — it
never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's
if (app.Environment.IsDevelopment()) guard lives in. Gating UseSwagger()/
UseSwaggerUI() therefore cannot affect it, in any environment, by construction — those
are pipeline middleware; the CLI tool bypasses the pipeline entirely.
Verified, not assumed: ran npm run gen:api for real. It exited 0, printed "Swagger
JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag
client. git status/git diff on both backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts showed zero changes — the regenerated
files are byte-identical to what's already committed, confirming the gate has no effect
on the generated contract at all.
Judgement calls
- The guard wraps both
UseSwagger()andUseSwaggerUI()together, not just one — the ticket's own wording lists both, and gating only the document while leaving the UI reachable (or vice versa) would be a strange half-measure: SwaggerUI without the document 404s on load anyway, and the document without the UI still leaks the same route/shape enumeration BIO-015 is about. AddSwaggerGen/AddEndpointsApiExplorerwere left unconditional. They're DI-registration-time calls with no HTTP surface, and — now confirmed rather than assumed —dotnet swagger tofileneedsISwaggerProviderregistered in whatever environment it runs the host under (pinned to Development bygen:api's own script, but nothing stops a future non-Development invocation), so conditioning those registrations onIsDevelopment()would risk breaking the CLI tool for no attack-surface benefit — nobody can reach a DI-registered-but-never-routed service over HTTP.- New tests build the "non-Development" case on a third environment name
("Staging"), not
"Production". RB-09 already made Production fail at startup entirely (no realIIdentityProviderexists yet) — a stronger guarantee than "no Swagger in Production," but one that means aUseEnvironment("Production")host never reaches this middleware to prove the gate itself works; it only proves RB-09's unrelated startup throw, which already has its own test. A"Staging"environment satisfies neitherIsDevelopment()norIsProduction(), soProgram.csregisters noIIdentityProviderfor it — the test supplies one viaConfigureTestServices(StubIdentityProvider, the same one Development uses) so the host actually boots, and the test exercises this ticket's real gate rather than a different ticket's. - The Staging host is built via
factory.WithWebHostBuilder(...)(layering on the sharedTestWebApplicationFactoryfixture), not a barenew WebApplicationFactory<Program>()— RB-12's implementation note already records the "table already exists" collision a bare factory hits by sharing the mutable staticDb.ConnectionStringinstead of the fixture's own per-class isolated temp path; layering avoids repeating that mistake here.
Verification
- Reverted the guard only (unwrapped
UseSwagger()/UseSwaggerUI()back to unconditional, via Edit, tests left in place) and ranSwaggerGateTests:Swagger_document_is_not_served_outside_developmentfailed red (Expected: NotFound, Actual: OK). Restored the fix (via Edit) and re-ran: both green. npm run gen:api, run for real: exit 0;backend/swagger.jsonandlibs/shared/src/infrastructure/api-client.tsboth unchanged (git statusclean on both) — see "The hazard" above.dotnet build(both projects): clean, 0 warnings.dotnet format BigRegister.slnx --verify-no-changes: clean.dotnet test --filter "Category!=Integration": 259 passed, 0 failed (257 + 2 new).