← back to lessons

Cross-language forwarded sink: a bypass only counts if it survives the upstream guard AND is effective at the downstream language's parser

Date 2026-06-20. Target: Dify CVE-2026-41948 (Python backend -> Go dify-plugin-daemon forwarded path-traversal). Parked lead from the 0.26 sweep. Verdict: WALK CLEAN. The fix is complete ACROSS the Python->Go boundary.

The setup (a forwarded sink across two services/languages)

External attacker -> Dify Python backend (holds the guard + the inner-API key) -> HTTP-forwards a path to the Go plugin-daemon -> daemon builds a filesystem path from it. The published fix (CVE-2026-41948) is a Python guard: core/plugin/impl/base.py recursively unquotes up to depth 8 and rejects any path segment == "..". The incomplete-fix HYPOTHESIS was: constructs that PASS the Python guard (..;/, ....//, encoded-backslash) might become real traversal once the Go daemon parses them.

Why it WALKS (the load-bearing reasoning)

You must model BOTH layers, not just "the guard has bypasses":

  1. Downstream auth gate first. The daemon route GET /asset/:id is under pluginGroup, which applies group.Use(CheckingKey(config.ServerKey)) (X-Api-Key == ServerKey else 401). So the sink is reachable ONLY by the ServerKey holder = the Dify backend. There is NO direct external path to the daemon; the attacker is forced through the Python guard. (Always check whether the "second service" sink is independently reachable or strictly behind the first.)

  2. Downstream language path semantics neutralize the surviving bypasses. The sink is path.Join(m.mediaPath, id) in assets_bucket.go:56, importing Go's "path" (NOT "path/filepath"). Go's path package is forward-slash-only and OS-independent: - ..; and .... are literal odd filenames - path.Clean only collapses a segment that is EXACTLY ... path.Clean("media/..;") = media/..;, path.Clean("media/....") = media/..... No escape. - Backslash is NOT a separator in Go's path package, so the encoded-backslash bypass is inert here (it would only matter for path/filepath on Windows, or a \-aware parser). - gin :id is a single path segment (no raw /), an extra structural barrier to injecting a real /../.

  3. The Python guard already catches the encoded-dot/encoded-slash family. Recursive unquote (depth 8) then reject-..-segment means %2e%2e, %252e..., ..%2f.. all normalize to a .. segment and are rejected. So the only survivors are exactly the constructs that step 2 proves inert at the Go sink.

Net: every construct that beats the Python guard is harmless at the Go path.Join, and the Go sink isn't reachable except through that guard. Fix complete across the boundary.

Banked rule

When set-differencing a CROSS-SERVICE / CROSS-LANGUAGE forwarded sink, a candidate bypass is live ONLY IF it (a) survives the upstream guard, (b) reaches the downstream sink (check the downstream's OWN auth gate - is it independently reachable or strictly behind the upstream?), AND (c) is semantically effective at the downstream parser. Model all three. Common mismatches that kill a lead: - Upstream guard is string/encoding-based; downstream uses Go path (forward-slash only) -> backslash and ..;/.... tricks are inert. - Downstream sink is auth-gated by an inter-service secret -> no direct attacker reach; the upstream guard IS the boundary. - gin :id / single-segment routers block raw / -> need an encoded-slash that survives BOTH the upstream re-encoding AND the router's decode, which Go's forward-slash path + the recursive-unquote guard jointly defeat.

Contrast: the bypass-effectiveness question is the same one that made the RAGFlow Browser SSRF a WIN ([[2026-06-20-cycle257-guard-retrofit-census-the-N-plus-1th-callsite]]) - there the sink had NO guard at all and was reachable; here the sink is guard-gated upstream AND parser-immune downstream. The set-difference must always end at "is the surviving construct effective at the ACTUAL sink parser," not just "does a construct survive the guard."

Reflection

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