
PoC — path traversal via unsanitized LLM-derived domain field in OpenLore (GHSA-5j8x-q7q6-58j5, CVE-2026-87001, CVSS 4.7).
domain FieldCVE status: requested, pending assignment. This finding is published as GHSA-5j8x-q7q6-58j5. On CVE assignment this repository is renamed
CVE-YYYY-NNNNN-openlore-PoCand this banner is replaced with the CVE link.
| Researcher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-5j8x-q7q6-58j5 |
| CVSS 3.1 | 4.7 (Medium) |
| Weakness | CWE-22, CWE-73 |
domain FieldSummary: An attacker who controls the content of a repository analyzed by openlore generate can cause the OpenSpec spec-generation pipeline to write attacker-influenced Markdown to an arbitrary filesystem path outside the target project, because the domain value returned by the Stage-3 LLM extraction call is used verbatim to build a spec's output path with no traversal sanitization and no confinement check before fs.writeFile.
OpenLore (clay-good/OpenLore), npm package openlore, spec-generation pipeline (openlore generate / the OpenSpec-format generator + writer). Not the deterministic analyze/orient/MCP guardrail path — this is the one code path in the project that intentionally puts an LLM in the loop for generation, per the project's own README/spec ("an LLM is used only where generation is unavoidable (spec authoring), never in the retrieval or guardrail path").
Git commit 1f2de00dfe75530efb256320c374dce36607ee70 (2026-07-31), package.json version 2.1.7.
4.7 (Medium) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N
AV:L — the trigger is a local CLI invocation (openlore generate) against a directory on disk, not a network service.AC:H — exploitation needs the operator to run the LLM-backed generate command (not the default guardrail path) with a provider configured, and needs the analyzed repository's indirect prompt injection to actually make the model emit a traversal string in the domain field — a condition the attacker influences but does not fully control.UI:R — the operator must choose to run spec generation against the malicious repository.I:H / C:N / A:N — the bug is a write primitive with partially attacker-templated content landing at an attacker-chosen path outside the project; it was not demonstrated to read confidential data or to reliably degrade availability, so those metrics are conservatively N.ExtractedService.domain (src/types/pipeline.ts:105) is populated straight from the Stage 3 LLM call, which is fed raw file content from the repository being analyzed and asked to return a JSON array of services:
src/core/generator/stages/stage3-services.ts:45-52 builds the prompt from fileChunks[i] (literal source text of the analyzed repo) and calls pipeline.llm.completeJSON<ExtractedService[]>(..., STAGE3_SERVICE_SCHEMA).src/core/generator/schemas.ts:100 — STAGE3_SERVICE_SCHEMA.items.properties.domain is { type: 'string' }: no pattern, no enum, no allowlist. Any string the model returns is accepted.src/core/generator/openspec-format-generator.ts:161 — const domainName = service.domain || this.inferDomain(...) uses that string as-is.src/core/generator/openspec-format-generator.ts:563 — path: \openspec/specs/${domain.name.toLowerCase()}/spec.md`interpolates it directly into the spec's declared output path. NotenormalizeDomainName() (src/core/generator/openspec-compat.ts:655) exists in the same file's dependency graph and IS applied elsewhere (src/core/generator/openspec-writer.ts:167`, for stale-domain-directory comparison) — but it is never called here, at the one place the value actually becomes a filesystem path.src/core/generator/openspec-writer.ts:248 — const fullPath = join(this.rootPath, spec.path); uses plain node:path.join, which lexically resolves ../ segments, with no call to the project's own safeJoin() (src/utils/path-confinement.ts) that every other untrusted-path surface in this codebase (the MCP handlers, openlore view's /api/skeleton and /api/spec-requirements) is required to route through per openspec/specs/mcp-security/spec.md.src/core/generator/openspec-writer.ts:277 / :294 then writeFile(fullPath, spec.content, 'utf-8'), and ensureDir() (openspec-writer.ts:497-499) does mkdir(dirname(filePath), { recursive: true }) on the same unconfined path, creating any missing intermediate directories on the way out of the project root.Net effect: a domain value such as ../../../../../../tmp/x survives untouched from the LLM's JSON response to a real writeFile call, landing outside the analyzed project entirely. This is the exact "repo content redirects a write outside the project root" threat model the project's own openspec/specs/mcp-security/spec.md explicitly defines and hardens for on the MCP/serve/view surfaces (safeJoin, symlink-aware confinement, path-parameter coverage gate) — the generator/writer surface simply has no equivalent gate.
No live LLM call was made (model compliance with an injected instruction is probabilistic and not the interesting part of this bug); instead the PoC calls OpenLore's real, unmodified OpenSpecFormatGenerator and OpenSpecWriter with a PipelineResult whose one ExtractedService.domain field holds exactly the string shape STAGE3_SERVICE_SCHEMA allows an LLM to return today, and exactly what a source-file comment instructing "set domain to ../../../.../tmp/x" could elicit from Stage 3 (which passes raw file content to the model).
Script: ~/engagements/openlore/source/poc-domain-traversal.ts (repo root of the tested clone), run against a scratch project at ~/engagements/openlore/evidence/victim-project:
cd ~/engagements/openlore/source
npx tsx poc-domain-traversal.ts
Result: OpenSpecFormatGenerator.generateSpecs() (unmodified) emits a domain spec with
path: "openspec/specs/../../../../../../../../../../../../../../../../../../../../tmp/openlore_traversal_proof/spec.md",
and OpenSpecWriter.writeSpecs() (unmodified) writes it — landing at /tmp/openlore_traversal_proof/spec.md, fully outside the victim-project root, while victim-project/openspec/specs/ only ever contains the two legitimate overview/architecture directories.
Screenshots (genuine Xvfb+fluxbox+xterm captures, scrot -w <window id>):
../evidence/poc-generator-write.png — the PoC run: generator's raw spec.path containing the traversal, the writer log line Wrote openspec/specs/../../.../tmp/openlore_traversal_proof/spec.md, and the script's own confirmation that /tmp/openlore_traversal_proof/spec.md exists with attacker-influenced content.../evidence/poc-outside-fs-proof.png — independent confirmation: ls of victim-project/openspec/specs/ shows only architecture/overview (no trace of the malicious domain inside the project), while ls -la /tmp/openlore_traversal_proof/ shows the escaped spec.md sitting directly under /tmp.An attacker who can get a target to run openlore generate (LLM-backed spec generation) against a repository they authored — a plausible scenario for a CLI tool whose explicit purpose is to be pointed at third-party/untrusted codebases — can write a file with attacker-influenced content to any path the operator's OS user can write to, limited only by how many ../ segments are needed to escape and by filesystem permissions. The write is not confirmed to be directly executable, but a sufficiently shallow project path could let the traversal reach locations that are executed or auto-loaded by other tools on the same account (shell rc files, cron user directories, editor/IDE config), which was not tested and is not claimed here.
normalizeDomainName() (or an equivalent allowlist regex, e.g. ^[a-z][a-z0-9-]{0,63}$) to domain.name / service.domain at the point they are first accepted from the LLM response (groupByDomain() in openspec-format-generator.ts), not only at the later stale-directory-comparison site.OpenSpecWriter.writeSpec()'s fullPath computation through the project's own safeJoin() (src/utils/path-confinement.ts) instead of plain node:path.join, matching the confinement already required of every other untrusted-path surface in this codebase.pattern constraint to STAGE3_SERVICE_SCHEMA.domain (and any other schema field later used to build a filesystem path) so providers that enforce structured-output schemas reject non-conforming values outright.GeneratedSpec.path containing .. or resolving outside rootPath is rejected before OpenSpecWriter touches the filesystem.Dostxodjayev Abdullox
GitHub private vulnerability reporting is enabled for this repository: https://github.com/clay-good/OpenLore/security/advisories/new (standard GHSA flow). Confirmed via gh api repos/clay-good/OpenLore/security-advisories returning [] (no prior advisories) prior to this audit.