Skip to content

Commit 2bfaf9e

Browse files
committed
Harden materialize: build the destination from a validated name, never git's path string
gitshow-research found a real hole in this module. `git ls-tree` can emit path strings containing literal `../` segments -- git does not sanitise tree-entry names (a tree built via mktree can name an entry `..`). The previous code derived the output path by slicing the raw git path, so a crafted entry could aim the write, with only the safeJoin containment check as a backstop. The fix removes the class rather than guarding it: the skill name is now a validated capture group (^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$ -- lowercase, no dots so no "..", no slashes), and the destination is built from a FIXED TEMPLATE (pi/skills/<name>/SKILL.md), never from git's reported string. classifyPiPath is the single choke point; selectEntries carries the template-derived outRel. safeJoin still re-checks containment as defence in depth and now splits the posix outRel into host segments (correct on Windows). Tests add the traversal-name attack directly: `.pi/skills/../SKILL.md`, `.pi/skills/../../etc/SKILL.md`, dotted and uppercase names all reject. The existing real-git hostile-object tests (symlink-to-/etc/passwd, submodule) still pass. Files written 0o444 as belt-and-braces behind the :ro mount. This is the second security finding folded in from the git-extraction research; the git-fetch-by-sha extraction method it validated (no clone, no checkout, no archive/tar -- archive+tar recreates live symlinks on disk) will be used by the worker's clone step.
1 parent fb90fa3 commit 2bfaf9e

2 files changed

Lines changed: 57 additions & 21 deletions

File tree

worker/src/materialize.mjs

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,41 @@ const exec = promisify(execFile);
2626
*/
2727

2828
const PI_DIR = ".pi";
29-
// Exactly the two shapes we accept. Anything else in .pi/ is ignored, not materialised.
3029
const APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
31-
const SKILL_RE = /^\.pi\/skills\/[A-Za-z0-9._-]+\/SKILL\.md$/;
30+
// A skill directory name: lowercase kebab/underscore, 1-64 chars, no dots (so no "..") and no
31+
// slashes. This is what makes a traversal name impossible at the source. Matched against the
32+
// CAPTURED segment only, never the whole path.
33+
const SKILL_PATH_RE = /^\.pi\/skills\/([a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?)\/SKILL\.md$/;
34+
const SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
3235

33-
/** A git tree path we are willing to materialise. Rejects traversal and unexpected shapes. */
36+
/**
37+
* Classify a git tree path into the destination we will WRITE, or null to reject.
38+
*
39+
* Critically, the returned `outRel` is built from a FIXED TEMPLATE using the validated skill name,
40+
* never from the raw git path. gitshow-research proved `git ls-tree` can emit path strings
41+
* containing literal `../` segments (git does not sanitise tree-entry names), so deriving the
42+
* output path from git's string is unsafe even behind a containment check. The name is the only
43+
* attacker-influenced input, and it is validated to a charset that cannot express traversal.
44+
*/
45+
export function classifyPiPath(path) {
46+
if (path === APPEND_SYSTEM) return { outRel: "pi/APPEND_SYSTEM.md" };
47+
const m = SKILL_PATH_RE.exec(path);
48+
if (!m) return null;
49+
const name = m[1];
50+
if (!SKILL_NAME_RE.test(name)) return null; // redundant with the capture group; belt and braces
51+
return { outRel: `pi/skills/${name}/SKILL.md` };
52+
}
53+
54+
/** Back-compat predicate used by callers/tests that only care whether a path is accepted. */
3455
export function isAllowedPiPath(path) {
35-
if (path === APPEND_SYSTEM) return true;
36-
return SKILL_RE.test(path);
56+
return classifyPiPath(path) !== null;
3757
}
3858

3959
/**
4060
* Parse `git ls-tree -r -z` output into entries, keeping ONLY regular blobs (100644) at allowed
41-
* paths. Symlinks (120000), submodules (160000), executables (100755), and anything outside the
42-
* allowlist are dropped here -- the single choke point for the reject-by-mode rule.
61+
* paths, each carrying its template-derived output path. Symlinks (120000), submodules (160000),
62+
* executables (100755), and anything outside the allowlist are dropped here -- the single choke
63+
* point for the reject-by-mode rule.
4364
*/
4465
export function selectEntries(lsTreeZ) {
4566
const entries = [];
@@ -51,8 +72,9 @@ export function selectEntries(lsTreeZ) {
5172
const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
5273
const path = record.slice(tab + 1);
5374
if (mode !== "100644" || type !== "blob") continue; // rejects symlink/submodule/exec
54-
if (!isAllowedPiPath(path)) continue;
55-
entries.push({ oid, path });
75+
const classified = classifyPiPath(path);
76+
if (!classified) continue;
77+
entries.push({ oid, path, outRel: classified.outRel });
5678
}
5779
return entries;
5880
}
@@ -62,11 +84,11 @@ export function selectEntries(lsTreeZ) {
6284
* Uses path.relative rather than string-prefix so it is correct on Windows too -- the worker is
6385
* cross-platform and destDir may be a Windows path with backslash separators.
6486
*/
65-
function safeJoin(root, relPath) {
66-
const resolved = join(root, relPath);
87+
function safeJoin(root, ...segments) {
88+
const resolved = join(root, ...segments);
6789
const rel = relative(root, resolved);
6890
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
69-
throw new Error(`path escapes destination: ${relPath}`);
91+
throw new Error(`path escapes destination: ${segments.join("/")}`);
7092
}
7193
return resolved;
7294
}
@@ -82,16 +104,16 @@ export async function materializePiDir({ gitDir, sha, destDir, git = defaultGit
82104
const entries = selectEntries(lsTreeZ);
83105

84106
const written = [];
85-
for (const { oid, path } of entries) {
107+
for (const { oid, outRel } of entries) {
86108
const content = await git(gitDir, ["cat-file", "blob", oid], { raw: true });
87-
// path is ".pi/skills/x/SKILL.md" (git always uses forward slashes); strip the leading
88-
// ".pi/" so it lands under destDir/pi.
89-
const relPosix = `pi/${path.slice(PI_DIR.length + 1)}`;
90-
const out = safeJoin(destDir, relPosix);
109+
// outRel is TEMPLATE-derived from a validated name, never the raw git path -- so it cannot
110+
// contain traversal. safeJoin re-checks containment as defence in depth, and splits the posix
111+
// outRel into host path segments so it is correct on Windows too.
112+
const out = safeJoin(destDir, ...outRel.split("/"));
91113
mkdirSync(dirname(out), { recursive: true });
92-
writeFileSync(out, content);
114+
writeFileSync(out, content, { mode: 0o444 });
93115
// Report posix-style: this names a CONTAINER path (/job/pi/...), stable across host OSes.
94-
written.push(relPosix);
116+
written.push(outRel);
95117
}
96118
return written;
97119
}

worker/test/materialize.test.mjs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,33 @@ import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { test } from "node:test";
7-
import { isAllowedPiPath, materializePiDir, selectEntries } from "../src/materialize.mjs";
7+
import { classifyPiPath, isAllowedPiPath, materializePiDir, selectEntries } from "../src/materialize.mjs";
88

99
// --- pure selection logic: runs everywhere ---
1010

1111
test("isAllowedPiPath accepts exactly the persona and skill shapes", () => {
1212
assert.ok(isAllowedPiPath(".pi/APPEND_SYSTEM.md"));
1313
assert.ok(isAllowedPiPath(".pi/skills/bug-fix/SKILL.md"));
14+
assert.ok(isAllowedPiPath(".pi/skills/bug_fix2/SKILL.md"));
1415
assert.ok(!isAllowedPiPath(".pi/skills/bug-fix/notes.md"));
1516
assert.ok(!isAllowedPiPath(".pi/settings.json"));
16-
assert.ok(!isAllowedPiPath(".pi/skills/../../etc/passwd/SKILL.md")); // traversal in the name
1717
assert.ok(!isAllowedPiPath(".pi/APPEND_SYSTEM.md.evil"));
1818
});
1919

20+
test("a skill name that could express traversal is rejected -- git ls-tree can emit `..` segments", () => {
21+
// gitshow-research: git does not sanitise tree-entry names, so ls-tree can report a path with
22+
// literal ../ in it. The name charset (no dots, no slashes) makes traversal impossible here.
23+
assert.equal(classifyPiPath(".pi/skills/../SKILL.md"), null);
24+
assert.equal(classifyPiPath(".pi/skills/../../etc/SKILL.md"), null);
25+
assert.equal(classifyPiPath(".pi/skills/a.b/SKILL.md"), null); // dots barred (no `..`)
26+
assert.equal(classifyPiPath(".pi/skills/UPPER/SKILL.md"), null); // case-sensitive, JS regex
27+
});
28+
29+
test("the destination is built from a fixed template, never the raw git path", () => {
30+
assert.deepEqual(classifyPiPath(".pi/skills/bug-fix/SKILL.md"), { outRel: "pi/skills/bug-fix/SKILL.md" });
31+
assert.deepEqual(classifyPiPath(".pi/APPEND_SYSTEM.md"), { outRel: "pi/APPEND_SYSTEM.md" });
32+
});
33+
2034
test("selectEntries rejects symlinks (120000), submodules (160000), and executables (100755)", () => {
2135
const z = [
2236
"100644 blob aaa\t.pi/APPEND_SYSTEM.md",

0 commit comments

Comments
 (0)