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 file lists tuned for exploitation, or a runnable proof of concept.

Executive summary

CVE-2026-86538 is a path-traversal vulnerability in knowns before 0.30.0. The POST /api/templates/preview endpoint accepted a caller-supplied templateFile value, joined it onto the resolved template directory with filepath.Join, read the resulting path from disk, and returned the contents in the JSON response.

filepath.Join cleans the path it produces. It does not constrain the result to the base directory. A templateFile containing parent-directory segments therefore resolves outside the template root, and the endpoint happily reads and returns it.

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

The PR:N in that vector is not an accident. It is the direct consequence of CVE-2026-86543, which left the management API unauthenticated on all interfaces by default. The two together make this a credential-free remote file-read primitive against a developer’s machine.

Fixed in knowns 0.30.0 by commit 09c5a96fd581.

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-86538 Verified
Advisory GHSA-fpxv-c555-rhm3 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 templateFile field of POST /api/templates/preview Verified
Dangerous sink os.ReadFile on a filepath.Join result Verified
Authentication required None, by default Verified
Impact Disclosure of any file readable by the server process Verified
CVSS 4.0 8.7 High Verified
CVSS 3.1 7.5 High Verified
Reporters Tong Hoang Gia, Nguyen Huy Hoang Verified
Fix commit 09c5a96fd5817b941dc86669278c1a17db10ed4e Verified

Why a “preview” endpoint is a file-read primitive

Template preview features share a recognisable shape across products: take a template identifier, take a file name inside that template, render it with some variables, show the result. Each step looks harmless. The composite is a request-controlled read whose output is returned verbatim to the caller.

Three properties make this variant more serious than the average traversal bug:

  • The output is the file, not a side effect. Some traversal bugs only leak existence or a boolean. This one returns content in the response body.
  • There is no extension constraint. The default is main.hbs, but nothing requires the supplied value to end in .hbs. Any readable path is fair game.
  • The process runs as the developer. knowns is a developer tool, so the reading process holds the developer’s own filesystem privileges — not a hardened service account.

Inferred: the interesting targets are therefore not project files, which the caller could often read through legitimate endpoints anyway, but the developer’s ambient secrets: environment files, cloud credential directories, SSH material, shell history, and the tool’s own configuration.

Root-cause analysis

The vulnerable handler in internal/server/routes/templates.go at v0.29.1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// POST /api/templates/preview
func (tr *TemplateRoutes) preview(w http.ResponseWriter, r *http.Request) {
var req previewTemplateRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}

tmpl, err := tr.getStore().Templates.Get(req.Name)
if err != nil {
respondError(w, http.StatusNotFound, "template not found: "+err.Error())
return
}

templateFile := req.TemplateFile
if templateFile == "" {
templateFile = "main.hbs"
}

hbsPath := filepath.Join(tmpl.Path, templateFile)
hbsContent, err := os.ReadFile(hbsPath)
if err != nil {
respondError(w, http.StatusNotFound, "template file not found: "+templateFile)
return
}

// Simple variable replacement: {{varName}} → value
rendered := string(hbsContent)
if req.Variables != nil {
for k, v := range req.Variables {
rendered = strings.ReplaceAll(rendered, "{{"+k+"}}", v)
}
}
...
}

The bug is one line, and it is a line that appears in a great deal of Go code:

1
hbsPath := filepath.Join(tmpl.Path, templateFile)

The filepath.Join misconception

filepath.Join joins elements and calls Clean on the result. Developers frequently read “cleans the path” as “makes the path safe”. It does not. Clean resolves .. segments lexically, which means it happily produces a path above the base directory when the untrusted element asks for it. Joining a base with ../../x yields a sibling of the base’s parent, not an error and not a path inside the base.

Nothing in the handler re-checks the result. There is no strings.HasPrefix containment check, no filepath.Rel verification, and no symlink resolution.

The variable substitution runs after the read

1
2
rendered := string(hbsContent)
if req.Variables != nil { ... }

Substitution only replaces {{name}} placeholders that happen to appear in the content. A file with no placeholders passes through unchanged, so the response contains the raw file. Inferred: this makes the primitive clean rather than lossy — the attacker does not have to reconstruct content from partial rendering artefacts.

The data flow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /api/templates/preview   (unauthenticated by default)
|
| { "name": <known template>, "templateFile": <untrusted> }
v
Templates.Get(name) -> tmpl.Path (trusted base)
|
v
filepath.Join(tmpl.Path, templateFile) <-- lexical join, no containment
|
v
os.ReadFile(...) <-- reads as the developer's user
|
v
JSON response body <-- content returned to caller

Note that Templates.Get(req.Name) is validated — the template must exist. The developer applied validation to the parameter that felt like an identifier and omitted it on the parameter that felt like a filename. Inferred: that asymmetry is a common and instructive pattern; the field that looks most like a path is the one that most needs a containment check, and it is often the one that gets the least.

Impact

Verified impact

  • An unauthenticated network caller could read arbitrary files accessible to the server process.
  • Contents are returned directly in the JSON response.
  • The CVE record specifically cites credentials and configuration as exposed material.
  • Integrity and availability are not affected by this issue in isolation (VI:N/VA:N).

Deployment-dependent consequences

The following are inferred:

  • disclosure of .env files, cloud credential files, API tokens, and CI secrets present on the host;
  • disclosure of SSH private keys and configuration, and of the knowns configuration itself;
  • disclosure of source code, internal specifications, and architectural decisions outside the current project;
  • reconnaissance that makes the write-side issues in this set more effective, because an attacker who can read .knowns/config.json and the project layout knows exactly where to aim CVE-2026-86542;
  • onward compromise of any service whose credentials were recovered — the blast radius is set by what the developer’s account can reach, not by knowns.

Inferred: for a developer workstation, a reliable arbitrary file read is frequently equivalent to a credential compromise, because development machines are where long-lived tokens accumulate.

Safe validation and detection

Inventory and configuration review

  1. Identify knowns installations and record versions; anything before 0.30.0 is affected.
  2. Determine whether the management API was reachable beyond loopback — see CVE-2026-86543 for the bind-address and password checks.
  3. Confirm the exposure window: how long did an affected version run, on what networks, and was a tunnel ever started?
  4. Inventory what was readable by that user account during the window. That set, not the project directory, is the exposure scope.

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

Telemetry to review

  • POST /api/templates/preview requests where templateFile is present and is not a plain relative name;
  • any templateFile value containing parent-directory segments, absolute path prefixes, or backslashes;
  • preview requests from source addresses other than loopback;
  • response sizes on the preview endpoint that are far outside the normal distribution for templates;
  • bursts of preview requests iterating over similar names, which is the signature of enumeration rather than use;
  • reads of sensitive files by the knowns process in host filesystem audit logs (auditd, EDR, or equivalent).

Inferred: the last item is the most reliable signal, because it observes the sink rather than the request. Request logging can be absent or truncated, but a process-level read of a credential file by a documentation tool is anomalous on its face.

Remediation and hardening

Required remediation

Upgrade to knowns 0.30.0 or later. The fix introduces a dedicated internal/safepath package and routes the preview through it:

1
2
3
4
5
6
hbsPath, err := safepath.Resolve(tmpl.Path, templateFile)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid template file path")
return
}
hbsContent, err := os.ReadFile(hbsPath)

safepath.Resolve is the interesting part of the patch, because it treats path validation as a real parsing problem rather than a substring check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Package safepath resolves untrusted filesystem paths inside an explicit root.
func resolve(root, untrusted string, allowContainedAbsolute bool) (string, error) {
if strings.TrimSpace(untrusted) == "" {
return "", fmt.Errorf("path is required")
}
if strings.IndexByte(untrusted, 0) >= 0 {
return "", fmt.Errorf("path contains a NUL byte")
}

normalized := strings.ReplaceAll(untrusted, `\`, "/")
if strings.HasPrefix(normalized, "//?/") || strings.HasPrefix(normalized, "//./") {
return "", fmt.Errorf("path must not use a Windows device prefix")
}
if strings.HasPrefix(normalized, "/") && !allowContainedAbsolute {
return "", fmt.Errorf("path must be relative")
}
...
for i, segment := range strings.Split(normalized, "/") {
if segment == ".." {
return "", fmt.Errorf("path must not contain parent traversal")
}
...
}
...
}

Worth noting about that implementation:

  • it rejects rather than sanitises, so there is no strip-and-retry logic to outsmart;
  • it normalises backslashes before segmenting, so Windows separators cannot smuggle a segment past a Unix-oriented check;
  • it rejects NUL bytes, Windows device prefixes (\\?\, \\.\), volume and UNC prefixes, drive-relative paths, and NTFS alternate data streams;
  • it resolves against an absolute, cleaned root and — in the ResolveProjectReal variant — follows symlinks so a link inside the root cannot point outside it.

The same commit applies containment to the document store, memory store, and template store; those paths are covered in CVE-2026-86439.

Defense in depth

  • Bind the server to loopback and set a password (both are enforced by default in 0.30.0).
  • Run developer context servers with the narrowest practical filesystem view; a container or a dedicated user account bounds what any future read primitive can reach.
  • Keep long-lived cloud and registry credentials out of the developer home directory; prefer short-lived, brokered credentials.
  • Rotate any secret that was readable by the account during the exposure window if you cannot rule out access.
  • Where you must accept a filename from a client, treat the allowed set as an enumeration (a whitelist of known template files) rather than a path at all.

Timeline

Date Event Source certainty
2026-08-15 Fix commit 09c5a96fd581 authored in the knowns repository Repository metadata
2026-08-16 Public disclosure date recorded on the CVE record CVE record datePublic
2026-09-07 CVE-2026-86538 reserved and published CVE record metadata
2026-09-08 This analysis published This site

Lessons for builders

filepath.Join is not a security boundary

This is the single most reusable takeaway. Join composes and cleans; it never constrains. Any time an untrusted element is joined onto a trusted base, the result must be validated against that base afterwards — or, better, produced by a function whose contract is containment.

Reject, do not sanitise

Stripping .. from a string is a losing game: encodings, mixed separators, and repeated sequences all defeat naive filters. The 0.30.0 approach — normalise, then refuse anything that is not a plain relative path inside the root — has no residual cleverness to be outwitted.

Validation applied unevenly is validation missing

req.Name was validated. req.TemplateFile was not. The handler’s author clearly understood that input needed checking; the check simply did not follow the value that reached the filesystem. Trace each parameter to its sink independently.

Confidentiality-only findings still deserve High

A vector with VI:N/VA:N can read as “just an info leak”. On a developer workstation, a general file read is the credential-collection step of an intrusion. Score the primitive, not the immediate symptom.

Centralise the dangerous operation

The most durable part of this fix is not the changed line — it is that safepath now exists. One reviewed, tested implementation that every call site uses is far more defensible than a containment check re-derived at each handler, and it gives future contributors an obvious right answer.

  • CVE-2026-86543 — unauthenticated management API exposure (the reason this one is PR:N)
  • CVE-2026-86542 — the write-side counterpart, via import name
  • CVE-2026-86439 — path traversal through MCP doc and memory tools
  • CVE-2026-86541 — path traversal in the code.replace MCP action
  • CVE-2026-86544 — authorisation bypass via misclassified code actions
  • CVE-2026-86540 — arbitrary code execution via the LSP binary override
  • CVE-2026-86775 — unauthenticated path traversal through the HTTP Document API

References

Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.