mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
fix(slice-05): create-path realpath-validates symlink escape (guard B1 repair on 79a89d40) (OPR.0.3.2.5)
Per velocity-guard BLOCKING-CONCERN on 79a89d40 (qitem-…bf67e58d): the
vendor helper's containment check was LEXICAL only
(nodePath.resolve does not follow symlinks), but readFileSync +
fs.cpSync({dereference:true}) follow them. So a path like
plugins/escape that's lexically under sourceRoot could be a symlink
to /etc/passwd or any outside dir, and /create would copy that
outside content into the archive as regular files.
Real trust-boundary defect on an HTTP surface that consumes author-
controlled source trees. Banked pre-existing-trust-boundary lesson
addendum extension: the lexical containment check protects against
"../" string traversal, but symlinks are a SEPARATE escape vector
that requires realpath validation.
Fix shape (per guard recipe + cont.18 §2 "no fork" constraint
explicitly expects symlinks for the gstack HYBRID author workflow —
reject-symlinks-outright was rejected as too restrictive; we
validate-then-allow):
- fs.realpathSync(sourceRoot) at top of consumeAuthorBundleYaml →
sourceRootReal
- assertSourceRealContained helper: realpath each declared sourceAbs,
assert it starts-with sourceRootReal (catches symlink-to-outside-
file AND symlink-to-outside-dir at the declared path level)
- assertNoSymlinkEscapeInTree helper: for dir vendoring, pre-walk the
realpath-resolved tree with lstat; for every symlink entry,
realpath-check containment; for symlink-to-dir under sourceRoot,
recurse to validate nested symlinks too. Runs BEFORE cpSync so any
escape attempt fails before dereference.
- vendorFile + vendorDir gated on these checks before any read/copy.
Secondary honesty fix (banked guard advisory): doc comment said
"/create catches + returns a 400" but actual route catch returns
500; updated comment to match.
2 new symlink-escape discriminator tests in bundle-routes.test.ts:
1. "POST /api/bundles/create rejects author bundle.yaml skill that is
a symlink targeting outside sourceRoot" — FILE shape. Creates
sourceRoot/skills/escape/SKILL.md as symlink to outsideDir/secret.md.
Bundle declares skills[skills/escape/SKILL.md]. /create expected
500 + error message naming symlink escape, NO archive at outputPath.
2. "POST /api/bundles/create rejects author bundle.yaml plugin that
is a symlink targeting outside sourceRoot" — DIR shape. Creates
sourceRoot/plugins/escape as symlink to outsideDir (dir tree with
private.json). Bundle declares plugins[escape]. /create expected
500 + error message, NO archive.
Discriminator-flip evidence BEFORE this commit (PROACTIVELY):
removed both assertSourceRealContained calls + the
assertNoSymlinkEscapeInTree call. Result: 2/56 fail — exactly the
2 new symlink-escape tests (without the realpath check, cpSync +
readFileSync silently dereference and produce successful archives
containing outside content; tests expect 500 + assert NO archive
which would not hold). Restored, 56/56 + 231/231 across full slice-05
daemon suite.
Banked discipline extension (banked pre-existing-trust-boundary
addendum / 11th tail-cycle catch): lexical containment + dereference-
on-read is a HALF-protection class. Symlinks are a SEPARATE escape
axis from string traversal — both need to be validated independently.
Pattern to recognize forward: any code path that does
nodePath.resolve(...) for containment followed by readFile/cpSync
with dereference must ALSO realpath-validate the resolved-then-
dereferenced source. The realpath-validate-then-allow pattern is the
banked-going-forward primitive for filesystem trust boundaries when
symlinks are part of the legitimate author workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
79a89d4061
commit
233dd16d72
2 files changed
+206
-11
No files matched your search
@@ -645,8 +645,13 @@ function writeInstallAudit(opts: {
|
||||
* file copy); plugins + context_packs (manifest.yaml path → parent
|
||||
* dir) + agent_images are dir paths (recursive copy with symlink
|
||||
* dereference).
|
||||
* - Throws on missing source path or path-containment violation; the
|
||||
* /create route catches + returns a 400 with the message.
|
||||
* - Throws on missing source path / path-containment violation /
|
||||
* realpath escape (banked 79a89d40 B1: lexical containment alone is
|
||||
* insufficient — symlinks under sourceRoot can target outside content;
|
||||
* each declared path is realpath-validated, and dir vendoring pre-
|
||||
* walks the tree with lstat to catch nested symlink escapes). The
|
||||
* /create route's outer catch returns 500 with the message; if this
|
||||
* maps poorly to operator UX, a follow-up can wrap to 400.
|
||||
*
|
||||
* Returns the cross-primitive blocks to populate on the manifest the
|
||||
* assembler returned. The /create route writes the manifest AFTER
|
||||
@@ -664,42 +669,114 @@ interface AuthorBundleCrossPrimitives {
|
||||
function consumeAuthorBundleYaml(sourceRoot: string, staging: string): AuthorBundleCrossPrimitives {
|
||||
const authorBundlePath = nodePath.join(sourceRoot, "bundle.yaml");
|
||||
if (!fs.existsSync(authorBundlePath)) return {};
|
||||
const sourceRootResolved = nodePath.resolve(sourceRoot);
|
||||
// Canonicalize sourceRoot before vendoring (banked 79a89d40 B1 guard
|
||||
// catch: lexical containment + dereference allows symlinks under
|
||||
// sourceRoot to escape). realpath the root once; every declared path's
|
||||
// realpath must stay within this boundary.
|
||||
const sourceRootReal = fs.realpathSync(sourceRoot);
|
||||
const stagingResolved = nodePath.resolve(staging);
|
||||
|
||||
/** Realpath-contain check: the actual file/dir behind `absPath` (after
|
||||
* symlink resolution) must live under sourceRootReal. Catches symlinks
|
||||
* whose targets escape the source tree even when the lexical path is
|
||||
* inside sourceRoot. Throws on escape. */
|
||||
const assertSourceRealContained = (absPath: string, kindLabel: string, declared: string): string => {
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = fs.realpathSync(absPath);
|
||||
} catch (err) {
|
||||
throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source: ${(err as Error).message}`);
|
||||
}
|
||||
if (realPath !== sourceRootReal && !realPath.startsWith(sourceRootReal + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} '${declared}' resolves outside bundle source root (symlink escape); rejected`);
|
||||
}
|
||||
return realPath;
|
||||
};
|
||||
|
||||
/** Pre-walk a directory tree with lstat; for every symlink encountered,
|
||||
* realpath-check containment under sourceRootReal. Regular files and
|
||||
* dirs need no special check (they're inherently contained — the
|
||||
* problem class is symlinks escaping). Throws on any escape. */
|
||||
const assertNoSymlinkEscapeInTree = (dirAbs: string, kindLabel: string, declared: string): void => {
|
||||
const stack: string[] = [dirAbs];
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
// Unreadable dir — skip; cpSync will surface the failure if material
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryAbs = nodePath.join(cur, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Realpath the symlink target; reject if escapes sourceRootReal
|
||||
let entryReal: string;
|
||||
try {
|
||||
entryReal = fs.realpathSync(entryAbs);
|
||||
} catch (err) {
|
||||
throw new Error(`author bundle ${kindLabel} '${declared}' has unreadable symlink at '${nodePath.relative(sourceRootReal, entryAbs)}': ${(err as Error).message}`);
|
||||
}
|
||||
if (entryReal !== sourceRootReal && !entryReal.startsWith(sourceRootReal + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} '${declared}' contains nested symlink at '${nodePath.relative(sourceRootReal, entryAbs)}' escaping bundle source root; rejected`);
|
||||
}
|
||||
// If the symlink target is a directory under sourceRoot, walk it
|
||||
// (cpSync with dereference:true will follow it; we need to
|
||||
// validate any nested symlinks too)
|
||||
try {
|
||||
const st = fs.statSync(entryReal);
|
||||
if (st.isDirectory()) stack.push(entryReal);
|
||||
} catch { /* unreadable — skip */ }
|
||||
} else if (entry.isDirectory()) {
|
||||
stack.push(entryAbs);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const authorYaml = fs.readFileSync(authorBundlePath, "utf-8");
|
||||
const authorParsed = parsePodBundleManifest(authorYaml) as Record<string, unknown>;
|
||||
const result: AuthorBundleCrossPrimitives = {};
|
||||
|
||||
const vendorFile = (declared: string, kindLabel: string): void => {
|
||||
if (!isRelativeSafePath(declared)) throw new Error(`author bundle ${kindLabel} path '${declared}' is not safe`);
|
||||
const sourceAbs = nodePath.resolve(sourceRoot, declared);
|
||||
if (sourceAbs !== sourceRootResolved && !sourceAbs.startsWith(sourceRootResolved + nodePath.sep)) {
|
||||
const sourceAbs = nodePath.resolve(sourceRootReal, declared);
|
||||
// Lexical containment + realpath containment (banked 79a89d40 B1)
|
||||
if (sourceAbs !== sourceRootReal && !sourceAbs.startsWith(sourceRootReal + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} path '${declared}' escapes bundle source root`);
|
||||
}
|
||||
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
|
||||
assertSourceRealContained(sourceAbs, kindLabel, declared);
|
||||
const targetAbs = nodePath.resolve(staging, declared);
|
||||
if (!targetAbs.startsWith(stagingResolved + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} target '${declared}' escapes staging`);
|
||||
}
|
||||
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
|
||||
fs.mkdirSync(nodePath.dirname(targetAbs), { recursive: true });
|
||||
// Dereference symlinks (tar safety per banked pre-existing-trust-boundary)
|
||||
// readFileSync follows the symlink; we already validated its realpath
|
||||
// stays in sourceRootReal. Write as regular file (tar safety).
|
||||
const content = fs.readFileSync(sourceAbs);
|
||||
fs.writeFileSync(targetAbs, content);
|
||||
};
|
||||
const vendorDir = (declared: string, kindLabel: string): void => {
|
||||
if (!isRelativeSafePath(declared)) throw new Error(`author bundle ${kindLabel} path '${declared}' is not safe`);
|
||||
const sourceAbs = nodePath.resolve(sourceRoot, declared);
|
||||
if (sourceAbs !== sourceRootResolved && !sourceAbs.startsWith(sourceRootResolved + nodePath.sep)) {
|
||||
const sourceAbs = nodePath.resolve(sourceRootReal, declared);
|
||||
if (sourceAbs !== sourceRootReal && !sourceAbs.startsWith(sourceRootReal + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} path '${declared}' escapes bundle source root`);
|
||||
}
|
||||
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
|
||||
// Realpath-validate the declared dir itself (catches symlink-to-outside-dir)
|
||||
const sourceReal = assertSourceRealContained(sourceAbs, kindLabel, declared);
|
||||
// Pre-walk the realpath-resolved dir to catch nested symlink escapes
|
||||
// before cpSync dereferences anything
|
||||
assertNoSymlinkEscapeInTree(sourceReal, kindLabel, declared);
|
||||
const targetAbs = nodePath.resolve(staging, declared);
|
||||
if (!targetAbs.startsWith(stagingResolved + nodePath.sep)) {
|
||||
throw new Error(`author bundle ${kindLabel} target '${declared}' escapes staging`);
|
||||
}
|
||||
if (!fs.existsSync(sourceAbs)) throw new Error(`author bundle ${kindLabel} '${declared}' does not exist in source`);
|
||||
fs.mkdirSync(nodePath.dirname(targetAbs), { recursive: true });
|
||||
// dereference: true → symlinks are followed and written as regular files
|
||||
// dereference: true → symlinks followed (now validated safe) and
|
||||
// written as regular files for tar safety
|
||||
fs.cpSync(sourceAbs, targetAbs, { recursive: true, dereference: true });
|
||||
};
|
||||
|
||||
|
||||
@@ -1648,6 +1648,124 @@ describe("Bundle API routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Item 6 / Checkpoint 7.5 B1 repair (banked 79a89d40 guard catch):
|
||||
// symlink escape via author bundle.yaml — FILE shape. A skill path
|
||||
// that looks lexically contained but symlinks to an outside file must
|
||||
// be rejected. Without realpath validation, dereference at vendor
|
||||
// time would copy outside content into the archive as a regular file.
|
||||
it("POST /api/bundles/create rejects author bundle.yaml skill that is a symlink targeting outside sourceRoot", async () => {
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-file-src-"));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-file-outside-"));
|
||||
const outputPath = path.join(tmpDir, "symlink-escape-file.rigbundle");
|
||||
try {
|
||||
// Outside content (private/secret file the bundle should NEVER contain)
|
||||
const outsideFile = path.join(outsideDir, "secret.md");
|
||||
fs.writeFileSync(outsideFile, "SECRET CONTENT");
|
||||
|
||||
// Pod-aware sourceRoot fixture
|
||||
fs.mkdirSync(path.join(sourceRoot, "agents/impl"), { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceRoot, "agents/impl/agent.yaml"), [
|
||||
'name: impl-agent', 'version: "1.0.0"', 'resources:', ' skills: []',
|
||||
'profiles:', ' default:', ' uses:', ' skills: []',
|
||||
].join("\n"));
|
||||
const specPath = path.join(sourceRoot, "rig.yaml");
|
||||
fs.writeFileSync(specPath, [
|
||||
'version: "0.2"', 'name: symlink-escape-file-rig', 'pods:',
|
||||
' - id: dev', ' label: Dev', ' members:',
|
||||
' - id: impl', ' agent_ref: "local:agents/impl"',
|
||||
' profile: default', ' runtime: claude-code', ' cwd: .',
|
||||
' edges: []', 'edges: []',
|
||||
].join("\n"));
|
||||
|
||||
// Symlink inside sourceRoot → outside file
|
||||
fs.mkdirSync(path.join(sourceRoot, "skills/escape"), { recursive: true });
|
||||
fs.symlinkSync(outsideFile, path.join(sourceRoot, "skills/escape/SKILL.md"));
|
||||
|
||||
fs.writeFileSync(path.join(sourceRoot, "bundle.yaml"), [
|
||||
'skills:',
|
||||
' - skills/escape/SKILL.md',
|
||||
].join("\n"));
|
||||
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
specPath, rigRoot: sourceRoot,
|
||||
bundleName: "symlink-escape-file-test", bundleVersion: "0.1.0", outputPath,
|
||||
}),
|
||||
});
|
||||
// /create catch returns 500 with the message (per banked 79a89d40
|
||||
// comment-honesty update). Body.error names the symlink escape.
|
||||
expect(createRes.status).toBe(500);
|
||||
const body = await createRes.json();
|
||||
expect(body.error).toMatch(/symlink escape|outside bundle source root/i);
|
||||
// CRUCIAL: no archive at outputPath (create failed before pack)
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(sourceRoot, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.rmSync(outputPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Item 6 / Checkpoint 7.5 B1 repair (banked 79a89d40 guard catch):
|
||||
// symlink escape via author bundle.yaml — DIR shape. A plugin path
|
||||
// that looks lexically contained but symlinks to an outside dir must
|
||||
// be rejected. Without realpath validation, cpSync dereference would
|
||||
// copy the outside dir tree into the archive.
|
||||
it("POST /api/bundles/create rejects author bundle.yaml plugin that is a symlink targeting outside sourceRoot", async () => {
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-dir-src-"));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-symlink-escape-dir-outside-"));
|
||||
const outputPath = path.join(tmpDir, "symlink-escape-dir.rigbundle");
|
||||
try {
|
||||
// Outside dir tree (private content the bundle should NEVER contain)
|
||||
fs.writeFileSync(path.join(outsideDir, "private.json"), '{"secret":"data"}');
|
||||
|
||||
fs.mkdirSync(path.join(sourceRoot, "agents/impl"), { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceRoot, "agents/impl/agent.yaml"), [
|
||||
'name: impl-agent', 'version: "1.0.0"', 'resources:', ' skills: []',
|
||||
'profiles:', ' default:', ' uses:', ' skills: []',
|
||||
].join("\n"));
|
||||
const specPath = path.join(sourceRoot, "rig.yaml");
|
||||
fs.writeFileSync(specPath, [
|
||||
'version: "0.2"', 'name: symlink-escape-dir-rig', 'pods:',
|
||||
' - id: dev', ' label: Dev', ' members:',
|
||||
' - id: impl', ' agent_ref: "local:agents/impl"',
|
||||
' profile: default', ' runtime: claude-code', ' cwd: .',
|
||||
' edges: []', 'edges: []',
|
||||
].join("\n"));
|
||||
|
||||
// Symlink inside sourceRoot/plugins → outside dir
|
||||
fs.mkdirSync(path.join(sourceRoot, "plugins"), { recursive: true });
|
||||
fs.symlinkSync(outsideDir, path.join(sourceRoot, "plugins/escape"));
|
||||
|
||||
fs.writeFileSync(path.join(sourceRoot, "bundle.yaml"), [
|
||||
'plugins:',
|
||||
' - id: escape',
|
||||
' source:',
|
||||
' kind: local',
|
||||
' path: plugins/escape',
|
||||
].join("\n"));
|
||||
|
||||
const createRes = await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
specPath, rigRoot: sourceRoot,
|
||||
bundleName: "symlink-escape-dir-test", bundleVersion: "0.1.0", outputPath,
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(500);
|
||||
const body = await createRes.json();
|
||||
expect(body.error).toMatch(/symlink escape|outside bundle source root/i);
|
||||
expect(fs.existsSync(outputPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(sourceRoot, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(outputPath)) fs.rmSync(outputPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Item 6 / Checkpoint 7.3e step 3: /install routes declared workflow_specs
|
||||
// after successful bootstrap. Target = SettingsStore-resolved
|
||||
// <workspaceSpecsRoot>/workflows. Bundle has workflow_specs[] with a path
|
||||
|
||||
Reference in new issue
Block a user