GHSA-rqrh-8wpv-x7hh: Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
## 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): ```go 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/[email protected]`): ```go // schema.go if s.Pattern != "" { s.patternRe = regexp.MustCompile(s.Pattern) ``` ```go // 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: ```go // 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 ``` ```go // 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 // go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3 package main import ( "contex
GHSA-rqrh-8wpv-x7hh: Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
Description
## 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): ```go 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/[email protected]`): ```go // schema.go if s.Pattern != "" { s.patternRe = regexp.MustCompile(s.Pattern) ``` ```go // 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: ```go // 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 ``` ```go // 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 // go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3 package main import ( "contex
CVSS v4.0
Affected software
Run on your own infrastructure? Check whether these packages are installed with threat-finder — our free open-source scanner.
Technical Details
- Gcve Source
- db.gcve.eu
- Osv Id
- GHSA-rqrh-8wpv-x7hh
- Osv Schema Version
- 1.4.0
- Aliases
- ["CVE-2026-50553"]
- Ecosystems
- ["Go"]
- Database Specific Severity
- HIGH
- Cvss Version
- 4.0
Threat ID: 6a50bac868715ace435892b3
Added to database: 07/10/2026, 09:26:32 UTC
Last updated: 07/10/2026, 09:26:32 UTC
Views: 1
Community Reviews
0 reviewsCrowdsource mitigation strategies, share intel context, and vote on the most helpful responses. Sign in to add your voice and help keep defenders ahead.
Want to contribute mitigation steps or threat intel context? Sign in or create an account to join the community discussion.
Actions
Need more coverage?
Upgrade to Pro Console for AI refresh and higher limits.
For incident response and remediation, OffSeq services can help resolve threats faster.
Latest Threats
Check if your credentials are on the dark web
Instant breach scanning across billions of leaked records. Free tier available.