CVE-2026-50553: Note Mark Path Traversal via Unsanitized Book/Note Slug in Migrate Export
Any authenticated Note Mark user can store a directory-traversal string as a book or note slug, and when an admin runs the data-export command that slug is joined into a filesystem path without saniti
The problem
Note Mark validates book and note slugs with the OpenAPI/huma tag `pattern:"[a-z0-9-]+"`. huma compiles and tests this pattern with `regexp.MustCompile` / `MatchString`, which performs an UNANCHORED substring match. A slug like `../../../../../../etc/cron.d/x` passes because the string contains the substring `etc`, which satisfies `[a-z0-9-]+`.
The data-export CLI commands (`note-mark migrate export` and `note-mark migrate export-v1`) join `book.Slug` and `note.Slug` raw into the output path via `path.Join`, then call `os.MkdirAll` and `os.Create`. Because `path.Join` resolves `../` segments, the note file `_index.md` is written outside the configured export directory.
These exports commonly run as root in Docker or bare-metal setups, making this an arbitrary directory-create plus arbitrary file-write as root, which can be escalated to code execution by targeting cron or systemd unit directories.
Proof of concept
A working proof-of-concept for CVE-2026-50553 in github.com/enchant97/note-mark/backend, with the exact payload below.
# Step 1: create a book with a traversing slug (passes the unanchored [a-z0-9-]+ pattern)
curl -s -X POST http://localhost:8080/api/books \
-H 'Content-Type: application/json' \
-b "Auth-Session-Token=$TOKEN" \
-d '{"name":"x","slug":"../../../../../../etc/cron.d/backdoor"}'
# Response echoes slug verbatim (HTTP 200/201) — slug is now stored in the DB.
# Step 2: admin triggers a routine export (commonly as root in Docker)
docker exec nm /note-mark migrate export-v1 --export-dir /data/backup
# path.Join("/data/backup", username, "../../../../../../etc/cron.d/backdoor")
# resolves to /etc/cron.d/backdoor — os.MkdirAll creates it,
# os.Create writes _index.md (attacker note content) there.The root cause is a missing regex anchor. huma calls `patternRe.MatchString(str)`, not `regexp.MatchString("^[a-z0-9-]+$", str)`. A traversal slug like `../../../../../../etc/cron.d/backdoor` always contains at least one `[a-z0-9-]` substring, so it always passes (CWE-20 at the input layer).
The export sink (`backend/cli/migrate.go`) uses `path.Join(exportDir, user.Username, book.Slug)` and `path.Join(bookDir, note.Slug)` with no further sanitization. Go's `path.Join` resolves `../` eagerly, so the joined path escapes `exportDir` entirely (CWE-22).
The sibling `asset.Name` field was already patched with `filepath.Base()` in GHSA-g49p-4qxj-88v3, but `book.Slug` and `note.Slug` in the exact same `path.Join` calls were left raw.
The fix anchors the slug pattern to `^[a-z0-9-]+$` at the DTO/input layer (so traversal strings are rejected before touching the DB) and adds defense-in-depth `filepath.Base()` checks in both export functions, skipping any slug whose `Base()` differs from its raw value.
The fix
Update to commit `67b7de04308a858ef27ceff87b514067b6d667e5` (pseudo-version `0.0.0-20260601210719-67b7de04308a`) or any tagged release that includes it. The patch anchors the huma pattern tags to `pattern:"^[a-z0-9-]+$"` (and `^[a-zA-Z0-9]+$` for usernames) so traversal slugs are rejected at the API boundary, and adds `filepath.Base()` guard checks to both `commandMigrateExportData` and `commandMigrateExportDataV1` in `backend/cli/migrate.go` as defense in depth.
Reported by tonghuaroot.
Related research
- high · 7.7CVE-2026-53553CVE-2026-53553: Goploy Arbitrary File Read via Path Traversal in /deploy/fileDiff
- high · 7.1CVE-2026-50163CVE-2026-50163: oras-go Hardlink Path Traversal via CWD Resolution
- high · 7.1CVE-2026-49339CVE-2026-49339: gonic Playlist ID Path Traversal Bypasses Ownership Check
- high · 8.1CVE-2026-49340CVE-2026-49340: gonic Arbitrary File Write via Path Traversal in createPlaylist