GHSA-J48M-H7XQ-2XPJ
Vulnerability from github – Published: 2026-07-01 21:59 – Updated: 2026-07-01 21:59Share-link ?token=… redemption races past download limit
Ecosystem: Go
Package: goshs.de/goshs/v2 (github.com/patrickhener/goshs)
Affected: <= v2.0.9 (every release that shipped the share-link feature)
Summary
ShareHandler reads the share token's DownloadLimit under RLock, releases the lock, serves the file, then re-acquires the lock to increment the counter. Concurrent requests all read the same Downloaded/DownloadLimit snapshot, all pass the check, and all are served — exceeding the operator's intended cap.
Details
httpserver/handler.go:968-1018:
fs.sharedLinksMu.RLock()
entry, ok := fs.SharedLinks[token]
fs.sharedLinksMu.RUnlock() // <-- released here
if entry.DownloadLimit > 0 || entry.DownloadLimit == -1 {
// ...serve file... // <-- whole transfer happens unlocked
}
fs.sharedLinksMu.Lock() // <-- re-acquired only now
current.Downloaded++
if current.Downloaded >= current.DownloadLimit { delete(fs.SharedLinks, token) }
fs.sharedLinksMu.Unlock()
Between line 978 (RUnlock) and line 1008 (Lock), any number of goroutines can interleave and each observes the same pre-increment limit.
Proof of concept
goshs -p 18000 -d /tmp/r -b admin:pw &
echo data > /tmp/r/f.txt
# operator issues a one-shot share
SHARE=$(curl -su admin:pw "http://localhost:18000/f.txt?share&limit=1")
TK=$(echo "$SHARE" | sed -n 's/.*token=\([^"]*\)".*/\1/p')
# attacker races two redemptions
curl -so /dev/null -w "%{http_code}\n" "http://localhost:18000/?token=$TK" & \
curl -so /dev/null -w "%{http_code}\n" "http://localhost:18000/?token=$TK" & \
wait
# observed: 200 / 200 (both succeed) -> limit=1 redeemed twice
Reproduced 5/5 times in a row on a 2026-era M-series Mac during verification.
Impact
A "single-use" share intended to deliver a one-shot secret can be redeemed N times by N concurrent clients. Combined with any token-leak vector (mail forwarding, browser history, intercepted link, etc.) this multiplies the exfiltration window.
Suggested fix
Reserve under the write lock before serving — refund only if the serve fails:
fs.sharedLinksMu.Lock()
entry, ok := fs.SharedLinks[token]
if !ok || time.Now().After(entry.Expires) ||
(entry.DownloadLimit != -1 && entry.Downloaded >= entry.DownloadLimit) {
fs.sharedLinksMu.Unlock(); http.NotFound(w, r); return
}
entry.Downloaded++
if entry.DownloadLimit != -1 && entry.Downloaded >= entry.DownloadLimit {
delete(fs.SharedLinks, token)
} else {
fs.SharedLinks[token] = entry
}
fs.sharedLinksMu.Unlock()
// ...serve...
Add a regression test that races two requests against a limit=1 token and asserts exactly one 200.
Reporter: Nishant Verma. Reproduced against goshs v2.0.9 (commit 8fc1e91) on 2026-05-27.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.0.9"
},
"package": {
"ecosystem": "Go",
"name": "goshs.de/goshs/v2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-50139"
],
"database_specific": {
"cwe_ids": [
"CWE-362"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-01T21:59:08Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# Share-link `?token=\u2026` redemption races past download limit\n\n**Ecosystem:** Go\n**Package:** `goshs.de/goshs/v2` (`github.com/patrickhener/goshs`)\n**Affected:** `\u003c= v2.0.9` (every release that shipped the share-link feature)\n\n## Summary\n\n`ShareHandler` reads the share token\u0027s `DownloadLimit` under `RLock`, releases the lock, serves the file, then re-acquires the lock to increment the counter. Concurrent requests all read the same `Downloaded`/`DownloadLimit` snapshot, all pass the check, and all are served \u2014 exceeding the operator\u0027s intended cap.\n\n## Details\n\n[`httpserver/handler.go:968-1018`](https://github.com/patrickhener/goshs/blob/v2.0.9/httpserver/handler.go#L968-L1018):\n\n```go\nfs.sharedLinksMu.RLock()\nentry, ok := fs.SharedLinks[token]\nfs.sharedLinksMu.RUnlock() // \u003c-- released here\n\nif entry.DownloadLimit \u003e 0 || entry.DownloadLimit == -1 {\n // ...serve file... // \u003c-- whole transfer happens unlocked\n}\n\nfs.sharedLinksMu.Lock() // \u003c-- re-acquired only now\ncurrent.Downloaded++\nif current.Downloaded \u003e= current.DownloadLimit { delete(fs.SharedLinks, token) }\nfs.sharedLinksMu.Unlock()\n```\n\nBetween line 978 (`RUnlock`) and line 1008 (`Lock`), any number of goroutines can interleave and each observes the same pre-increment limit.\n\n## Proof of concept\n\n```bash\ngoshs -p 18000 -d /tmp/r -b admin:pw \u0026\necho data \u003e /tmp/r/f.txt\n\n# operator issues a one-shot share\nSHARE=$(curl -su admin:pw \"http://localhost:18000/f.txt?share\u0026limit=1\")\nTK=$(echo \"$SHARE\" | sed -n \u0027s/.*token=\\([^\"]*\\)\".*/\\1/p\u0027)\n\n# attacker races two redemptions\ncurl -so /dev/null -w \"%{http_code}\\n\" \"http://localhost:18000/?token=$TK\" \u0026 \\\ncurl -so /dev/null -w \"%{http_code}\\n\" \"http://localhost:18000/?token=$TK\" \u0026 \\\nwait\n# observed: 200 / 200 (both succeed) -\u003e limit=1 redeemed twice\n```\n\nReproduced 5/5 times in a row on a 2026-era M-series Mac during verification.\n\n## Impact\n\nA \"single-use\" share intended to deliver a one-shot secret can be redeemed N times by N concurrent clients. Combined with any token-leak vector (mail forwarding, browser history, intercepted link, etc.) this multiplies the exfiltration window.\n\n## Suggested fix\n\nReserve under the write lock *before* serving \u2014 refund only if the serve fails:\n\n```go\nfs.sharedLinksMu.Lock()\nentry, ok := fs.SharedLinks[token]\nif !ok || time.Now().After(entry.Expires) ||\n (entry.DownloadLimit != -1 \u0026\u0026 entry.Downloaded \u003e= entry.DownloadLimit) {\n fs.sharedLinksMu.Unlock(); http.NotFound(w, r); return\n}\nentry.Downloaded++\nif entry.DownloadLimit != -1 \u0026\u0026 entry.Downloaded \u003e= entry.DownloadLimit {\n delete(fs.SharedLinks, token)\n} else {\n fs.SharedLinks[token] = entry\n}\nfs.sharedLinksMu.Unlock()\n// ...serve...\n```\n\nAdd a regression test that races two requests against a `limit=1` token and asserts exactly one `200`.\n\nReporter: Nishant Verma. Reproduced against `goshs v2.0.9` (commit `8fc1e91`) on 2026-05-27.",
"id": "GHSA-j48m-h7xq-2xpj",
"modified": "2026-07-01T21:59:08Z",
"published": "2026-07-01T21:59:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/patrickhener/goshs/security/advisories/GHSA-j48m-h7xq-2xpj"
},
{
"type": "PACKAGE",
"url": "https://github.com/patrickhener/goshs"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "goshs: Share-link ?token=\u2026 redemption races past download limit"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.