Canonicalization differentials at security gates¶
A large share of "fixed but still bypassable" findings share one shape: a security gate and the actual router disagree about which representation of the path is authoritative. The gate tests one string, the router resolves another, and an attacker crafts input that passes one but is interpreted by the other.
The canonical example is Mailpit GHSA-8r62-w5wh-fc5m / CVE-2026-67448: a cross-site WebSocket hijacking fix was reimplemented as an origin check gated on a raw-URI prefix test, but Go's ServeMux routes on the percent-decoded path. Requesting /%61pi/events skips the origin gate (strings.HasPrefix("/%61pi/events", "/api/") is false) while the mux decodes %61 → a and dispatches to /api/events. The upgrader's CheckOrigin returns true, so the browser upgrades to a cross-origin WebSocket with no credential or auth. The original CVE-2026-22689 was "fixed" by deleting CheckOrigin; the regression reintroduced it as true and moved the control into the bypassable raw-prefix test.
This is the same bug class as return-URL scheme bypasses and backslash open redirects, but pointed at auth/origin/CORS gates instead of redirect sinks. The reusable rule: a security decision and the eventual sink must operate on the same normalized representation that the downstream router/browser/fetcher will follow.
Operator signal¶
Look for this pattern whenever you see a security control that:
- keys on the raw request target /
RequestURI/r.URL.RawPath/Hostheader / SNI while the handler routes on a decoded, normalized, or differently-parsed value; - runs a prefix / substring / regex allowlist on a path or URL before the router normalizes it;
- makes an origin/host/CORS decision in middleware but the actual socket, fetch, redirect, or upgrade applies a different check (or none);
- was patched by moving the control rather than adding it, especially a fix that swaps one representation for another.
High-signal gate surfaces to check for representational drift:
| Gate | Raw representation it tends to test | Router/browser representation it disagrees with |
|---|---|---|
Go ServeMux path-prefix origin/CSRF gate | r.RequestURI (wire target, percent-encoded) | r.URL.Path (decoded) |
Host/X-Forwarded-Host trust decision | client Host | SNI, :authority, decoded path in the backend |
Redirect / Location allowlist | pre-normalization string | browser URL parser (double-decode, \→/) |
CORS / CheckOrigin on a WS/upgrade | middleware prefix test | upgrade handshake origin (often unenforced) |
| Signed-token / JTI / nonce scope check | string compare on a field | canonical decoded value the verifier later uses |
Preconditions¶
- You have explicit permission to test the application or service.
- You can identify at least one security gate (origin, CSRF, CORS, host-trust, path allowlist, token scope) that guards an interesting sink (WebSocket/upgrade, API route, redirect, authenticated fetch, SSRF-adjacent proxy).
- You can observe the gate's decision (status code, response header, whether the upgrade/handshake succeeds) without needing to exfiltrate data or touch real users.
Lab harness (safe, replayable)¶
Set up a local instance of the target so you can produce a control pair — one request the gate should block and one that should bypass it. For Mailpit, the advisory's own proof is a loopback binary plus a tiny SMTP/WebSocket client:
# Loopback lab instance, no UI auth (the pre-condition the advisory used).
./mailpit --disable-version-check \
--database ./mailpit-lab.db \
--listen 127.0.0.1:8025 \
--smtp 127.0.0.1:1025 \
--max-message-size 50
#!/usr/bin/env python3
# Control pair: same cross-origin Origin, plain vs percent-encoded target.
import socket, urllib.request
def ws_upgrader(target):
req = urllib.request.Request(
f"http://127.0.0.1:8025{target}",
headers={
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version": "13",
"Origin": "https://evil.example", # cross-origin, should be gated
},
)
with urllib.request.urlopen(req, timeout=5) as r:
return r.status # 403 = gated, 101 = bypass
print("plain /api/events ->", ws_upgrader("/api/events"))
print("encoded /%61pi/events ->", ws_upgrader("/%61pi/events"))
Positive result: plain path returns 403 while the percent-encoded path returns 101 Switching Protocols. Keep this control pair in every report — it is the entire proof. Do not exfiltrate mail content; proving the cross-origin socket opens is enough.
Validation workflow¶
- Locate the gate and the router. Find where the security decision is made (middleware,
CheckOrigin, header trust, path allowlist) and where the actual routing/normalization happens. Note which representation each uses. - Produce the control pair. One input the gate should block, one that changes only the representation while keeping the decoded/normalized value identical. Record both status codes / handshake outcomes.
- Enumerate the representation space for the specific sink:
- percent-encoding of path segments (
%61→a,%2F→/) - overlong / double-decoded forms (
%252F,//collapse) - backslash vs forward-slash on Windows-ish or Go-ish routers
- mixed-case scheme/segment where only one side normalizes case
- userinfo /
@/ trailing dot / trailing%00where the parser and the gate differ Host/ SNI /:authority/X-Forwarded-Hostdivergence- Confirm the sink is reachable through the bypassed representation (upgrade succeeds, API returns the guarded resource, redirect resolves off-origin).
- Scope the impact to the actual user context: cross-origin socket, cross-origin credentialed fetch, off-origin redirect, or scope confusion in a token. Label any inference; separate the bypass from any secondary data-exfil claim.
- Capture version provenance. Record the exact version/build where the bypass holds and the first patched version that keys the gate on the router's representation.
Related examples in this class¶
- Concourse post-login redirect via
redirect_uridouble-decode:/%252F→//after the router normalizes — see return-URL scheme-bypass testing. - Gogs
redirect_tobackslash smuggling:/a/../\\example.compasses a leading-slash same-site check, browser normalizes\→/→ off-origin. - CakePHP Authentication backslash normalization before slash conversion, bypassing a "local target only" check.
- Puma PROXY-protocol v1 source-IP trust when a trusted edge keeps
Host/SNI and origin disagree. - Apache Shiro Jakarta EE trusting client-controlled
Refereras the post-login return target. - Reverse::Proxy (GHSA-5xq5-hx4g-f5v6 / CVE-2026-75922): routing decision and upstream serializer disagree on encoded versus decoded
PATH_INFO, turning%0d%0ain the client target into a CRLF in the proxy's self-serialized request line — the same representational drift pointed at a request-framing sink rather than a gate. See the "DecodedPATH_INFOframing at serialized upstream request lines" section of the HTTP desync research campaigns page. - Echo (Go web framework)
%2Fstatic-file differential: the router matches routes on the raw encoded path (req.URL.RawPath, preserving%2F), so/admin%2Fsecret.txtis a single segment that does not match the protected/admin/*route, whileStaticDirectoryHandlercallsurl.PathUnescape()before resolving the filesystem path and converts%2F→/, readingadmin/secret.txton disk. A security gate (route-level auth) and the file sink disagree on which representation of the path is authoritative. See GHSA-vfp3-v2gw-7wfq / CVE-2026-55677.
What to report¶
- The gate and the router, and which representation each uses
- The exact input form that bypasses the gate while still reaching the sink
- The control pair (block vs bypass) with status codes / handshake results
- The affected version/build and the first patched version
- Impact scoped to the real user context
- Minimal proof (a benign marker or upgrade result), no real-user targeting, no exfiltration
Heuristics that catch weak fixes¶
Flag fixes that only change the representation the gate tests instead of making the gate and router agree:
strings.HasPrefix(r.RequestURI, webroot+"api/") // raw wire target, not decoded
r.URL.RawPath != "" && allow(prefix(RawPath)) // RawPath vs Path
if host := r.Host; allowlist[host] // Host vs SNI vs :authority
allow = strings.HasPrefix(loc, "/") // no //, backslash, or double-decode reject
CheckOrigin: return true // control moved out, not added
Useful regression inputs (keep to lab / owned hosts only):
/%61pi/events # %61 = 'a'
/%2561pi/events # double-encoded
/\\example.invalid/x # backslash router collapse
//example.invalid/x # protocol-relative collapse
/%2f%2fexample.invalid # encoded slash collapse
JaVaScRiPt:alert(1) # mixed-case scheme
Safe boundaries¶
- Keep testing in lab instances or explicitly authorized systems; use
example.invalid/ owned hosts. - Prove the gate bypass with a control pair and a benign marker; do not exfiltrate, target real users, or read real mail/data.
- Do not send crafted upgrade/redirect links to other users.
- Stop at proof of the representational differential unless the engagement explicitly permits deeper impact.
Sources¶
- GitHub Advisory Database: Mailpit GHSA-8r62-w5wh-fc5m / CVE-2026-67448 — WebSocket origin check bypass via percent-encoded path (regression of CVE-2026-22689)
- GitHub Advisory Database: Mailpit GHSA-r553-m4fv-5v97 / CVE-2026-67447 — SMTP DATA line buffered before size enforcement (availability-only; reviewed, not promoted)
- GitHub Advisory Database: gettext-converter GHSA-f4jp-rw7w-ccwg / CVE-2026-55451 — prototype pollution in
js2i18next()(app-dependent; reviewed, not promoted) - Return URL scheme-bypass testing — same representational-drift class pointed at redirect sinks
- Mailpit advisories: https://github.com/axllent/mailpit/security/advisories