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¶
- Sanitize-the-value / forget-the-name is a response-splitting class, not a Dancer2 bug. The header-name leg also shows up as
Content-Dispositionfilename legs, cookie names, and customX-*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. - 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.
- 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/%2fencodings, 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. - Fingerprint framework config posture before calling a leg dead. Two of the four advisories are off-by-default features (
auto_page,Fileinroute_handlerswithstatic_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/passwdmay mean patched, default-config, or feature-off, which are different conclusions. - 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)¶
- 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.
- 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. - Hook-refusal proof: lab app with a
beforehook 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. - Path-spelling proof: request the layout dir and
public_dir/../config.ymlonly against owned/lab instances; stop at status + length + first-bytes fingerprint; do not retain or exfiltrate real config contents. - Fiori proof: owned redirector link only; capture whether the launchpad shell loads or proxies the external target; no session-token collection.
Adjacent: Python stdlib tarfile data-filter verdict ignored on the link-fallback path¶
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 (Apachemod_xsendfile, nginxX-Accelmappings, lighttpdmod_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.