Path traversal via unsanitized tag in tm-core file path builders (CWE-23) — regression vs legacy slugify

Author: zied-jlassiCreated Jun 22, 2026Updated Jun 22, 2026

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 sinkpackages/tm-core/src/modules/tasks/services/task-file-generator.service.ts:134

typescript
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 sinkpackages/tm-core/src/modules/reports/managers/complexity-report-manager.ts:32

typescript
private getReportPath(tag) {
  const tagSuffix = tag && tag !== 'master' ? `_${tag}` : '';      // tag raw
  return path.join(reportsDir, `task-complexity-report${tagSuffix}.json`);
}
// :55  fs.access + fs.readFile

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

  1. MCP / CLI arggenerate, get-tasks, get-task, set-task-status declare tag: z.string().optional() with no .regex/.refine (apps/mcp/src/tools/tasks/generate.tool.ts:22). The value is passed straight to generateTaskFiles({ tag }).
  2. .taskmaster/state.jsoncurrentTag — read raw in runtime-state-manager.service.ts:53. This is the zero-interaction vector: a repo committed with a poisoned state.json triggers traversal as soon as a victim runs task-master generate / list on the clone.
  3. TASKMASTER_TAG env 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 *.md file 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/.json extension → not arbitrary-file overwrite of e.g. ~/.bashrc, and not RCE.

CWE-22 / CWE-23 / CWE-73.

Suggested fix

  1. Factor a single tag-aware path resolver shared by both builders (kills the legacy/TS parity drift), and call sanitizeTag (or reject) inside it.
  2. Validate tag on the read/generate path too (same regex as create), or reject any tag containing /, \, .., or NUL — including the value read from state.json and TASKMASTER_TAG.
  3. Defense-in-depth: assert resolved.startsWith(baseDir + path.sep) after resolution.
  4. 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