Scope and safety. This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target path lists, or a runnable proof of concept.

Executive summary

CVE-2026-86775 is a path-traversal vulnerability in the HTTP Document API of knowns before 0.30.0. The route layer passes the caller-supplied document path through a helper called cleanDocPath(), which trims a leading slash, a trailing .md, and surrounding separators — and leaves parent-directory segments completely untouched. The storage layer then joins that value onto the documents directory with filepath.Join and never checks that the result stayed inside it.

The result is read, create, overwrite and delete of arbitrary .md files anywhere the process can reach, plus creation of arbitrary directories through os.MkdirAll.

The weakness is CWE-22: Improper Limitation of a Pathname to a Restricted Directory. The vendor scores it CVSS 4.0 8.8 High (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N) and CVSS 3.1 8.6 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L).

This is the eighth CVE in the knowns 0.30.0 set, and it is the most instructive one, because it is a variant rather than a new bug. CVE-2026-86439 covered the same unsafe join in internal/storage/doc_store.go reached through the MCP doc and memory tools. This CVE covers the same storage layer reached through the unauthenticated HTTP Document API — and where the MCP path was mostly a read primitive, this one carries write, delete, and directory creation with it.

The PR:N in the vector comes from CVE-2026-86543: the management API was unauthenticated and bound to every interface by default.

Fixed in knowns 0.30.0.

Evidence labels used below:

  • Verified means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.
  • Inferred means a deployment consequence derived from those facts and clearly marked as such.

Key facts

Item Finding Confidence
Identifier CVE-2026-86775 Verified
Advisory GHSA-3h35-4jq7-hv45 Verified
Product knowns (pkg:npm/knowns, Go implementation) Verified
Affected versions All versions before 0.30.0 Verified
Fixed version 0.30.0 Verified
Weakness Path traversal, CWE-22 Verified
Entry point Document path on the /api/docs routes Verified
Dangerous sinks os.MkdirAll, writeFile, os.Remove on a filepath.Join result Verified
Authentication required None, by default Verified
Operations exposed Read, create, overwrite, delete, plus directory creation Verified
File-type constraint .md for file operations; none for directory creation Verified
CVSS 4.0 8.8 High Verified
CVSS 3.1 8.6 High Verified
Reporters uziii2208, hoanggxyuuki Verified

The routes involved

From internal/server/routes/docs.go at v0.29.1:

1
2
3
4
5
6
7
8
func (dr *DocRoutes) Register(r chi.Router) {
r.Get("/docs", dr.list)
r.Post("/docs", dr.create)
// Wildcard routes must come after specific ones.
r.Get("/docs/*", dr.getOrHistory)
r.Post("/docs/*", dr.postDocAction)
r.Put("/docs/*", dr.update)
}

Two independent ways in. POST /api/docs takes the path from the JSON body. The wildcard routes take it from the URL, so a percent-encoded traversal survives routing and is decoded before it reaches the helper. Both funnel into the same place.

Root-cause analysis

The normaliser that normalises nothing dangerous

1
2
3
4
5
func cleanDocPath(raw string) string {
raw = strings.TrimPrefix(raw, "/")
raw = strings.TrimSuffix(raw, ".md")
return strings.Trim(raw, "/")
}

Three operations, none of which constrains anything:

  • TrimPrefix(raw, "/") looks like it prevents an absolute path from replacing the base. It does not need to — Go’s filepath.Join already cleans a leading separator on the joined element. So this line changes nothing security-relevant while looking like it does.
  • TrimSuffix(raw, ".md") exists so the store can re-append the extension. It is a formatting concern.
  • Trim(raw, "/") strips surrounding separators. Cosmetic.

Parent-directory segments pass through completely untouched. The function is named clean, is called on every path the API accepts, and provides no containment whatsoever.

Inferred: this is the most reusable lesson in the whole knowns set. A helper named clean, sanitize, or normalize sitting on the boundary is exactly the thing that stops a reviewer from asking whether containment happens further down. The name did more damage than the missing check.

The sink joins and writes

From internal/storage/doc_store.go at v0.29.1:

1
2
3
4
5
6
7
8
9
10
func (ds *DocStore) Create(doc *models.Doc) error {
if doc.Path == "" {
return fmt.Errorf("doc path is required")
}
absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(doc.Path)+".md")
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
return fmt.Errorf("create doc dir: %w", err)
}
return ds.writeFile(absPath, doc)
}

Update is the same shape. Delete is the same shape with a different verb:

1
2
3
4
5
6
// Delete removes a doc file.
func (ds *DocStore) Delete(path string) error {
path = strings.TrimSuffix(path, ".md")
absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(path)+".md")
return os.Remove(absPath)
}

filepath.Join composes and cleans; it never constrains. Nothing here calls filepath.Rel, checks a prefix, or resolves symlinks. The only validation in Create is that the path is non-empty.

Two constraints, and why one of them does not hold

The forced .md suffix is real: every file operation lands on a Markdown file. That is where VC:L comes from — you cannot read /etc/shadow with this.

But os.MkdirAll(filepath.Dir(absPath), 0755) runs before the write and carries no extension constraint at all. The directory portion of the path is arbitrary. So the primitive is:

1
2
files       -> arbitrary .md, read / create / overwrite / delete
directories -> arbitrary, created on demand

Inferred: the asymmetry matters more than it first appears. A write primitive limited to one extension is still dangerous when the attacker also chooses where the parent directory goes, because it lets them construct the target tree rather than find one that already exists.

The auto-path branch

1
2
3
4
5
6
7
8
9
// Auto-generate path from title + folder when not provided.
if doc.Path == "" && doc.Title != "" {
slug := slugifyTitle(doc.Title)
if doc.Folder != "" {
doc.Path = doc.Folder + "/" + slug
} else {
doc.Path = slug
}
}

When path is absent, the handler builds one by concatenating folder with a slugified title. The title goes through slugifyTitle; folder does not. Inferred: a second caller-controlled field reaching the same join, through a branch a reviewer checking the path parameter would not think to look at.

The data flow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /api/docs            (unauthenticated by default)
PUT /api/docs/*
GET /api/docs/*
|
| path from JSON body, or percent-decoded from the URL
v
cleanDocPath() <-- trims cosmetics, leaves ".." intact
|
v
filepath.Join(docsDir, FromSlash(path) + ".md") <-- lexical, no containment
|
+--> os.MkdirAll(Dir(absPath), 0755) <-- arbitrary directory
+--> writeFile(absPath, doc) <-- arbitrary .md write
+--> os.Remove(absPath) <-- arbitrary .md delete
|
v
Runs as the developer's own user

Why this got a second CVE

The unsafe join in doc_store.go was already public as CVE-2026-86439. Assigning a second identifier is correct, and the reason is worth stating plainly:

CVE-2026-86439 CVE-2026-86775
Entry point MCP doc and memory tools HTTP Document API
Caller An AI assistant’s tool call Any client that can reach the port
Privileges PR:L — needs a session PR:N — none
Operations Read, create, overwrite, delete Same, plus arbitrary mkdir
Also covers Memory store IDs Doc routes only

Same defect, different reachability and a different threat model. Inferred: this is textbook variant analysis — once you know a sink is unguarded, the work is enumerating every caller that reaches it, not re-finding the bug. Fixing one entry point would have left the other wide open, which is precisely why the vendor fixed the storage layer instead.

Impact

Verified impact

  • A remote unauthenticated attacker could read, create, overwrite and delete arbitrary .md files accessible to the process.
  • Arbitrary directories could be created via os.MkdirAll.
  • Documentation belonging to other projects on the same host could be exposed.
  • Files could be corrupted or destroyed.
  • The CVE record explicitly describes the result as an arbitrary-write primitive that may be chained toward code execution.
  • Integrity impact is High; confidentiality and availability are Low.

Deployment-dependent consequences

The following are inferred:

  • disclosure of specifications, notes and architectural decisions from unrelated projects on the same machine;
  • overwriting agent instruction files, project READMEs and decision records — Markdown is exactly the file type an AI assistant treats as authoritative, so the .md constraint bites far less here than it would elsewhere;
  • destruction of documentation that may not be under version control;
  • chaining toward execution by planting Markdown where another tool will act on it, or by combining the directory-creation primitive with the other write path in CVE-2026-86542;
  • persistence that outlives the network exposure, because the payload is a file.

Inferred: for a tool whose entire purpose is feeding documents to a coding assistant, “attacker can write arbitrary Markdown” is much closer to “attacker can influence the code you ship” than the CVSS confidentiality rating suggests.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and versions; anything before 0.30.0 is affected.
  2. Establish whether the API was reachable beyond loopback and for how long — see CVE-2026-86543 for the bind-address and password checks.
  3. Review the docs directory and, more importantly, the directories around it for unexpected .md files or unexplained new folders.
  4. Treat the exposure scope as every path the account could write, not the project directory.

Do not validate by sending traversal payloads at a shared or production host. Version and bind-address facts answer the question without touching the vulnerable path.

Telemetry to review

  • POST /api/docs, PUT /api/docs/* and GET /api/docs/* where the path is not a plain relative name;
  • any path value containing parent-directory segments, in raw or percent-encoded form;
  • POST /api/docs bodies carrying a folder field that is not a simple name;
  • Markdown files created or modified outside the project tree;
  • directories created by the knowns process outside its data directory — this is the highest-signal event, because a documentation tool has no legitimate reason to do it;
  • os.Remove activity in host filesystem audit logs attributed to the process.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix introduces a single resolver and routes every document operation through it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func resolveDocPath(root, path string) (string, string, error) {
path = strings.TrimSuffix(path, ".md")
if strings.TrimSpace(path) == "" {
return "", "", fmt.Errorf("doc path is required")
}
absPath, err := safepath.Resolve(root, filepath.ToSlash(path)+".md")
if err != nil {
return "", "", fmt.Errorf("invalid doc path %q: %w", path, err)
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return "", "", fmt.Errorf("resolve doc path: %w", err)
}
canonical := strings.TrimSuffix(filepath.ToSlash(rel), ".md")
return canonical, absPath, nil
}

Create, Update, Delete and Get all call it. safepath.Resolve rejects rather than sanitises — absolute paths, parent traversal, NUL bytes, Windows device and volume prefixes, and symlink escapes all fail closed. That package is covered in more detail in the CVE-2026-86538 writeup.

The detail worth copying is what the patch did not touch. cleanDocPath is byte-for-byte identical in 0.30.0:

1
2
3
4
5
func cleanDocPath(raw string) string {
raw = strings.TrimPrefix(raw, "/")
raw = strings.TrimSuffix(raw, ".md")
return strings.Trim(raw, "/")
}

The maintainers deliberately left the route-layer normaliser alone and put containment at the sink. That is the right call. Hardening cleanDocPath would have fixed the reported routes and left every other caller of DocStore — including the MCP tools from CVE-2026-86439 — still exposed. Guarding the function that touches the filesystem fixes all callers at once, including ones not yet written.

resolveDocPath also returns a canonical path derived from filepath.Rel, so the value stored back on the document is the one that was actually validated. That closes the gap where a record could carry a path different from the file it was written to.

Defense in depth

  • Bind to loopback and set a password (both are default in 0.30.0).
  • Treat any endpoint that accepts a path as a privileged operation, even a read-only-looking one.
  • Run developer context servers with the narrowest practical filesystem view.
  • Enable integrity monitoring on agent instruction files and project documentation, not just on code.
  • Audit for unexpected writes rather than assuming absence of an alert means absence of a write.

Timeline

Date Event Source certainty
2026-08-14 Public disclosure date recorded on the CVE record CVE record datePublic
2026-09-09 CVE-2026-86775 published CVE record metadata
2026-09-10 This analysis published This site

Lessons for builders

A helper named clean is a liability if it does not contain

cleanDocPath trimmed three cosmetic things and let traversal through. The name told every subsequent reader that the path had been handled. If a function only formats, call it formatDocPath — and if it is supposed to make the value safe, make it return an error.

Fix the sink, not the route

The patch left the route-layer normaliser untouched and hardened the storage layer. That single decision fixed both this CVE and the MCP-facing variant, and it protects call sites nobody has written yet. Guarding entry points is a maintenance treadmill; guarding the dangerous operation is durable.

Enumerate callers, not bugs

The same unguarded join produced two CVEs with different reachability and different privilege requirements. When you find an unsafe sink, the remaining work is mechanical: list every path that reaches it. Grep for the idiom, not for the reported endpoint.

An extension filter is not a boundary

Restricting writes to .md sounds like containment until you notice os.MkdirAll on the same line has no such restriction — and that for an AI documentation tool, Markdown is the sensitive file type.

Check every field that reaches the path

path was the obvious parameter. folder reached the same join through the auto-generate branch, unslugified. Trace each input to its sink independently, including the ones that only appear on a fallback path.

Eight CVEs were assigned to knowns before 0.30.0 and fixed in the same release:

References

Reported by uziii2208 and hoanggxyuuki. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.