mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
fix(slice-05): install compat extraction uses safe unpack path (guard B1 repair on fbf92f9c) (OPR.0.3.2.5)
Repairs guard BLOCKING-CONCERN B1 on qitem-20260518201306-2ec080de /fbf92f9c: the install-time compat check's extractManifestForCompatCheck called tar.extract directly on the untrusted .rigbundle, bypassing the existing safety boundary in domain/bundle-archive.unpack (verifyArchiveDigest + tar.list unsafe-entry prescan rejecting symlinks / hardlinks / absolute paths / dot-dot traversal). Even though extraction went to a tmp dir, the trust-boundary regression was real because the new pre-delegation gate ran on attacker-craftable archives before any safety code. Fix: replace the raw tar.extract call with a call to the canonical unpack(bundlePath, tmpDir) from domain/bundle-archive. unpack performs digest verify, then the same tar.list safety prescan, then extracts, then runs content-integrity verification. Single source for the safety contract — future safety changes to unpack auto-apply here. Discriminator test added in test/bundle-routes.test.ts: - Hand-crafts a bundle whose tar contains a symlink entry to /etc/passwd - Writes a VALID sibling .sha256 so the digest check passes (proving the prescan is what catches the symlink, not the digest) - POSTs /api/bundles/install with plan=true - Asserts status 400 and the response body contains the unsafe-entries message (SymbolicLink / symlink / "Unsafe archive entries") - Verified the test is a real discriminator: temporarily reverting extractManifestForCompatCheck to a raw tar.extract call makes this one test FAIL (1/24); restoring the unpack call returns 24/24 PASS Verified locally: - 24/24 daemon bundle-routes suite green (was 23; +1 new unsafe-archive test) - Other daemon bundle suites unchanged - All scope-and-versioning sections 1 and 3 coherence grep — empty - audit-every-layer unchanged: function-level reads only Diff: 2 files, +62 / -5 lines. No CLI files modified. Branch chain since main:82d39715->3b7432e4->6b81d3b6->77bed48b(B1 repair from Item 1) ->1fd11b76->f10a1990->fbf92f9c(Item 2 close-out, B1 finding on this) -> THIS COMMIT (B1 repair). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
fbf92f9ce1
commit
6401fe4be5
2 files changed
+62
-5
No files matched your search
@@ -95,15 +95,19 @@ function checkBundleCompatibility(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bundle.yaml manifest from a .rigbundle archive without doing the
|
||||
* full bootstrap unpack. Used by the install-time compatibility check. Throws
|
||||
* on archive / parse / safety failures.
|
||||
* Extract bundle.yaml manifest from a .rigbundle archive via the canonical
|
||||
* safe-extraction path (unpack from domain/bundle-archive). unpack performs
|
||||
* verifyArchiveDigest, then tar.list pre-scan rejecting symlinks / hardlinks
|
||||
* / absolute paths / dot-dot traversal, then extracts, then verifies content
|
||||
* integrity. Using unpack here keeps the install-time compat check inside the
|
||||
* existing trust boundary — raw tar.extract on an untrusted archive would
|
||||
* bypass the safety prescan (B1 regression fixed). Throws on archive / safety
|
||||
* / parse failures; the caller converts these into a 3-part 400 response.
|
||||
*/
|
||||
async function extractManifestForCompatCheck(bundlePath: string): Promise<Record<string, unknown>> {
|
||||
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-compat-"));
|
||||
try {
|
||||
const tar = await import("tar");
|
||||
await tar.extract({ file: bundlePath, cwd: tmpDir });
|
||||
await unpack(bundlePath, tmpDir);
|
||||
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
|
||||
if (!fs.existsSync(manifestPath)) throw new Error("Bundle missing bundle.yaml");
|
||||
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
|
||||
|
||||
@@ -767,6 +767,59 @@ describe("Bundle API routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 Checkpoint 3.3 / guard B1 repair: install rejects unsafe
|
||||
// archives through the safe error path BEFORE bootstrap delegation.
|
||||
// Discriminator: reverting extractManifestForCompatCheck to a raw tar.extract
|
||||
// (without unpack's verifyArchiveDigest + tar.list unsafe-entry prescan) must
|
||||
// make this test fail — the unsafe symlink would be silently extracted and
|
||||
// bootstrap would see an attacker-controlled link target.
|
||||
it("POST /api/bundles/install rejects an archive containing a symlink entry via the safe path (B1 repair)", async () => {
|
||||
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-unsafe-staging-"));
|
||||
const bundlePath = path.join(tmpDir, "unsafe-symlink.rigbundle");
|
||||
try {
|
||||
// Minimal valid manifest contents (bundle.yaml present) plus the unsafe
|
||||
// symlink that the safety prescan must reject.
|
||||
fs.writeFileSync(path.join(stagingDir, "bundle.yaml"), [
|
||||
'schema_version: 1',
|
||||
'name: unsafe-test',
|
||||
'version: "0.1.0"',
|
||||
'created_at: "2026-05-18T00:00:00Z"',
|
||||
'rig_spec: rig.yaml',
|
||||
'packages: []',
|
||||
].join("\n"));
|
||||
fs.writeFileSync(path.join(stagingDir, "rig.yaml"), 'schema_version: 1\nname: x\nversion: "1.0"\nnodes: []\nedges: []');
|
||||
fs.symlinkSync("/etc/passwd", path.join(stagingDir, "evil-symlink"));
|
||||
|
||||
const tar = await import("tar");
|
||||
await tar.create(
|
||||
{ gzip: { level: 9 }, file: bundlePath, cwd: stagingDir, portable: true },
|
||||
["bundle.yaml", "rig.yaml", "evil-symlink"],
|
||||
);
|
||||
|
||||
// Write a valid sibling .sha256 so digest verification PASSES — proving
|
||||
// the prescan is what catches the symlink, not the digest check.
|
||||
const { createHash } = await import("node:crypto");
|
||||
const archiveHash = createHash("sha256").update(fs.readFileSync(bundlePath)).digest("hex");
|
||||
fs.writeFileSync(`${bundlePath}.sha256`, archiveHash, "utf-8");
|
||||
|
||||
const installRes = await app.request("/api/bundles/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bundlePath, plan: true }),
|
||||
});
|
||||
expect(installRes.status).toBe(400);
|
||||
const body = await installRes.json();
|
||||
// Either explicit-extraction-failed shape (safe rejection happened inside
|
||||
// unpack) OR the compat-check-failed shape with the safety message in
|
||||
// detail. Either way, the symlink string must appear and bootstrap must
|
||||
// NOT have been entered.
|
||||
const text = JSON.stringify(body);
|
||||
expect(text).toMatch(/Unsafe archive entries|SymbolicLink|symlink/i);
|
||||
} finally {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// T11: Install concurrency lock
|
||||
it("concurrent bundle install returns 409", async () => {
|
||||
// Acquire lock manually
|
||||
|
||||
Reference in new issue
Block a user