fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
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>
This commit is contained in:
@@ -142,8 +142,21 @@ app.Use(async (ctx, next) =>
|
|||||||
await next(ctx);
|
await next(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
|
||||||
|
// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it
|
||||||
|
// out") let a caller fire requests straight from the browser. Development-only, like the
|
||||||
|
// dev-role/scenario-toggle hatches this POC already keeps out of production builds
|
||||||
|
// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs
|
||||||
|
// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI
|
||||||
|
// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it
|
||||||
|
// never sends an HTTP request through this pipeline, so it never touches this middleware at
|
||||||
|
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's
|
||||||
|
// note that this exact file has already broken that tool once.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
app.UseCors(SpaCors);
|
app.UseCors(SpaCors);
|
||||||
|
|
||||||
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
|
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Net;
|
||||||
|
using BigRegister.Domain.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// RB-15/BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the
|
||||||
|
/// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were
|
||||||
|
/// reachable in every environment, including a real deployment. Both are now gated behind
|
||||||
|
/// `app.Environment.IsDevelopment()`.
|
||||||
|
public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Swagger_document_is_served_in_development()
|
||||||
|
{
|
||||||
|
// The default test environment (WebApplicationFactory<T> defaults to "Development" when
|
||||||
|
// nothing overrides it — same fact RB-09's implementation note relies on) — this is the
|
||||||
|
// regression guard that the gate didn't also break the documented `npm run gen:api` /
|
||||||
|
// local-dev-Swagger-UI experience.
|
||||||
|
var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which
|
||||||
|
/// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain
|
||||||
|
/// `UseEnvironment("Production")` host never reaches this middleware to prove the gate
|
||||||
|
/// itself works, only that the whole app refuses to start. This uses a third environment
|
||||||
|
/// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` —
|
||||||
|
/// the one thing Program.cs doesn't register outside those two branches — so the host
|
||||||
|
/// actually boots and this test exercises the real gate, not RB-09's unrelated startup throw.
|
||||||
|
[Fact]
|
||||||
|
public async Task Swagger_document_is_not_served_outside_development()
|
||||||
|
{
|
||||||
|
// Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new
|
||||||
|
// WebApplicationFactory<Program>()` — that keeps this host on the fixture's own per-class
|
||||||
|
// isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's
|
||||||
|
// implementation note records the "table already exists" collision a bare factory hits
|
||||||
|
// by sharing the mutable static Db.ConnectionString instead).
|
||||||
|
using var staging = factory.WithWebHostBuilder(builder => builder
|
||||||
|
.UseEnvironment("Staging")
|
||||||
|
.ConfigureTestServices(services => services.AddSingleton<IIdentityProvider, StubIdentityProvider>()));
|
||||||
|
|
||||||
|
var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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()` and `UseSwaggerUI()` 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`/`AddEndpointsApiExplorer` were left unconditional.** They're
|
||||||
|
DI-registration-time calls with no HTTP surface, and — now confirmed rather than
|
||||||
|
assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever
|
||||||
|
environment it runs the host under (pinned to Development by `gen:api`'s own script,
|
||||||
|
but nothing stops a future non-Development invocation), so conditioning those
|
||||||
|
registrations on `IsDevelopment()` 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 real `IIdentityProvider` exists yet) — a stronger guarantee than "no
|
||||||
|
Swagger in Production," but one that means a `UseEnvironment("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 neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no
|
||||||
|
`IIdentityProvider` for it — the test supplies one via `ConfigureTestServices`
|
||||||
|
(`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
|
||||||
|
shared `TestWebApplicationFactory` fixture), not a bare `new
|
||||||
|
WebApplicationFactory<Program>()` — RB-12's implementation note already records the
|
||||||
|
"table already exists" collision a bare factory hits by sharing the mutable static
|
||||||
|
`Db.ConnectionString` instead 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 ran `SwaggerGateTests`:
|
||||||
|
`Swagger_document_is_not_served_outside_development` failed 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.json` and
|
||||||
|
`libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean 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).
|
||||||
Reference in New Issue
Block a user