URL extension shadow diagnostics
This is observation only. No request is denied, rewritten, redirected or re-serialized by this feature. It does not change Router extension parsing, Accept negotiation, authentication, permissions, CSRF, Sentry exclusions, or application/cache behavior. Enforcing a policy is separate work.
Declaration semantics
#[\Dash\Attribute\AcceptExtensions('json', 'xml')]
public function ratings_index() { /* existing implementation */ }
#[\Dash\Attribute\AcceptExtensions('', 'csv')]
public function admin_index() { /* normal page or export */ }
- No attribute means only the absent suffix (
'') is accepted. - An attribute replaces that default. Include
''explicitly to allow it. - Values are exact, case-sensitive, lowercase suffixes without a dot.
- These are URL permissions, not MIME types or guarantees of a response. Legacy invitation
.jsonaliases deliberately keep their text response. - Overriding an annotated method does not inherit its declaration. Delegating actions need their own declaration.
- Initial annotations cover known API/JSON aliases, scripts, images, sitemaps, CSV/PDF outputs and other high-volume uses. They are a starting policy, not enforcement or a declaration that the inventory is exhaustive.
Lifecycle / coverage
The priority11 dispatcher filter runs after Cake routing (priority10) but before controller construction/startup. It reads reflection metadata without instantiating a controller or calling an action. It never stops dispatch.
Only would-be denials register a shutdown observer. Native PHP response status and emitted headers are read there, so redirects, direct image/file exits, exceptions and Cake-rendered fatal errors are covered without relying on afterDispatch or on the original (possibly replaced) CakeResponse object.
The observer never changes headers, output buffering, status or body. It does not inspect response bodies. Headers are only used to derive the safe metadata below.
Coverage is application-controller HTTP requests that reach this filter. Nginx/static/plugin assets, earlier cache-dispatch hits, plugin controllers, missing/non-actions, CLI and internal requested/return dispatches are excluded. A fatal error before registration, process kill or an earlier shutdown handler that exits can prevent an observation. Shutdown handlers that subsequently change output can also limit final-header accuracy.
Dedicated, bounded output
Output is app/tmp/logs/extension_shadow.log (the normal application's shared TMP/log directory). It uses the existing MyFileLog rotation engine directly, not the debug/error channels or Sentry: a10MiB current file and at most two rotated files, approximately30MiB total plus a final line.
Each record contains:
- UTC timestamp and
extension_shadow_deniedevent name; - source-known canonical controller class and action, standard HTTP method;
- requested suffix (including
'') and the declared allowed suffixes; - native HTTP status, normalized Content-Type and attachment boolean.
No raw URL, path arguments, query/body values, IDs, IPs, referrers, credentials, cookies, redirect targets or attachment filenames are logged. Unknown suffixes, nonstandard methods and unrecognized MIME values are redacted/category-only. For Content-Type, parameters such as charset/boundary are discarded. A null type means no explicit Content-Type was visible; it is not a guessed PHP default.
Sampling is one record per distinct metadata shape per UTC minute, with a hard maximum of120 shapes/minute per state directory. Shapes include status, MIME and attachment status, so a200 JSON result and a500 HTML result are distinct. Counts in this log are not total request counts, and absence is not proof that an alias is unused. Flooding, contention or exhausted capacity can hide rare shapes.
The small local app/tmp/locks/extension_shadow.lock state has no raw request values and is bounded to120 fingerprints for the current minute. It uses a non-blocking lock; contended/unwritable storage simply drops observations. There is no Redis access, cache lock, database write or network call. All diagnostic failures are best-effort and must not affect the response or generate Sentry reports. Normal read/write I/O still has some overhead.
To disable observation through the existing application configuration:
There is intentionally no enforcement switch in this PR.
Reading the evidence
- JSON MIME on a would-be denial is evidence of a possible missing JSON alias, not permission to automatically add one.
- Redirects/auth errors/legacy text can explain misleading2xx or MIME results.
- Attachment=true suggests a filename/download contract needing separate review.
- A would-be denial for an empty suffix identifies an action annotated without
''; review whether it requires a suffix or needs its current default retained. - Review annotated format sets, extensionless defaults, dotted arguments and method/OPTIONS behavior before any future enforcement.
- Keep existing Sentry HTTP/missing-view exclusions unchanged.
Tests cover declaration/default semantics, prefix and case handling, privacy, sampling/rotation/non-blocking failure behavior, routing invariance, and actual local PHP HTTP comparisons with shadow off/on. The HTTP fixture includes JSON, PNG+exit, attachment, redirect, exception, fatal, unknown metadata, required-empty suffix, allowed request and broken diagnostic storage. Cake's existing per-response CSP nonce is normalized only when comparing fatal pages.