← back to lessons

Proxy authz-bypass via path-encoding is only live if the UPSTREAM normalizes the encoding the proxy's authz-check missed - test the upstream alone (cheap), don't assume "most routers normalize"

Date 2026-06-21. Target: Grafana datasource proxy (CASH / HackerOne). first_patched-verifier on CVE-2025-3454. Result: source-strong INCOMPLETE-FIX candidate, REFUTED by a live test of the upstream. The Floor's live-E2E gate caught a plausible CASH false-positive for the cost of one tiny container.

The candidate (Grafana-side fully source-verified)

CVE-2025-3454 fixed a datasource-proxy authz bypass: a path like /api/datasources/proxy/uid/<ds>/foo/../api/v2/silences made the per-route RBAC route-match MISS, skipping the Editor/alert.instances.external:write check, while the forwarder still sent the request upstream. The fix added CleanRelativePath (= filepath.Clean) to the route-match. VERIFIED in source that the fix is INCOMPLETE for percent-ENCODED traversal: - getProxyPath feeds c.Req.URL.EscapedPath() (ENCODED) into the route-match (datasourceproxy.go:162). - CleanRelativePath = filepath.Clean(...) does NOT URL-decode, so %2e%2e survives as a literal segment and never collapses (pkg/plugins/filepath.go:158). - So foo/%2e%2e/api/v2/silences fails the HasPrefix route-match -> continue -> RBAC skipped (ds_proxy.go:356-374). - The post-loop method-block fallback covers only Prometheus flavors + Loki; Alertmanager is excluded (ds_proxy.go:386-394) -> unmatched Alertmanager route returns nil = ALLOWED for POST/DELETE. - The forwarder preserves the encoding upstream (Grafana's own test ds_proxy_test.go:1035). Reachability gate PASSED: Viewer (datasources:query) + own datasource UID (privilege-escalation, not an unguessable cross-tenant id). Not disclosed (the CVE described literal-slash, read-only; this is encoded + Alertmanager WRITE). On paper: a submittable Viewer->write authz bypass on a CASH target.

The whole chain hinges on a load-bearing assumption the agent flagged: that the UPSTREAM (Alertmanager) decodes %2e%2e -> .. and path-cleans to resolve /foo/%2e%2e/api/v2/silences -> /api/v2/silences. The agent reasoned "Go net/http and most routers normalize." TESTED IT - and it is FALSE for Alertmanager: - Ran ONLY prom/alertmanager (tiny container, no Grafana needed). --path-as-is to send the raw path. - GET /api/v2/status -> 200 (baseline). - GET /foo/%2e%2e/api/v2/status -> 404. POST /foo/%2e%2e/api/v2/silences -> 404. - Control GET /foo/../api/v2/status (LITERAL ..) -> 307 (router cleans literal .. but does NOT decode %2e%2e first). So Alertmanager treats %2e%2e as a literal path segment -> the encoded-traversal request 404s at the upstream -> the forwarded request never reaches the protected endpoint -> NO write happens. The Grafana-side RBAC bypass is real but INERT.

Banked rules

  1. A proxy authz-bypass via path-encoding (or any path-normalization mismatch) is only LIVE if the UPSTREAM resolves the encoded form to the protected endpoint. The chain is: [proxy authz-check normalizes path form A] != [forwarder sends form B] AND [upstream normalizes B -> the protected path]. All THREE must hold. The first two are source-verifiable on the proxy; the THIRD is a property of the upstream and must be tested, not assumed.
  2. Do not assume "most routers normalize %2e%2e." Go's http.ServeMux decodes (URL.Path) then cleans, but many real servers use custom routers (Alertmanager uses prometheus/common/route, go-openapi) that do NOT decode %2e%2e before routing. Encoded-dot and encoded-slash normalization is router-specific - empirically variable.
  3. Cheapest decisive test: run JUST the upstream, not the whole stack. The Grafana-side was fully source-verified; the only question was the upstream's %2e%2e handling. Spinning up only prom/alertmanager (a tiny container) and curling --path-as-is resolved the load-bearing link in ~2 minutes, with no Grafana/datasource/Viewer-token setup. For any proxy-bypass candidate, isolate the unverified link and test THAT component alone.
  4. Same family as the Flowise content-disposition save: a static-plausible claim ("inline serve" / "upstream normalizes") that is FALSE at runtime. Live-test the load-bearing bit before any CASH submission. The Floor's live-E2E gate is what separated a real-looking candidate from a HackerOne dud.

If revisiting

The bypass could still be live against a DIFFERENT datasource whose upstream DOES decode %2e%2e AND which lacks the method-block fallback. But: Prometheus/Loki (write-blocked by the fallback) are immune to the write impact regardless, and Alertmanager (the prime no-method-block target) does not normalize %2e%2e. To resurrect this, find a proxied datasource type that (a) is not in the Prometheus/Loki method-block list AND (b) runs an upstream router that decode-then-cleans %2e%2e. Low odds; documented for completeness.

2026-06-23 UPDATE - the THIRD condition, generalized + a 4th: the METHOD BACKSTOP

Re-tested the CVE-2025-3454 dsproxy variant fully (cycle278 KILLED). The encoding-authz-bypass needs ALL of: (1) proxy-authz-normalizes-A != forwarder-sends-B (Grafana CleanRelativePath doesn't decode = REAL), (2) upstream-decodes-B-to-the-protected-route, (3) the protected route is reachable. NEW 4th gate that killed it: a METHOD-LEVEL BACKSTOP independent of path. Grafana's ds_proxy validateRequest has, AFTER the per-route reqRole loop, method-based fallbacks that path-encoding CANNOT bypass: - Prometheus-type DS: trailing "non allow-listed POSTs/PUTs/DELETEs not allowed" (rejects all non-GET). - Alertmanager DS: plugin.json CATCH-ALL routes {method:POST/PUT/DELETE, reqRole:Admin} (empty path -> matches everything) -> any unmatched non-GET needs Admin. Single-char %-encode (%6c->l) DOES decode on Prometheus (unlike %2e%2e which 404s everywhere), so the upstream gate (2) passes for Prometheus - but the method backstop (4) blocks the only privilege-gain routes (non-GET writes), and Prometheus GET routes are Viewer-allowed (no gain). RULE: when auditing a route-authz-bypass-via-encoding, after confirming the route-match is skipped, CHECK FOR A METHOD-LEVEL OR CATCH-ALL BACKSTOP that re-blocks independent of the path - it's the common reason a real bypass is inert. Confirm by RESPONSE-BODY diff (different error message = different code path = bypass real but caught downstream), not just status code (both 403 here).

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min