SFTPGo lets administrators hide files from an account with name filters: deny *.pem in /keys, or allow only *.tar.gz everywhere. Downloads, listings and stat calls all honoured those filters. Copy didn't. It checked the filter on the destination only, so an account with copy permission could copy a denied file to an allowed name and download the copy (CVE-2026-103028). A recursive copy could do the same for a whole restricted folder.
It's a medium-severity bug in one file server, fixed in SFTPGo 2.7.6. It's also one of the most common shapes an authorization bug takes: an operation with two paths, where only one gets checked. This teardown is for teams that run SFTPGo, and for anyone writing code that copies, moves, renames or links things on a user's behalf. It covers the bug, the fix, a model that shows the pattern in a few lines, an audit script for SFTPGo accounts, and a gap in what scanners will tell you about this release.
The short answer
In SFTPGo 2.5.0 through 2.7.5, file pattern filters were evaluated on the target of a copy but not the source, so an authenticated account with the copy permission could read files the filters deny, and the hide policy didn't stop it. Upgrade to 2.7.6 (19 September), which checks the source like a download: copy permission on both sides, download permission on the source, and the filter on the source. The same commit fixes related inconsistencies in rename and symlink. Only one of the four security advisories in 2.7.6 has a CVE and appears in GitHub's advisory database, so read the release notes rather than relying on a scanner.
The bug
| CVE | CVE-2026-103028 (GHSA-wwv9-g5x4-966q) |
| Severity | Medium, CVSS 3.1 score 4.3 |
| Affected | github.com/drakkan/sftpgo/v2 2.5.0 through 2.7.5 |
| Fixed | 2.7.6, released 19 September |
| GitHub advisory database | 15 October |
| Reported by | arpitjain099 |
The advisory is precise about the limits. The account needs the copy permission, both ends of the copy must be paths it can already reach, and home directories, virtual folders and per-directory permissions still apply. What leaks is the content of files the name filters were meant to keep from it. The hide policy, which makes denied files invisible to listings and stat, didn't help either: a file reported as missing could still be copied.
The filters exist for real separation, so this matters more than the score suggests. A shared partner account that can upload and copy within /contracts but must not read draft spreadsheets is exactly the setup the bug undoes.
The fix: a copy is a read, then a write
Commit dc8886b, merged on 12 August, is titled "evaluate the name filter on the source of copy, rename and symlink". After it, a copy:
- needs the copy permission on both the source and the target directory;
- needs the download permission on the source directory, because copying reveals the content as surely as downloading it does;
- checks the filter on the source and returns whatever the configured deny policy says, as a download would;
- checks the filter on the target and returns a permission error, since that path is being written, not read.
The commit message also lists two siblings. Rename evaluated the filter but turned every refusal into a generic permission error, throwing away the configured policy. Symlink creation honoured only the hide policy and let the default one through. Now all three give the same answer a download gives.
The same release carries a related hardening change: computing a file's hash over SFTP now requires the download permission, where it previously accepted list. A hash isn't the file, but it reads every byte, and it confirms guesses about content. The principle is the same one: anything that reads content needs the read check.
The pattern in a few lines
This toy server isn't SFTPGo code. It has one account that may list, download, upload and copy, and one filter that denies *.pem in /keys. It runs the same three attempts against the logic before and after the fix:
#!/usr/bin/env python3
"""A toy file server with SFTPGo-style name filters, before and after the fix.
Not SFTPGo code. The question it asks of every operation: can a user end up
reading the contents of a file the filter denies them?
"""
import fnmatch, posixpath
FILES = {"/keys/server.pem": b"-----BEGIN PRIVATE KEY----- (placeholder)", "/docs/readme.txt": b"hello"}
PERMS = {"/": {"list", "download", "upload", "copy"}} # what the account may do
DENIED = {"/keys": ["*.pem"]} # filter: never serve *.pem in /keys
def allowed(path):
patterns = DENIED.get(posixpath.dirname(path), [])
return not any(fnmatch.fnmatch(posixpath.basename(path), p) for p in patterns)
def has(perm, path):
return perm in PERMS["/"]
class Server:
def __init__(self, fixed):
self.fixed, self.fs = fixed, dict(FILES)
def download(self, p):
if not (has("download", p) and allowed(p)):
raise PermissionError(p)
return self.fs[p]
def copy(self, src, dst):
if not (has("copy", src) and has("copy", dst)):
raise PermissionError(src)
if self.fixed and not (has("download", src) and allowed(src)): # the source is read
raise PermissionError(src)
if not allowed(dst): # the target is written
raise PermissionError(dst)
self.fs[dst] = self.fs[src]
def attempt(server, steps):
try:
return f"READ {steps(server)!r:.32}"
except PermissionError as e:
return f"refused ({e})"
CASES = [
("download the key", lambda s: s.download("/keys/server.pem")),
("copy to /docs, then download", lambda s: (s.copy("/keys/server.pem", "/docs/server.txt"),
s.download("/docs/server.txt"))[1]),
("copy to an allowed name in /keys", lambda s: (s.copy("/keys/server.pem", "/keys/server.bak"),
s.download("/keys/server.bak"))[1]),
]
for fixed in (False, True):
print("after the fix" if fixed else "before the fix")
for name, steps in CASES:
print(f" {name:34} {attempt(Server(fixed), steps)}")
$ python3 two_path_model.py
before the fix
download the key refused (/keys/server.pem)
copy to /docs, then download READ b'-----BEGIN PRIVATE KEY----- (p
copy to an allowed name in /keys READ b'-----BEGIN PRIVATE KEY----- (p
after the fix
download the key refused (/keys/server.pem)
copy to /docs, then download refused (/keys/server.pem)
copy to an allowed name in /keys refused (/keys/server.pem)
Before the fix, the direct download is refused and both copies succeed, including a copy to a different name inside the same denied folder. After it, all three are refused at the source.
The test is the reusable part. For every operation that takes two paths, ask which one is read and which one is written, and assert that the read side passes the same check as a download. Copy, move, rename, hard link, symlink, archive and "duplicate" buttons in web UIs are all candidates. Our broken access control guide covers the wider class.
Audit your SFTPGo accounts
Upgrading closes the hole. It's also worth knowing which accounts could have used it, and checking their history. This script reads an SFTPGo backup, from sftpgo dumpdata or the admin API, and lists users who have a file pattern filter on a path where they also hold the copy permission. A backup contains password hashes and keys, so keep it local and delete it afterwards:
#!/usr/bin/env python3
"""Find SFTPGo users whose file pattern filters a copy could bypass before 2.7.6.
sftpgo_filter_audit.py backup.json (from `sftpgo dumpdata` or the admin API backup)
A backup holds password hashes and keys: keep it local and delete it after.
Checks users only; groups can carry filters and permissions too. Permission
paths written as glob patterns are not expanded.
"""
import json, posixpath, sys
def perms_at(perms, path):
"""SFTPGo uses the permissions of the closest configured directory, falling back to "/"."""
while True:
if path in perms:
return set(perms[path])
if path == "/":
return set()
path = posixpath.dirname(path)
def main(path):
data = json.load(open(path))
flagged = 0
for user in data.get("users", []):
perms = user.get("permissions", {})
for flt in (user.get("filters") or {}).get("file_patterns") or []:
if not flt.get("denied_patterns") and not flt.get("allowed_patterns"):
continue
p = perms_at(perms, flt["path"])
if p & {"copy", "*"}:
flagged += 1
rule = ("denies " + ", ".join(flt["denied_patterns"])) if flt.get("denied_patterns") \
else ("allows only " + ", ".join(flt["allowed_patterns"]))
hide = " (hide policy)" if flt.get("deny_policy") == 1 else ""
print(f"REVIEW {user['username']}: {flt['path']} {rule}{hide}, and the account can copy there")
if not flagged:
print("no user combines a file pattern filter with the copy permission")
print("Before 2.7.6 a copy checked these filters on the target only. Upgrade, then check the "
"audit log for copies out of these paths.")
return 1 if flagged else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
sys.exit(main(sys.argv[1]))
On a synthetic backup with three accounts:
$ python3 sftpgo_filter_audit.py backup-example.json
REVIEW partner-acme: /contracts denies *.xlsx, *draft* (hide policy), and the account can copy there
REVIEW ops: /backups/keys denies *.pem, and the account can copy there
Before 2.7.6 a copy checked these filters on the target only. Upgrade, then check the audit log for copies out of these paths.
(exit 1)
ci-uploader has an allow-list filter but no copy permission, so it couldn't use the bug. The other two could. For those, check SFTPGo's logs or your event rules for copies whose source was inside the filtered path.
What scanners will tell you about 2.7.6
SFTPGo 2.7.6 fixed four security advisories, all published in the project's repository on 19 September. Only this one has a CVE, and on 17 October it was the only one in GitHub's global advisory database and OSV. The other three, including a high-severity stored cross-site scripting bug in the WebClient through directory names, return nothing from GitHub's advisory API. The same is true of a June advisory about a folder permission bypass through symlinks.
So a scanner checking a 2.7.5 deployment against those databases reports one medium finding, when the upgrade fixes four. That's not a criticism of SFTPGo, which documents every fix in its release notes and advisory page. It's a reminder that for software you run as a service, the project's own advisories are the primary source, and the databases are a partial copy.
What to do
- Upgrade SFTPGo to 2.7.6 or later. It fixes this bug, the three advisories above, and several hardening items.
- Review accounts that combine filters with copy, using the script above, and check their recent copy activity.
- Remove the copy permission where it isn't needed. Partner and upload accounts often have no need to copy server-side.
- Don't rely on name filters alone for secrets. Keep files that an account must never read outside its reachable paths, so no operation can touch them.
- Watch the project's security advisories page, not only your scanner, for SFTPGo and other self-hosted services.
- If you build file APIs, test every two-path operation against the read check, as in the model above.
The fair view
SFTPGo's maintainer handled this the right way. The fix doesn't just patch copy. It makes copy, rename and symlink agree with download, the commit message explains the reasoning, and the tests cover the cases. The advisory states plainly what is and isn't bypassed, which makes it easy to judge exposure.
The bug itself is the useful lesson. Name filters were added to a system that already had copy, and the new check was wired into the place a path gets written, which looked natural. Every system that grows a new restriction has the same risk: the obvious operations get the check, and the operations that touch two paths get it on one side. Asking "is this a read?" for every path an operation touches catches it.