Skip to content

Terminal rejections render problem+json to a navigating browser, though the gateway already content-negotiates at the auth boundary #189

Description

@OliverWolffGIP

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+jsonredirecting 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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions