fix(artifacts): validate selected path traversal

This commit is contained in:
morluto committed 2026-10-27 09:45:10 +08:00
1 parent 27ac1b6781
commit f3a301eca5
5 files changed
+135 -7

No files matched your search

+13 -2
View File
@@ -1,6 +1,17 @@
import { realpath } from "node:fs/promises";
import { realpath, stat } from "node:fs/promises";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
/** Canonicalize only a path that native filesystem lookup actually accepts. */
export const resolveArtifactSourcePath = async (
path: string,
): Promise<string> => {
// Darwin realpath can collapse file/.. although opening that spelling fails
// with ENOTDIR. Keep both file and directory selections valid here; the
// inventory/reader owns format and regular-file admission.
await stat(path);
return realpath(path);
};
/** Verify selection spelling independently of canonical descriptor ownership. */
export const assertArtifactSelectedSource = async (
canonicalPath: string,
@@ -19,7 +30,7 @@ export const assertArtifactSelectedSource = async (
);
let current: string;
try {
current = await realpath(selectedPath);
current = await resolveArtifactSourcePath(selectedPath);
} catch (cause: unknown) {
if (
cause instanceof Error &&
@@ -1,6 +1,9 @@
import { assertAdmittedRootDigest } from "../inventory/classify.js";
import { assertArtifactFileSelectedSource } from "../ArtifactFileSource.js";
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
import {
assertArtifactSelectedSource,
resolveArtifactSourcePath,
} from "../ArtifactSelectedSource.js";
import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
@@ -83,7 +86,7 @@ const extractArtifactInScope = async (
): Promise<ArtifactExtractionResult> => {
// Cancellation wins over every later refusal, including unsupported formats.
abortIfNeeded(signal);
const sourcePath = await realpath(input.inputPath);
const sourcePath = await resolveArtifactSourcePath(input.inputPath);
if (input.expectedRootSha256 !== undefined)
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
const admission = await requireExtractableFormat(sourcePath, input, signal);
+3 -3
View File
@@ -1,5 +1,5 @@
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import { realpath } from "node:fs/promises";
import { resolveArtifactSourcePath } from "../ArtifactSelectedSource.js";
import {
artifactInventoryResultSchema,
@@ -31,7 +31,7 @@ export const scanArtifactInventoryInScope = async (
options: ArtifactInventoryOptions,
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
const path = await resolveArtifactSourcePath(inputPath);
return scanCanonicalArtifactInventoryInScope(
path,
options,
@@ -49,7 +49,7 @@ export const scanArtifactInventoryForExtractionInScope = async (
readonly transferredSource: TransferredArtifactInventorySource | undefined;
}> => {
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
const path = await resolveArtifactSourcePath(inputPath);
return scanCanonicalArtifactInventoryForExtractionInScope(
path,
options,
@@ -12,6 +12,7 @@ import {
writeFile,
unlink,
symlink,
rmdir,
} from "node:fs/promises";
import { join } from "node:path";
import { createPackage } from "@electron/asar";
@@ -24,6 +25,100 @@ import {
} from "../../../src/domain/artifactGraph.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createSingleEntryZip } from "../../fixtures/zipNames.js";
it.skipIf(process.platform === "win32").each(["zip", "asar"])(
"rejects a %s selection whose traversed directory became a file",
async (format) => {
const root = await createTestTempDirectory("rea-admitted-traversal-");
const child = join(root, "child");
await mkdir(child);
const stored = join(root, `stored.${format}`);
if (format === "asar") {
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "payload.txt"), "member bytes");
await createPackage(source, stored);
} else {
await writeFile(stored, await createSingleEntryZip("payload.txt"));
}
const selected = `${child}/../stored.${format}`;
const session = createTestBinarySession(new ArtifactProvider({}));
try {
const opened = await session.open(selected);
if (!opened.ok) throw opened.error;
await rmdir(child);
await writeFile(child, "now a regular file");
await expect(readFile(selected)).rejects.toMatchObject({
code: "ENOTDIR",
});
for (const operation of [
"inventory_artifact",
"extract_artifact",
] as const) {
const output = join(root, operation);
const result = await session.execute(
operation,
operation === "extract_artifact" ? { output_root: output } : {},
);
expect(result).toMatchObject({
ok: false,
error: {
reason: "integrity",
detail: expect.stringContaining(
"no longer resolves to its admitted source",
),
},
});
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
}
} finally {
await session.close();
}
},
);
it.skipIf(process.platform === "win32")(
"keeps native symlink parent traversal when revalidating archive selections",
async () => {
const root = await createTestTempDirectory(
"rea-admitted-native-traversal-",
);
const physical = join(root, "physical");
await mkdir(join(physical, "child"), { recursive: true });
const link = join(root, "link");
await symlink(join(physical, "child"), link);
await writeFile(
join(physical, "stored.zip"),
await createSingleEntryZip("payload.txt", "selected bytes"),
);
await writeFile(
join(root, "stored.zip"),
"lexical sibling is not an archive",
);
const session = createTestBinarySession(new ArtifactProvider({}));
try {
const opened = await session.open(`${link}/../stored.zip`);
if (!opened.ok) throw opened.error;
const inventory = await session.execute("inventory_artifact", {});
if (!inventory.ok) throw inventory.error;
expect(
artifactInventoryResultSchema.parse(inventory.value.result).manifest
.root_format,
).toBe("zip");
const output = join(root, "output");
const extraction = await session.execute("extract_artifact", {
output_root: output,
});
if (!extraction.ok) throw extraction.error;
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
"selected bytes",
);
} finally {
await session.close();
}
},
);
it.each([
{ format: "zip", replacement: "valid" },
@@ -6,6 +6,7 @@ import {
rename,
rm,
writeFile,
mkdir,
} from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -26,6 +27,24 @@ import {
fatImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
it.skipIf(process.platform === "win32").each(["file", "directory"])(
"rejects invalid native traversal before canonicalizing a %s inventory root",
async (kind) => {
const root = await createTestTempDirectory(
"rea-inventory-native-selection-",
);
await writeFile(join(root, "blocker"), "regular file");
const path = join(root, "target");
if (kind === "directory") await mkdir(path);
else await writeFile(path, "target bytes");
await expect(
inventoryArtifact(`${root}/blocker/../target`),
).rejects.toMatchObject({ code: "ENOTDIR" });
const valid = await inventoryArtifact(path);
expect(valid.manifest.root_format).toBe(kind);
},
);
it.skipIf(process.platform === "win32")(
"rejects a FIFO artifact root without waiting for a writer",
async () => {