mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
fix(auth): decode rest before matching the passthrough path allowlist
Close the %2f allowlist bypass (security review finding 3, round 3,
2026-08-22): allowsPassthroughPath and hasTraversalSegment (routes_
passthrough.go) reasoned about rest asymmetrically -- the traversal check
decodes it first, the allowlist matcher ran path.Match against the raw
escaped form. path.Match("v1/*", "v1/chat%2fcompletions") reports true
(one escaped segment, no literal "/" to split it), while the decoded
equivalent, "v1/chat/completions", is two segments and does not match
"v1/*". A group restricted to PassthroughPaths: ["v1/*"] therefore let
"/openai/v1/fine_tuning%2Fjobs" through -- a path an upstream that
itself decodes %2F would read as a different, unauthorized endpoint.
allowsPassthroughPath now url.PathUnescape's rest before matching -- the
same decode hasTraversalSegment already applies -- and denies a rest
that fails to unescape at all, mirroring hasTraversalSegment's own
fail-closed rule for malformed encoding. The empty-pattern fast path
(matchesGlob's own empty-means-all contract) is checked first, before
any decoding: a group with no PassthroughPaths configured -- every live
group today -- keeps allowing every request exactly as before, malformed
encoding included; hasTraversalSegment (unconditional, later in
handlePassthrough) remains the gate for that case.
Tests: a direct unit test proving the decoded three-segment form is
denied (both via the encoded and literal spellings), a single-segment
encoded value still matches, malformed encoding denies for a restricted
group and is unaffected for an unrestricted one, and an end-to-end
ServeHTTP test reproducing the exact finding scenario.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -110,8 +111,39 @@ func (grp *group) allowsAgent(name string) bool {
|
||||
// PassthroughPaths' own doc comment (llmgateway.go) for a path.Match
|
||||
// footgun a non-empty list inherits: "*" does not cross "/", so ["*"]
|
||||
// is NOT "allow everything".
|
||||
//
|
||||
// %2f BYPASS (security review finding 3, round 3, 2026-08-22): rest
|
||||
// arrives here still in its ESCAPED form (passthroughRoute's own
|
||||
// contract, routes_passthrough.go — callers must pass r.URL.
|
||||
// EscapedPath()), the same form path.Match evaluates literally, with no
|
||||
// notion of percent-decoding. hasTraversalSegment (routes_passthrough.go)
|
||||
// decodes rest before reasoning about it; this function used to match
|
||||
// the RAW escaped form instead — an asymmetry a client can exploit:
|
||||
// path.Match("v1/*", "v1/chat%2fcompletions") reports true (one escaped
|
||||
// segment, no literal "/" for the glob to see splitting it), while the
|
||||
// DECODED equivalent, "v1/chat/completions", is two segments and does
|
||||
// NOT match "v1/*". A group restricted to ["v1/*"] therefore let
|
||||
// "/openai/v1/fine_tuning%2Fjobs" through — a path an upstream that
|
||||
// itself decodes %2F would read as "v1/fine_tuning/jobs", a different,
|
||||
// unauthorized endpoint. Fixed by decoding rest with the SAME
|
||||
// url.PathUnescape call hasTraversalSegment already uses before matching,
|
||||
// and rejecting (denying) a rest that fails to unescape at all — the
|
||||
// same fail-closed rule hasTraversalSegment already applies. The
|
||||
// zero-pattern (empty-means-all) fast path runs BEFORE decoding: a group
|
||||
// that has not opted into PassthroughPaths at all keeps allowing every
|
||||
// request, malformed encoding included, exactly as before this fix —
|
||||
// hasTraversalSegment (called unconditionally, later, in
|
||||
// handlePassthrough) is still the gate that denies a malformed rest for
|
||||
// that case, unchanged.
|
||||
func (grp *group) allowsPassthroughPath(rest string) bool {
|
||||
return matchesGlob(grp.passthroughPaths, rest)
|
||||
if len(grp.passthroughPaths) == 0 {
|
||||
return true
|
||||
}
|
||||
decoded, err := url.PathUnescape(rest)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return matchesGlob(grp.passthroughPaths, decoded)
|
||||
}
|
||||
|
||||
// cloneStringSlice returns an independent copy of s, preserving nil (a nil
|
||||
|
||||
Reference in New Issue
Block a user