ADR-038: Hide forbidden resources as 404 (object-level authorization)
- Status: Accepted
- Date: 2026-06-04
- Depends on: ADR-001 (Global Architecture), ADR-028 (Ollama integration — unrelated, referenced only for the audit context)
- Origin: Sprint 35.2 audit (finding
IDOR-DETAILfixed in #616; follow-up findingENUM-404)
Context and Problem Statement
All object-level authorization in the API gates trip resources by ownership through the
TripVoter (TRIP_VIEW / TRIP_EDIT / TRIP_DELETE), wired via API Platform security:
expressions on the trip, stage, chat-history, analyze and share operations.
Until now a denial returned 403 Forbidden. Combined with the fact that a non-existent trip returns 404 Not Found, this leaks the existence of other users' trips: an authenticated attacker probing UUIDs can tell "exists but not mine" (403) from "does not exist" (404). This is a Broken Object Level / enumeration weakness (OWASP API #1/#3), verified during the audit on the write endpoints:
PATCH/DELETE /trips/{unknown} -> 404 GET /trips/{unknown}/detail -> 404
PATCH/DELETE /trips/{foreign} -> 403 GET /trips/{foreign}/detail -> 200 (pre-fix) / 403
Decision
Object-level authorization denials are surfaced as 404 Not Found, not 403 Forbidden,
so a foreign trip is indistinguishable from a non-existent one and existence is never leaked.
RFC 7231 §6.5.4 explicitly sanctions this ("An origin server that wishes to 'hide' the current existence of a forbidden target resource MAY instead respond with a status code of 404"); it is also the convention used by GitHub for private resources a caller cannot access.
Implementation: a kernel.exception listener (App\EventListener\HideForbiddenAsNotFoundListener)
replaces the AccessDeniedException raised by the security: expressions with the same
App\Exception\TripNotFoundException (a NotFoundHttpException with a fixed message and no echoed id)
that the trip providers raise for a missing or expired trip. API Platform then renders one canonical
problem+json 404, so a foreign trip and a non-existent one are byte-for-byte identical.
The listener runs at priority 2, immediately before the firewall's own exception listener
(priority 1): it swaps the throwable so the firewall no longer treats it as an access denial. The swap
is gated on full authentication (AuthenticationTrustResolverInterface::isFullFledged, the same test the
firewall applies), so an anonymous caller still gets 401 from the entry point. The replacement carries no
previous link, because the firewall listener walks getPrevious() and would otherwise re-detect the
wrapped AccessDeniedException and re-handle it.
Scope and boundaries
The mapping is only for object-level access denials:
- Anonymous requests stay 401. The listener swaps only for a fully authenticated caller
(
isFullFledged, the same test the firewall uses); an unauthenticated caller's denial is left untouched and the entry point turns it into 401 (access_controlrequiresIS_AUTHENTICATED_FULLYon^/). - Owned-but-forbidden state denials stay 4xx of their own. Editing a locked trip you own
throws
HttpException(423)(TripLocker::assertNotLocked), notAccessDeniedException, so it is untouched — the caller already knows the trip exists, hiding it would be wrong. - Today every
AccessDeniedExceptionin the app is an object-access denial (TRIP_*); there are no directAccessDeniedHttpExceptionthrows and no authenticated role-only 403s (ROLE_USER/IS_AUTHENTICATED_FULLY/PUBLIC_ACCESSnever deny an authenticated user). The blanket swap is therefore correct. If a future capability/role denial must stay 403 (a denial that does not hide an object's existence), it must bypass this listener by throwing anAccessDeniedHttpException(the HttpKernel one): the listener only intercepts the SecurityAccessDeniedException, so anAccessDeniedHttpExceptionis rendered as 403 untouched. (API Platform throws a bareAccessDeniedExceptionwith no attributes, so the listener cannot scope by the denied attribute — hence the swap is unconditional for authenticated callers.) This boundary is the maintenance contract of this ADR.
Consequences
Positive
- No existence enumeration of other users' trips: foreign and non-existent both return 404.
- Uniform contract across reads and writes (detail, chat-history, analyze, stage, item GET/PATCH/DELETE, share).
- Spec-compliant (RFC 7231 §6.5.4) and matches a widely understood convention (GitHub).
Negative
- Slightly less debuggable for an authenticated-but-unauthorized caller (a 404 where a 403 would be clearer). Negligible here: the PWA only ever requests the signed-in user's own trips, so legitimate flows never hit a foreign/forbidden trip.
Neutral
- 401 (unauthenticated), 405 (method), 415 (media type), 422 (validation), 423 (locked) and 429 (rate limit) keep their own semantics.
Considered alternatives
- Keep 403. Rejected: leaks existence (the problem above).
- Throw 404 in each provider/processor instead of using
security:expressions. Rejected: scatters the policy across many state classes, easy to forget on a new endpoint, and loses the single voter-based access model. - A firewall
access_denied_handler. The framework's intended hook for an authenticated denial, firing only for the authenticated-forbidden case (no auth gating needed). Rejected after testing: the handler must return aResponseand cannot rethrow — throwing aNotFoundHttpExceptionfrom it is caught by the firewall and re-wrapped as a 500 ("Exception thrown when handling an exception"). A hand-built returnResponsewould also not match API Platform'sproblem+json404, leaving a foreign trip distinguishable from a missing one by body on the object-load endpoints (where the missing case is raised by the provider, not the voter). Thekernel.exceptionlistener (priority 2, gated on full authentication) avoids both problems: it swaps the exception so API Platform renders one canonical 404.
Sources
- RFC 7231 §6.5.4 (404 Not Found) and §6.5.3 (403 Forbidden).
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization.