GHSA-rqrh-8wpv-x7hh Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
SummaryNote 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 codeSlug input validation (`backend/db/types.go`, v0.19.4):huma applies the pattern UNANCHORED (`github.com/danielgtaylor/huma/v2@v2.37.3`):`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:ImpactA 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 scenarioAttacker registers / uses any normal user account.Attacker `POST /api/books` (or a note) with `slug` = `../../../../../../etc/cron.d/x` (passes the unanchored `[a-z0-9-]+` pattern). Stored verbatim.Admin runs `note-mark migrate export-v1 --export-dir /data/backup` (root).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 conceptSelf-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.Verbatim output (`go run .`, huma v2.37.3, go1.26.1):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 reproductionAgainst the released image `ghcr.io/enchant97/note-mark-aio:0.19.4` (the GHSA-g49p fix release):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 fixApply `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: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 PRA 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.CreditReported by tonghuaroot.
CVSS v4.0 score: 8.6