Skip to content

Dancer2 guard semantics: header-name CRLF, hook refusal that still dispatches, and path-spelling differentials

Source: hourly offensive-security scan, 2026-09-22. Primary entries: GitHub Advisory Database GHSA-vp8p-vxw7-c877 / CVE-2026-93711 (response header names keep CR/LF), GHSA-qp5m-r6hx-rjhj / CVE-2026-93710 (dying hook's refusal does not stop the route body), GHSA-8fpv-59mj-6jfr / CVE-2026-93709 (AutoPage guard compares raw path, lookup canonicalizes), and GHSA-fq22-f6q5-wv3x / CVE-2026-93712 (File route handler joins uncollapsed .. onto public_dir). All four affect Perl Dancer2 < 2.2.0 (fixed in 2.2.0); adjacent entry GHSA-454c-c45p-5rr9 / CVE-2026-76974 (SAP Fiori Launchpad unauthenticated crafted-link content load, medium).

Dancer2 is a niche Perl framework, but this four-advisory cluster is worth its own page because each advisory is a different spelling of the same bug-hunting rule: a guard's verdict is computed over a different copy of the data than the one the sink consumes. Sweep any web framework — not just Dancer2 — for these four shapes.

Advisory table

Advisory Boundary Operator value
GHSA-vp8p-vxw7-c877 / CVE-2026-93711 headers_to_array strips CR/LF from header values but not names; a name carrying CRLF reaches the PSGI server intact and, if the server doesn't validate keys, the bytes after the CRLF arrive as their own header line Response splitting whenever the app derives a header name from request data. Value-side sanitization is what everyone audits; name-side omission is the gap.
GHSA-qp5m-r6hx-rjhj / CVE-2026-93710 A before hook that dies to refuse a request fires the exception handler; if the handler halts via the halt keyword (instead of the response object's halt method / is_halted), cleanup discards the refusal the caller sees while the dispatcher still runs the route body and its writes land Auth/validation enforced in hooks is not enforced: the caller receives a 403-class refusal while side effects execute. Guard-in-hook + exception-path semantics = response lies about execution.
GHSA-8fpv-59mj-6jfr / CVE-2026-93709 AutoPage guard compares the request path against the layout directory name as text, while the lookup that follows canonicalizes; doubled slash, dot segment, percent-encoded slash, or case variant on case-insensitive FS misses the guard and serves a non-public layout Fourth-plus instance of guard-compares-lexical / sink-consumes-canonical. Fuzz every path-based guard with the equivalence-class battery.
GHSA-fq22-f6q5-wv3x / CVE-2026-93712 File route handler joins the request path onto public_dir without collapsing relative segments and only checks "readable regular file"; /../outside.txt (or percent-encoded dots) escapes the root — config.yml above public_dir is served to unauthenticated requests Alternate-handler config changes attack surface: the default static_handler refuses dot segments, so a vulnerable deployment only exists once someone names File in route_handlers and disables static handling. Config posture is a precondition probe.

Why it matters for operators

  1. Sanitize-the-value / forget-the-name is a response-splitting class, not a Dancer2 bug. The header-name leg also shows up as Content-Disposition filename legs, cookie names, and custom X-* header names built from user input. Sweep: any place request data becomes a header name, then test with a raw CRLF + inert marker header; confirm whether the fronting server (plackup/starman/nginx) rejects or writes the key.
  2. Hook/filter-based enforcement has an exception path, and the exception path is the bypass. Test pattern on any framework with before-hooks/filters/middleware that "die" or throw: send a request the gate refuses, then observe a side effect that only the route body produces (write to an owned sink, a job queue entry, a counter) while the HTTP response says refused. Response-status-vs-side-effect fidelity tables are the evidence artifact. Same family as the parameter-ABSENT axis — here the request is refused in words and executed in fact.
  3. Path-guard equivalence battery is reusable verbatim. For every textual path comparison (startswith, equality against a deny dir, prefix checks): baseline, // double slash, ./ and ../ dot segments, %2e%2e%2f / %2f encodings, mixed case (on case-insensitive storage), trailing dot/space, and Unicode fold variants. The Quarkus matcher-vs-dispatcher split (Sept 21) and Grav lexical-collapse (Aug 25) pages collect the siblings; this adds guard-text-vs-lookup-canonicalization inside one handler.
  4. Fingerprint framework config posture before calling a leg dead. Two of the four advisories are off-by-default features (auto_page, File in route_handlers with static_handler: 0). On Perl/Dancer2 targets, version-fingerprint (< 2.2.0) and, where visible, handler-config posture determine whether the battery applies at all — a 404 on /../etc/passwd may mean patched, default-config, or feature-off, which are different conclusions.
  5. SAP Fiori Launchpad crafted-link item (CVE-2026-76974, medium) is sparse but names the reusable leg: an unauthenticated crafted link makes an authenticated browser load attacker-controlled external content from the launchpad origin — treat Fiori launchpad URL-parameter surfaces (#/ targets, shell parameters) as open-redirect/content-injection sweep targets in SAP landscapes; prove with an owned redirector and header/URL capture only.

Replayable validation boundaries (authorized labs / owned instances only)

  1. Version-fingerprint first: confirm Dancer2 < 2.2.0 from error pages, banner headers, or dependency manifests; test AutoPage/File legs only if the feature config is present.
  2. Header-name CRLF proof: lab route that echoes a request parameter into a header name; send param=a%0d%0aX-Marker:%201; record raw wire bytes (client-side capture) showing the injected marker header line, plus patched-version negative control. Never test response splitting through shared fronting proxies in production.
  3. Hook-refusal proof: lab app with a before hook that dies on a marker token; send a refused request that would write to an owned sink (temp file, local queue); compare refusal status in the response against sink state. Inert writes only; never trigger real business actions.
  4. Path-spelling proof: request the layout dir and public_dir/../config.yml only against owned/lab instances; stop at status + length + first-bytes fingerprint; do not retain or exfiltrate real config contents.
  5. Fiori proof: owned redirector link only; capture whether the launchpad shell loads or proxies the external target; no session-token collection.

GHSA-rj44-3777-mh5x / CVE-2026-87910 (medium, published 2026-09-11, surfaced in the 2026-09-22 updated feed): when tarfile extracts a link on a system that doesn't support links, it falls back to extracting the link's target member and runs the extraction filter twice — once for the member, once with the link location as name — and one call's return value was ignored. A filter (including stdlib data_filter) that returns None to deny an entry is therefore bypassed on exactly that fallback path.

Operator rule: a security filter is only as strong as every call site honoring its return value, and fallback/error branches are where verdicts get dropped. This is the mirror image of the Penelope extractall()-without-filter case on the May 6 archive-extraction page: there the filter was absent; here it runs and its answer is thrown away. Proof stays bounded: disposable extraction root, one symlink member aimed only at a temp sibling canary, a platform/config where link creation fails (forcing the fallback), patched-Python negative control; never target real home dirs or credentials.

Reporting heuristics

  • Name each leg precisely: header-name CRLF passthrough (value-sanitize/name-forget), hook refusal with continued route-body execution (response-vs-side-effect divergence), lexical guard vs canonicalizing lookup, relative-segment join file disclosure, extraction-filter verdict ignored on fallback branch.
  • Report status-vs-effect divergence with a fidelity table (HTTP status, body, sink state), not a narrative.
  • For off-by-default handler legs, state the config precondition in the finding — that is what makes it reproducible.

Related pages: the Sept 17 guard-composition page, the Sept 21 Quarkus normalization-split fold, the Aug 25 Grav lexical-collapse page, and the May 6 archive-extraction boundary page.

October 7 03:30Z follow-up: ASUSTOR ADM header injection pivots into X-Sendfile arbitrary file read (CVE-2026-105324 / GHSA-75h9-9962-658r, critical)

Unauthenticated HTTP header injection in start-page-loader.cgi via the state parameter (ADM 4.1.0–4.3.3.RWC1, 5.0.0–5.1.4.RL21): the injected header sets the web server's X-Sendfile header, and the fronting server then serves the attacker-named file on the host as the response body — header-injection CRLF upgraded from response-splitting into a full unauthenticated arbitrary-file-read primitive.

  • This page's CRLF legs were about where CR/LF survives (names vs values, filter verdicts); this leg is about what the injected header buys you: any internal server feature that trusts an inbound response header as a directive (X-Sendfile/X-Accel-Redirect and friends) turns one CRLF into a file-read sink. Operator sweep for any CGI/legacy front-controller target: after confirming CRLF survives into the raw response, enumerate server-side header directives the fronting server honors (X-Sendfile, X-Accel-Redirect, X-Download-Options-adjacent proxy features) and test which backend path serves them (Apache mod_xsendfile, nginx X-Accel mappings, lighttpd mod_fastcgi); the file-read proof is a benign canary path you own readable-ness for, not /etc/shadow.
  • Parser-differential sibling of the page's four legs: the guard (CGI's parameter sanitization) never considered that its output framing would be re-interpreted by a second parser (fronting web server honoring injected headers as directives). Same verdict-vs-consumer rule, one layer down the request path.