Path traversal via unsanitized tag in tm-core file path builders (CWE-23) — regression vs legacy slugify
Summary
The tag value is interpolated raw into filesystem paths in two tm-core
file-path builders, with no sanitization on the read/generate path. A tag
containing ../ escapes the .taskmaster/ directory, giving a path-traversal
write (*.md) and read/probe (*.json) primitive outside the project root.
This looks like a parity regression: the legacy JS path slugifies the tag
(slugifyTagForFilePath, scripts/modules/utils.js:348), but the TypeScript
rewrite of tm-core dropped that step. A sanitizeTag() helper exists
(storage.interface.ts:471) but is never called anywhere.
I want to be calibrated up front: this is not RCE. The extension is forced
(.md on write, .json on read) and the written content is task-derived, not
fully attacker-controlled. I'd rate it Medium — a real, reachable traversal,
not a critical. Filing as an issue (no SECURITY policy / advisory channel found);
happy to move this to a private advisory if you prefer.
Affected code (commit c0c98d3)
Write sink — packages/tm-core/src/modules/tasks/services/task-file-generator.service.ts:134
private getTaskFileName(taskId, tag) {
return tag === 'master' ? `task_${id}.md` : `task_${id}_${tag}.md`; // tag raw
}
// :93 path.join(outputDir, fileName) -> fs.writeFile (path.join does NOT strip "..")Read sink — packages/tm-core/src/modules/reports/managers/complexity-report-manager.ts:32
private getReportPath(tag) {
const tagSuffix = tag && tag !== 'master' ? `_${tag}` : ''; // tag raw
return path.join(reportsDir, `task-complexity-report${tagSuffix}.json`);
}
// :55 fs.access + fs.readFileWhy it's reachable (the part that makes it real)
tag reaches these sinks unvalidated from three sources. validateTagName
(the strict ^[a-zA-Z0-9_-]+$ regex in tag.service.ts) only runs on
create/rename/copy — never on read/generate.
- MCP / CLI arg —
generate,get-tasks,get-task,set-task-statusdeclaretag: z.string().optional()with no.regex/.refine(apps/mcp/src/tools/tasks/generate.tool.ts:22). The value is passed straight togenerateTaskFiles({ tag }). .taskmaster/state.json→currentTag— read raw inruntime-state-manager.service.ts:53. This is the zero-interaction vector: a repo committed with a poisonedstate.jsontriggers traversal as soon as a victim runstask-master generate/liston the clone.TASKMASTER_TAGenv var — same raw assignment (:63).
Reproduction (executed PoC)
I ran this against the real tm-core source inside an isolated, offline
container (node:22-alpine, --network none, read-only rootfs). The read path
exercises the actual ComplexityReportManager class (only its getLogger import is
stubbed — all path logic is untouched); the write path runs the verbatim
getTaskFileName + path.join + fs.writeFile from the generator. Project root is
/work/project; the attacker escapes to /work/escape (outside the project).
=== READ — real ComplexityReportManager.loadReport(tag) ===
projectRoot : /work/project
attacker tag : "../../../../../escape/secret"
RESOLVED read path : /work/escape/secret.json <-- outside project
escapes project? : true
loadReport() result : FILE READ + PARSED (traversal works)
leaked meta.secret : THIS_FILE_IS_OUTSIDE_THE_PROJECT_ROOT
=== WRITE — verbatim from task-file-generator.service.ts ===
outputDir : /work/project/.taskmaster/tasks
attacker tag : "../../../../../escape/pwned"
RESOLVED write path : /work/escape/pwned.md <-- outside project
escapes project? : true
write confirmed : # attacker-written task file (outside project root)Note on depth: the glued prefix (task-complexity-report_.. / task_001_..) is a
literal directory segment, so it costs two .. to undo — five ../ reach the
grandparent of .taskmaster/ here. An attacker just adds segments; the primitive
is the unsanitized tag, not the exact depth.
A poisoned .taskmaster/state.json of { "currentTag": "../../../../../escape/pwned" }
reaches the same sink via generate with no flags and no prompt-injection needed
(currentTag is read raw in runtime-state-manager.service.ts:53).
Impact (honest)
- Write: create/overwrite any
*.mdfile in any writable location (e.g. an autostart.md, a doc served elsewhere, a README in another repo). - Read: file-existence +
.json-shape oracle on arbitrary paths (content is consumed for complexity enrichment, not returned verbatim — limited exfil). - Constrained by the forced
.md/.jsonextension → not arbitrary-file overwrite of e.g.~/.bashrc, and not RCE.
CWE-22 / CWE-23 / CWE-73.
Suggested fix
- Factor a single tag-aware path resolver shared by both builders (kills the
legacy/TS parity drift), and call
sanitizeTag(or reject) inside it. - Validate
tagon the read/generate path too (same regex as create), or reject any tag containing/,\,.., or NUL — including the value read fromstate.jsonandTASKMASTER_TAG. - Defense-in-depth: assert
resolved.startsWith(baseDir + path.sep)after resolution. - Negative-oracle tests:
../../x,..\\..\\\\x, NUL → rejected;feature-v2→ allowed.
I'm happy to send a focused PR if you'd like (I'll follow the repo's CodeRabbit docstring coverage and AI-assistance disclosure conventions).
Disclosure: this analysis was AI-assisted; findings were human-reviewed and the
PoC above was executed against the real source in an isolated, offline
(--network none) container before filing — no network calls, no out-of-sandbox
writes, nothing submitted automatically.
Source: eyaltoledano/claude-task-master