What happened
A browser was pointed at a gateway under a hostname that is not the host of oidc.redirect_uri. The reserved-path registry did not match, the request fell through to the routing layer, matched nothing, and the viewport rendered:
{"type":"urn:api-sheriff:problem:input-validation","title":"Input Validation","status":404}
That is a correct response and a useless one. It was a top-level navigation — no JavaScript was running, no client was there to branch on the status. The person reading it had no way to learn that a host binding was the cause, and the word "Input Validation" actively pointed away from it.
The gateway already draws this distinction — just not everywhere
Measured against a running gateway, three behaviours exist today:
| Case |
Accept: text/html |
Accept: application/json |
Auth failure on a require: session route |
302 into the auth-code flow |
401 problem+json |
/auth/userinfo without a session |
401 problem+json |
401 problem+json |
| No route matched |
404 problem+json |
404 problem+json |
Row 1 is exactly the behaviour being asked for, already implemented in SessionAuthenticationStage.challengeUnauthenticated (acceptsHtml, line 181). The reference states the reasoning outright:
An unauthenticated navigation request (one that accepts text/html, i.e. a browser page load) is redirected into the auth-code flow; any other unauthenticated request (XHR/fetch, API clients) receives 401 application/problem+json — redirecting a JSON call is useless to its caller.
— doc/configuration.adoc:1397
The converse of that sentence is equally true and is not acted on: rendering a JSON document is useless to a navigating browser.
Row 3 is the gap. GatewayEdgeRoute.renderProblem is Accept-blind at every call site — NO_ROUTE_MATCHED (line 775), METHOD_NOT_ALLOWED (line 694), RESERVED_BODY_TOO_LARGE (line 566), and the generic path (line 750).
Row 2 must not change, and this issue does not propose changing it. /auth/userinfo is Accept-blind by design; demo-client/doc/playwright-suite.adoc:324 records a PROHIBITED ASSERTION against ever testing it otherwise, and the demo client encodes it as a runtime assertion with redirect: 'error'. Any implementation of this issue has to leave the reserved endpoints alone, and the prohibition stays as written.
What NOT to do: redirect
The obvious suggestion — send the browser to a landing page, perhaps with an error parameter — should be rejected, for three reasons in descending weight:
- A
302 on a 404 destroys the status code. Monitoring, uptime checks and API clients would see success-shaped traffic where a rejection occurred. The status is the contract; a gateway is the last component that should blur it.
- The gateway has no landing page to redirect to.
oidc.logout.final_redirect exists only inside the oidc block. A pure-proxy deployment (Variant 1) has no such target at all, so the behaviour would be undefined for the majority configuration.
- An error message carried as a URL parameter turns the landing page into a reflection sink. That is precisely the lever the product refuses elsewhere: the built-in extension map is immutable because remapping one entry "is a stored-XSS lever on a security gateway" (
doc/configuration.adoc:251).
What to do
When the request offers text/html, render the same status as a static HTML document instead of problem+json. Same status code, same category, no redirect.
The constraint that must survive is already written down in the threat model:
Every gateway-generated rejection is application/problem+json naming the category only, no internal detail. […] Assertion. No error response or log line contains a resolved secret value or a stack trace/internal detail; problem+json bodies carry category only.
— doc/security-threat-model.adoc:705, :716 (control gw-12)
So the HTML body must be static per category — no request path, no query string, no header value, no hostname echoed into it. The current problem document is exemplary in this respect: it carries type, title and status and nothing else. The HTML variant must be no more revealing, which also makes it trivially injection-free: there is no interpolation site.
There is precedent for a caller-shaped carve-out in the same section. gRPC rejections are already not problem+json — they are trailers-only, because "a gRPC client cannot consume an application/problem+json body" (doc/architecture.adoc:_grpc_error_contract). The reasoning generalises exactly: a browser viewport cannot consume one either. This issue asks for the second instance of a rule the codebase has already accepted once.
Open design questions
- Is the HTML page configurable? Recommendation: no, or at most an operator-supplied static file path per status. A templated page invites the interpolation this whole section is trying to avoid.
- Does it apply to every terminal error, or only to those a navigation can realistically reach?
404 and 405 are reachable by navigation; 413 on a reserved POST path is not. Scoping it to the edge's own rejections keeps the surface small.
Accept: */* (curl's default) must keep receiving problem+json. Only an explicit text/html offer should switch, matching acceptsHtml's existing behaviour.
Second, separable defect: the category is wrong
Filed separately as #188, cross-linked here because it was found through the same report and shares the documentation surface.
NO_ROUTE_MATCHED, METHOD_NOT_ALLOWED and PASSTHROUGH_HOST_SMUGGLED all sit in EventCategory.INPUT_VALIDATION (EventType.java:62, :71, :69), whose javadoc reads "Path / parameter / header pipeline, collection limits, or body-size violations." None of the three is any of those. A routing miss is not an invalid input — nothing about the request was malformed, it simply addressed nothing. The rendered title: "Input Validation" sends a reader looking for a validation failure that never occurred, which is what happened in the report above.
Documentation
Both statements below are normative and currently assert the absolute that this change breaks:
doc/architecture.adoc:707 — "Every gateway-generated rejection is an application/problem+json (RFC 9457) response", opening === Error Contract. The status table at :713–:719 needs a rendering column or a note; the 404 and 405 rows are the affected ones. The gRPC carve-out immediately below (:723 ff.) is the model to follow — a subsection stating the second exception and why.
doc/security-threat-model.adoc:705 — the same absolute, in the control for gw-12. The assertion at :716 ("problem+json bodies carry category only") is the part that must be restated to cover both renderings rather than weakened.
doc/configuration.adoc:1397 — states the negotiation principle for require: session. Worth a forward reference so a reader learns the principle is general rather than an auth-only quirk.
doc/plan/04-request-pipeline.adoc:233 — "Every rejection path returns application/problem+json".
doc/variants/01-base-gateway.adoc:160 — "renders application/problem+json", in the variant that has no landing page and therefore benefits most.
doc/README.adoc:253 — the error-contract summary line.
Demo client — make every variant visible
The demo is where an integrator learns which shape they will get, and it currently demonstrates only one of the three rows. Making all of them observable is the part of this issue that has the most practical value, because the distinction is invisible until someone hits it in production.
Proposed: a panel that fires each variant deliberately and reports what came back — status, Content-Type, and whether a redirect occurred.
| Probe |
Expected today |
Expected after |
fetch /auth/userinfo, no session |
401 problem+json |
unchanged — and labelled deliberately Accept-blind |
fetch a require: session route, no session |
401 problem+json |
unchanged |
navigate a require: session route, no session |
302 into login |
unchanged |
| navigate an unrouted path |
404 problem+json |
404 text/html |
fetch an unrouted path |
404 problem+json |
unchanged |
The last two rows are the pair that teaches the rule, and they are cheap to demonstrate: the same URL, fetched and navigated.
Files:
demo-client/src/main/resources/spa/index.html — a new panel; the existing "Last response" panel already renders status / Content-Type / Cache-Control and can be reused verbatim.
demo-client/src/main/resources/spa/app.js — the probes. Note the existing three rules in the file header (:9–:19) are stated as properties of the gateway; a fourth belongs there once this lands.
demo-client/doc/integration-sample.adoc:29 — the browser-facing contract table. This is the authoritative integrator surface and should carry a row per rendering, not just per status.
demo-client/doc/playwright-suite.adoc:324 — the PROHIBITED ASSERTION. It stays, but its scope should be stated as the reserved endpoints explicitly, so a reader does not generalise it into "the gateway never negotiates" — which the require: session path already contradicts and this change would contradict further. The line at :329 ("determined by which path it called rather than by content negotiation") is the one to tighten.
A downstream deployment worked around the underlying confusion client-side: its demo treats a 404 from the info endpoint as a distinct diagnosis, because neither a live nor an absent session can produce that status — it can only mean the page is loaded under a host the reserved paths are not bound to. That is a reasonable division of labour (the client knows its own hostname; the gateway only knows that nothing matched) and might be worth folding into the sample as a documented pattern. It is not a substitute for the navigation case, where no client is running at all.
What happened
A browser was pointed at a gateway under a hostname that is not the host of
oidc.redirect_uri. The reserved-path registry did not match, the request fell through to the routing layer, matched nothing, and the viewport rendered:{"type":"urn:api-sheriff:problem:input-validation","title":"Input Validation","status":404}That is a correct response and a useless one. It was a top-level navigation — no JavaScript was running, no client was there to branch on the status. The person reading it had no way to learn that a host binding was the cause, and the word "Input Validation" actively pointed away from it.
The gateway already draws this distinction — just not everywhere
Measured against a running gateway, three behaviours exist today:
Accept: text/htmlAccept: application/jsonrequire: sessionroute401 problem+json/auth/userinfowithout a session401 problem+json401 problem+json404 problem+json404 problem+jsonRow 1 is exactly the behaviour being asked for, already implemented in
SessionAuthenticationStage.challengeUnauthenticated(acceptsHtml, line 181). The reference states the reasoning outright:The converse of that sentence is equally true and is not acted on: rendering a JSON document is useless to a navigating browser.
Row 3 is the gap.
GatewayEdgeRoute.renderProblemisAccept-blind at every call site —NO_ROUTE_MATCHED(line 775),METHOD_NOT_ALLOWED(line 694),RESERVED_BODY_TOO_LARGE(line 566), and the generic path (line 750).Row 2 must not change, and this issue does not propose changing it.
/auth/userinfoisAccept-blind by design;demo-client/doc/playwright-suite.adoc:324records a PROHIBITED ASSERTION against ever testing it otherwise, and the demo client encodes it as a runtime assertion withredirect: 'error'. Any implementation of this issue has to leave the reserved endpoints alone, and the prohibition stays as written.What NOT to do: redirect
The obvious suggestion — send the browser to a landing page, perhaps with an error parameter — should be rejected, for three reasons in descending weight:
302on a404destroys the status code. Monitoring, uptime checks and API clients would see success-shaped traffic where a rejection occurred. The status is the contract; a gateway is the last component that should blur it.oidc.logout.final_redirectexists only inside theoidcblock. A pure-proxy deployment (Variant 1) has no such target at all, so the behaviour would be undefined for the majority configuration.doc/configuration.adoc:251).What to do
When the request offers
text/html, render the same status as a static HTML document instead of problem+json. Same status code, same category, no redirect.The constraint that must survive is already written down in the threat model:
So the HTML body must be static per category — no request path, no query string, no header value, no hostname echoed into it. The current problem document is exemplary in this respect: it carries
type,titleandstatusand nothing else. The HTML variant must be no more revealing, which also makes it trivially injection-free: there is no interpolation site.There is precedent for a caller-shaped carve-out in the same section. gRPC rejections are already not problem+json — they are trailers-only, because "a gRPC client cannot consume an
application/problem+jsonbody" (doc/architecture.adoc:_grpc_error_contract). The reasoning generalises exactly: a browser viewport cannot consume one either. This issue asks for the second instance of a rule the codebase has already accepted once.Open design questions
404and405are reachable by navigation;413on a reserved POST path is not. Scoping it to the edge's own rejections keeps the surface small.Accept: */*(curl's default) must keep receiving problem+json. Only an explicittext/htmloffer should switch, matchingacceptsHtml's existing behaviour.Second, separable defect: the category is wrong
Filed separately as #188, cross-linked here because it was found through the same report and shares the documentation surface.
NO_ROUTE_MATCHED,METHOD_NOT_ALLOWEDandPASSTHROUGH_HOST_SMUGGLEDall sit inEventCategory.INPUT_VALIDATION(EventType.java:62,:71,:69), whose javadoc reads "Path / parameter / header pipeline, collection limits, or body-size violations." None of the three is any of those. A routing miss is not an invalid input — nothing about the request was malformed, it simply addressed nothing. The renderedtitle: "Input Validation"sends a reader looking for a validation failure that never occurred, which is what happened in the report above.Documentation
Both statements below are normative and currently assert the absolute that this change breaks:
doc/architecture.adoc:707— "Every gateway-generated rejection is anapplication/problem+json(RFC 9457) response", opening=== Error Contract. The status table at:713–:719needs a rendering column or a note; the404and405rows are the affected ones. The gRPC carve-out immediately below (:723ff.) is the model to follow — a subsection stating the second exception and why.doc/security-threat-model.adoc:705— the same absolute, in the control forgw-12. The assertion at:716("problem+json bodies carry category only") is the part that must be restated to cover both renderings rather than weakened.doc/configuration.adoc:1397— states the negotiation principle forrequire: session. Worth a forward reference so a reader learns the principle is general rather than an auth-only quirk.doc/plan/04-request-pipeline.adoc:233— "Every rejection path returnsapplication/problem+json".doc/variants/01-base-gateway.adoc:160— "renders application/problem+json", in the variant that has no landing page and therefore benefits most.doc/README.adoc:253— the error-contract summary line.Demo client — make every variant visible
The demo is where an integrator learns which shape they will get, and it currently demonstrates only one of the three rows. Making all of them observable is the part of this issue that has the most practical value, because the distinction is invisible until someone hits it in production.
Proposed: a panel that fires each variant deliberately and reports what came back — status,
Content-Type, and whether a redirect occurred.fetch /auth/userinfo, no session401 problem+jsonAccept-blindfetcharequire: sessionroute, no session401 problem+jsonrequire: sessionroute, no session302into login404 problem+json404 text/htmlfetchan unrouted path404 problem+jsonThe last two rows are the pair that teaches the rule, and they are cheap to demonstrate: the same URL, fetched and navigated.
Files:
demo-client/src/main/resources/spa/index.html— a new panel; the existing "Last response" panel already renders status /Content-Type/Cache-Controland can be reused verbatim.demo-client/src/main/resources/spa/app.js— the probes. Note the existing three rules in the file header (:9–:19) are stated as properties of the gateway; a fourth belongs there once this lands.demo-client/doc/integration-sample.adoc:29— the browser-facing contract table. This is the authoritative integrator surface and should carry a row per rendering, not just per status.demo-client/doc/playwright-suite.adoc:324— the PROHIBITED ASSERTION. It stays, but its scope should be stated as the reserved endpoints explicitly, so a reader does not generalise it into "the gateway never negotiates" — which therequire: sessionpath already contradicts and this change would contradict further. The line at:329("determined by which path it called rather than by content negotiation") is the one to tighten.A downstream deployment worked around the underlying confusion client-side: its demo treats a
404from the info endpoint as a distinct diagnosis, because neither a live nor an absent session can produce that status — it can only mean the page is loaded under a host the reserved paths are not bound to. That is a reasonable division of labour (the client knows its own hostname; the gateway only knows that nothing matched) and might be worth folding into the sample as a documented pattern. It is not a substitute for the navigation case, where no client is running at all.