-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The auth middleware reads one path. The bucket handler reads another.\n\ns3-proxy's authentication middleware was committed to `pkg/s3-proxy/authx/authentication/main.go` on November 13, 2019. The pre-patch version reads:\n\n```go\n// Get request data\nrequestURI := r.URL.RequestURI()\nhttpMethod := r.Method\n\n// Find resource\nres, err := findResource(resources, requestURI, httpMethod)\n```\n\n`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:\n\n```go\nfunc findResource(resL []*config.Resource, requestURI, httpMethod string) (*config.Resource, error) {\n for _, res := range resL {\n if !funk.Contains(res.Methods, httpMethod) {\n continue\n }\n // Compile a glob pattern for uri matching\n g, err := glob.Compile(res.Path)\n if err != nil {\n return nil, errors.WithStack(err)\n }\n if g.Match(requestURI) {\n return res, nil\n }\n }\n return nil, nil\n}\n```\n\n`glob.Compile(res.Path)` is the [gobwas/glob](https://github.com/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.\n\nThe bucket handler is in `pkg/s3-proxy/server/server.go`. The pre-patch GET handler reads:\n\n```go\nrt2.Get(\"/*\", func(_ http.ResponseWriter, req *http.Request) {\n brctx := bucket.GetBucketRequestContextFromContext(req.Context())\n resHan := responsehandler.GetResponseHandlerFromContext(req.Context())\n\n // Get request path\n requestPath := chi.URLParam(req, \"*\")\n\n // Unescape it\n // Found a bug where sometimes the request path isn't unescaped\n requestPath, err := url.PathUnescape(requestPath)\n if err != nil {\n resHan.InternalServerError(brctx.LoadFileContent, errors.WithStack(err))\n return\n }\n ...\n brctx.Get(req.Context(), &bucket.GetInput{\n RequestPath: requestPath,\n ...\n })\n})\n```\n\nThe comment `// Found a bug where sometimes the request path isn't unescaped` was committed on May 2, 2021, in [`04b88bb`](https://github.com/oxyno-zeta/s3-proxy/commit/04b88bb2b656fe9248d4917c3ec6492251c2e2ad). 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.\n\n## Three techniques, one disagreement.\n\nCVE-2026-42882 names three exploit techniques. Each lives in the gap.\n\n**Wildcard crossing path boundaries.** A resource pattern `/upload/*/open` is intended to match exactly `/upload//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.\n\n**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.\n\n**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.\n\nThe 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.\n\n## The fix is a third middleware that runs first.\n\nThe patch ships as two commits.\n\nThe first, [`1320e4a`](https://github.com/oxyno-zeta/s3-proxy/commit/1320e4abd46ad18c2851fedde50dbb79df8b7a51), adds one character:\n\n```diff\n- g, err := glob.Compile(res.Path)\n+ g, err := glob.Compile(res.Path, '/')\n```\n\nWith `/` 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.\n\nThe second, [`af5ff57`](https://github.com/oxyno-zeta/s3-proxy/commit/af5ff57d8c6022459495b8fb50130073bca7b48a), adds a new middleware in a new file:\n\n```go\n// pkg/s3-proxy/server/middlewares/reject-traversal.go\nfunc RejectTraversal(cfgManager config.Manager) func(http.Handler) http.Handler {\n return func(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n hasDot, err := isPathTraversalAttempt(r.URL.EscapedPath())\n if err != nil {\n responsehandler.GeneralBadRequestError(r, w, cfgManager, errors.WithStack(err))\n return\n }\n if hasDot {\n responsehandler.GeneralBadRequestError(r, w, cfgManager,\n errors.New(\"path traversal detected\"))\n return\n }\n next.ServeHTTP(w, r)\n })\n }\n}\n\nfunc isPathTraversalAttempt(escapedPath string) (bool, error) {\n decoded, err := url.PathUnescape(escapedPath)\n if err != nil {\n return false, err\n }\n return countSegments(decoded) != countSegments(path.Clean(decoded)), nil\n}\n```\n\n`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.\n\nThe 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`.\n\n## The test corpus tells you what the patch defends.\n\nThe integration test the second commit adds, `TestPathTraversalPrevention`, enumerates exactly the inputs the middleware was written to refuse:\n\n```go\n{name: \"%2E%2E traversal is rejected before auth\",\n url: \"http://localhost/public/%2E%2E/secret/traversal.txt\", expectedCode: 400},\n{name: \".. traversal is rejected before auth\",\n url: \"http://localhost/public/../secret/traversal.txt\", expectedCode: 400},\n{name: \".. traversal from /open/*/files is rejected before auth\",\n url: \"http://localhost/open/../restricted/foo/files\", expectedCode: 400},\n{name: \"%2F-based traversal from /public/** is rejected before auth\",\n url: \"http://localhost/public/foo%2F..%2F..%2Fsecret/traversal.txt\", expectedCode: 400},\n{name: \"%2F-based traversal from /open/*/files is rejected before auth\",\n url: \"http://localhost/open/foo%2F..%2F..%2Frestricted%2Fbar/files\", expectedCode: 400},\n```\n\nEvery 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.\n\nThat 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.\n\n## The GHSA names the structural fix. The maintainer did not apply it.\n\nThe GitHub Security Advisory's recommended-fix section for the `%2F` smuggling technique reads:\n\n> Use raw path in both layers via `r.URL.EscapedPath()` instead of the decoded form.\n\nThat 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.\n\n`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.\n\n## Gate-before-canonicalize, again.\n\nThis is the [Gate Before Canonicalize](https://nefariousplan.com/patterns/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.\n\nIn [CVE-2026-26336](https://nefariousplan.com/posts/alfresco-share-blacklist-misses-the-semicolon), 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.\n\nIn [CVE-2025-41242](https://nefariousplan.com/posts/spring-uridecode-ghost-bits-jetty-finishes), 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.\n\ns3-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.\n\nThree 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.\n\n## The unescape that planted the bug.\n\nThe 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.\n\nThe 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.\n\nPoC: [`oxyno-zeta/s3-proxy`](https://github.com/oxyno-zeta/s3-proxy/security/advisories/GHSA-rfgq-wgg8-662p)\n","closing_line":"The patch does not make the two parses agree. It adds a third parse that rejects the requests where they would disagree.","hook_md":"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`.","post_id":282,"slug":"s3-proxy-cve-2026-42882-two-parses-one-bouncer","title":"CVE-2026-42882: s3-proxy's Auth Reads One Path. Its Handler Reads Another. The Patch Adds a Bouncer.","type":"initial","unreadable_sentence":"The patch does not make the two parses agree. It adds a third parse that rejects the requests where they would disagree."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatZPPwAKCRDeZjl4jgkQ JutBAP46CwdE/DUmxqwXTxCahUb9Sav9PLgs8Xv3Ghgi0B6G+wD/e10P5TJ+R7j+ 4TkQpeajoFSt3lOIylQjrxce4YC6Iw4= =s8qm -----END PGP SIGNATURE-----