//nefariousplan

CVE-2026-42882: s3-proxy's Auth Reads One Path. Its Handler Reads Another. The Patch Adds a Bouncer.

pattern

cve

proof of concept

s3-proxy's authentication middleware decides whether a request is allowed by glob-matching r.URL.RequestURI() against a list of resource path patterns. Its bucket handler decides what S3 object to read or write by taking the same request, running chi.URLParam(req, "*"), then url.PathUnescape on the result. The same URL produces two different paths. CVE-2026-42882 is the gap, and the patch does not close it. It adds a third middleware that runs before both and rejects any request whose decoded form would lose a segment to path.Clean.

The auth middleware reads one path. The bucket handler reads another.

s3-proxy's authentication middleware was committed to pkg/s3-proxy/authx/authentication/main.go on November 13, 2019. The pre-patch version reads:

// Get request data
requestURI := r.URL.RequestURI()
httpMethod := r.Method

// Find resource
res, err := findResource(resources, requestURI, httpMethod)

r.URL.RequestURI() returns the path as the client sent it, percent-encoding intact. %2F stays %2F. %2E%2E stays %2E%2E. The auth check evaluates resource glob patterns against this string:

func findResource(resL []*config.Resource, requestURI, httpMethod string) (*config.Resource, error) {
    for _, res := range resL {
        if !funk.Contains(res.Methods, httpMethod) {
            continue
        }
        // Compile a glob pattern for uri matching
        g, err := glob.Compile(res.Path)
        if err != nil {
            return nil, errors.WithStack(err)
        }
        if g.Match(requestURI) {
            return res, nil
        }
    }
    return nil, nil
}

glob.Compile(res.Path) is the gobwas/glob constructor called with no separator argument. From the library's own documentation: with no separator supplied, * matches any character including /. A pattern like /upload/*/open/ is read by the developer as "one segment between upload and open." The library reads it as "any run of characters between upload and open, slashes included." The two readings are silently different.

The bucket handler is in pkg/s3-proxy/server/server.go. The pre-patch GET handler reads:

rt2.Get("/*", func(_ http.ResponseWriter, req *http.Request) {
    brctx := bucket.GetBucketRequestContextFromContext(req.Context())
    resHan := responsehandler.GetResponseHandlerFromContext(req.Context())

    // Get request path
    requestPath := chi.URLParam(req, "*")

    // Unescape it
    // Found a bug where sometimes the request path isn't unescaped
    requestPath, err := url.PathUnescape(requestPath)
    if err != nil {
        resHan.InternalServerError(brctx.LoadFileContent, errors.WithStack(err))
        return
    }
    ...
    brctx.Get(req.Context(), &bucket.GetInput{
        RequestPath: requestPath,
        ...
    })
})

The comment // Found a bug where sometimes the request path isn't unescaped was committed on May 2, 2021, in 04b88bb. That commit added url.PathUnescape to all four bucket handlers, HEAD, GET, PUT, and DELETE. It did not touch the auth middleware. From that day on, the auth check read what the client sent and the storage layer read what Go decoded.

Three techniques, one disagreement.

CVE-2026-42882 names three exploit techniques. Each lives in the gap.

Wildcard crossing path boundaries. A resource pattern /upload/*/open is intended to match exactly /upload/<one-segment>/open. Because glob.Compile was called without a separator, * consumes everything up to the next literal open. A request DELETE /upload/foo/bar/restricted/baz/open is matched by an allowlist on /upload/*/open and reaches the storage layer with the multi-segment path the deployment had intended to scope. The integration test TestGlobSeparatorSingleStar in the patch commit codifies the contract: * should match exactly one segment.

Percent-encoded slash smuggling. A request PUT /upload/foo%2Frestricted/drafts/ is, to the auth middleware reading RequestURI(), three segments: upload, foo%2Frestricted, drafts. The literal %, 2, F are content of segment two. A pattern /upload/*/drafts/ matches: between upload/ and /drafts/ is one segment of arbitrary content. To the bucket handler reading the decoded path, foo%2Frestricted is foo/restricted, and the S3 object key resolves to foo/restricted/drafts. The auth layer matched an allowlisted pattern; the storage layer hit a namespace the deployment configured as protected.

Dot-segment traversal under ** prefix patterns. A whitelist on /open/** is intended to expose everything under /open/. The auth middleware glob-matches /open/** against the raw RequestURI(). The request PUT /open/../restricted/secret.json matches: the literal .. is content of a segment under the ** prefix. Go's URL parser resolves .. before the handler runs. The bucket handler sees /restricted/secret.json and writes there.

The CVSS vector is AV:N/AC:L/PR:N/UI:N/C:H/I:H/A:L. No authentication. No interaction. No precondition that the attacker does not control.

The fix is a third middleware that runs first.

The patch ships as two commits.

The first, 1320e4a, adds one character:

- g, err := glob.Compile(res.Path)
+ g, err := glob.Compile(res.Path, '/')

With / as a declared separator, * no longer crosses path boundaries in the glob matcher. The commit's body carries a BREAKING CHANGE note. Any deployment whose resource patterns relied on * spanning multiple segments must migrate to **. The character is the contract change that closes technique one.

The second, af5ff57, adds a new middleware in a new file:

// pkg/s3-proxy/server/middlewares/reject-traversal.go
func RejectTraversal(cfgManager config.Manager) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            hasDot, err := isPathTraversalAttempt(r.URL.EscapedPath())
            if err != nil {
                responsehandler.GeneralBadRequestError(r, w, cfgManager, errors.WithStack(err))
                return
            }
            if hasDot {
                responsehandler.GeneralBadRequestError(r, w, cfgManager,
                    errors.New("path traversal detected"))
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

func isPathTraversalAttempt(escapedPath string) (bool, error) {
    decoded, err := url.PathUnescape(escapedPath)
    if err != nil {
        return false, err
    }
    return countSegments(decoded) != countSegments(path.Clean(decoded)), nil
}

RejectTraversal is wired in server.go before authentication. It calls url.PathUnescape on the escaped path, counts non-empty segments, runs path.Clean, counts again. If the counts differ, a . or .. segment was present, literal or percent-encoded, and the request is rejected with 400. That closes techniques two and three for any input that smuggles dot-segments through encoding.

The auth middleware itself is unchanged. It still calls r.URL.RequestURI(). The bucket handler is unchanged. It still calls chi.URLParam(req, "*") followed by url.PathUnescape. The two parsers still disagree on what /upload/foo%2Frestricted/drafts/ means. The fix is that this kind of URL no longer reaches them when the disagreement would produce a missing or collapsed segment under path.Clean.

The test corpus tells you what the patch defends.

The integration test the second commit adds, TestPathTraversalPrevention, enumerates exactly the inputs the middleware was written to refuse:

{name: "%2E%2E traversal is rejected before auth",
 url: "http://localhost/public/%2E%2E/secret/traversal.txt", expectedCode: 400},
{name: ".. traversal is rejected before auth",
 url: "http://localhost/public/../secret/traversal.txt", expectedCode: 400},
{name: ".. traversal from /open/*/files is rejected before auth",
 url: "http://localhost/open/../restricted/foo/files", expectedCode: 400},
{name: "%2F-based traversal from /public/** is rejected before auth",
 url: "http://localhost/public/foo%2F..%2F..%2Fsecret/traversal.txt", expectedCode: 400},
{name: "%2F-based traversal from /open/*/files is rejected before auth",
 url: "http://localhost/open/foo%2F..%2F..%2Frestricted%2Fbar/files", expectedCode: 400},

Every input the test asserts is rejected contains a dot-segment, literal or encoded. The middleware's contract is exactly that: a request must survive url.PathUnescape followed by path.Clean without losing segments. A request that smuggles a / through %2F but carries no dots is, by construction, outside the rejected set. The two parsers still see it differently. The reporter's test corpus did not exercise the case where they would.

That is the residual seam. It may not be reachable in any deployment's configured patterns under the new glob separator. It is also no longer being defended against in code.

The GHSA names the structural fix. The maintainer did not apply it.

The GitHub Security Advisory's recommended-fix section for the %2F smuggling technique reads:

Use raw path in both layers via r.URL.EscapedPath() instead of the decoded form.

That is the structurally correct fix: make the auth middleware and the bucket handler read the same string, the same way. The applied fix keeps the two reads and gates the door in front of them. The auth middleware will continue to consume the encoded form for as long as no one returns to that file. The bucket handler will continue to decode. Every future resource pattern, every new handler that joins them in the chain, will inherit the same split.

RejectTraversal is a guard placed where the two parses meet. It does not unify them. It enforces a specific invariant: the decoded form must have the same non-empty-segment count after path.Clean. That is enough to close the techniques the reporter named. The general property it does not enforce is the one the GHSA's recommendation was about, that the auth layer and the storage layer agree on what segments the request contains.

Gate-before-canonicalize, again.

This is the Gate Before Canonicalize shape. A security check inspects an input in one form. A downstream subsystem canonicalizes the same input into a different form. The check ran against the wrapped representation; the action ran against the unwrapped one.

In CVE-2026-26336, Alfresco Share's resource controller ran a regex denylist against the URL Spring extracted from /share/page/resource/**. Tomcat then resolved the same path against the disk and treated ..; as ... The regex saw ..;/; the file system saw ../. The defense was deployed in 2021. The bypass was published in 2018. The CVE caught up in 2026.

In CVE-2025-41242, Spring's StringUtils.uriDecode truncated sixteen-bit Java chars into eight-bit bytes and produced the IIS escape .%u002e out of legitimate-looking input. Spring's isInvalidPath ran against that truncated string and saw a token its pattern set could not name. Jetty 12's URIUtil.encodePathSafeEncoding, downstream of the gate, recognised %uXXXX and finished the canonicalisation Spring's checks were never written to detect.

s3-proxy is the same shape in Go. The auth check runs against r.URL.RequestURI(). The action runs against r.URL.Path as decoded by url.PathUnescape. The bypass writes a representation the check cannot canonicalize but the storage layer can.

Three vendors, three layers, regex denylist, string-pattern check, glob matcher, one structural mistake repeated. In all three cases, the structurally correct fix is the same: canonicalize once, before any security check reads the input. None of the three vendors did this. Alfresco wrote a regex that tried to match every encoded variant of ..;. Spring tried to detect the truncated forms after the fact. s3-proxy refuses the request if the decoded form would lose segments. Each defense enumerates the differences the developer happened to remember.

The unescape that planted the bug.

The comment is testimony. Someone in 2021 had noticed that chi.URLParam was returning a percent-encoded string in cases the handler had been written to treat as decoded. They wrote url.PathUnescape, four times, with a comment. They did not look at the auth middleware in the same file tree, and they did not check whether anyone else in the request pipeline was depending on the URL reading the same way.

The auth middleware had been reading r.URL.RequestURI() since 2019. It continued to do so. The bucket handler now decoded. The two readings parted on May 2, 2021, and stayed parted for one thousand, eight hundred, and eight days, until the reporter named the disagreement on April 14, 2026.

PoC: oxyno-zeta/s3-proxy

The patch does not make the two parses agree. It adds a third parse that rejects the requests where they would disagree.