Skip to content

dispatch: middleware request context carries no cgi key — documented req.cgi.* patterns silently fail (RateLimiter keyFunction collapses all clients into one budget; InboundRequestId never honors the inbound header) #3074

Description

@bpamiri

Summary

Dispatch builds the middleware request context as {params, route, pathInfo, method} (vendor/wheels/Dispatch.cfc ~lines 420-425) — there is no cgi member. Two guides (and repo CLAUDE.md) document middleware code that reads req.cgi.* / request.cgi.*, which therefore always misses and silently degrades:

  1. Rate-limiting keyFunction (digging-deeper/rate-limiting.mdx:131-145, same snippet in repo CLAUDE.md): req.cgi.http_x_api_key ?: "anonymous" Elvis-defaults on every live request, so ALL clients collapse into the single "anonymous" budget. Security-relevant for exactly the use case the section targets (token-authenticated APIs behind NAT). Verified live with maxRequests=3: 3× X-Api-Key: aaa → 200,200,200; then X-Api-Key: bbb429; no-header → 429.
  2. Observability InboundRequestId example (deployment/observability-and-logging.mdx): request.cgi.http_x_request_id ?: "" is always empty, so curl -H 'X-Request-Id: test-123' gets a fresh UUID — the documented header passthrough never happens. (Adobe bonus trap: request.wheels.requestId = ... inside handle() writes to the shadowing request parameter, not the scope — see RequestId.cfc's own $writeRequestId comment.)

Cors.cfc already defends against this exact trap (StructKeyExists(arguments.request, "cgi") + engine-CGI fallback, lines 89-93/138-142), and RateLimiter.cfc:224-244 does the same for client IP — the framework's own middleware know the context has no cgi; the docs don't.

Verified working forms (live, Lucee 7, develop @ 840274b)

// keyFunction — note: bare `cgi.http_x_api_key ?: "anonymous"` is ALSO wrong,
// because a missing header reads as empty string, so the Elvis never fires.
keyFunction=function(req) {
    var apiKey = cgi.http_x_api_key;
    return Len(apiKey) ? apiKey : "anonymous";
}

→ aaa: 200,200,200,429 (own budget); bbb: 200; no header: 200 (separate anonymous bucket). The InboundRequestId example likewise works after switching to the real cgi scope (echoes test-123, still generates a UUID when absent).

The RateLimiter specs only pass hand-built request structs that DO carry cgi/remoteAddr, so no spec catches the live-dispatch shape.

Proposed direction (pick one)

  1. Framework parity (nicer): add cgi (or a curated header subset) to the Dispatch middleware request context so the documented req.cgi.* form works and per-component engine-scope fallbacks (Cors, RateLimiter) become redundant. Docs still need the empty-string/Len() correction.
  2. Docs-only (minimal): replace both snippets (plus repo CLAUDE.md's keyFunction) with the Len()-guarded bare-cgi form, and add a spec that drives RateLimiter through real Dispatch to pin the context shape.

While editing, correct the adjacent rationale at rate-limiting.mdx line 145: an empty-string key doesn't "accumulate counter entries" — it merges all header-less traffic into one shared budget (memory is separately bounded by maxStoreSize/maxKeyLength).

Acceptance

  • Either the middleware context carries cgi (with a spec) or all documented req.cgi middleware examples are replaced with verified-working forms (guide + CLAUDE.md).
  • A dispatch-level spec exercises keyFunction with real headers so the context-shape regression class is pinned.

Reported by the guide-behavioral-audit P1 batch 2 (work items p1-11-ratelimit claim rl-14, p1-16-deploy claim obs-03 — two independent live repros, develop @ 840274b).

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