fix(skills): reject incomplete tar archive listings (#125406)

This commit is contained in:
Josh Avant
2026-08-17 14:15:43 -07:00
committed by GitHub
parent 6938026f20
commit 2f653a73c2
4 changed files with 63 additions and 9 deletions
+48 -1
View File
@@ -109,7 +109,9 @@ function createCancelableBody() {
return { stream, wasCanceled: () => canceled };
}
function runCommandResult(params?: Partial<Record<"code" | "stdout" | "stderr", string | number>>) {
function runCommandResult(
params?: Partial<Record<"code" | "stdout" | "stderr" | "stdoutTruncatedBytes", string | number>>,
) {
return {
code: 0,
stdout: "",
@@ -287,6 +289,51 @@ describe("installDownloadSpec extraction safety", () => {
});
describe("installDownloadSpec extraction safety (tar.bz2)", () => {
it.each(["plain", "verbose"] as const)(
"rejects truncated %s tar listings before extraction",
async (truncatedListing) => {
const name = `tbz2-truncated-${truncatedListing}`;
const entry = buildEntry(name);
const targetDir = path.join(resolveSkillToolsRootDir(entry), "target");
mockArchiveResponse(new Uint8Array([1, 2, 3]));
runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
const cmd = (argv[0] ?? []) as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return runCommandResult({
stdout: "package/hello.txt\n",
...(truncatedListing === "plain" ? { stdoutTruncatedBytes: 1 } : {}),
});
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return runCommandResult({
stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n",
...(truncatedListing === "verbose" ? { stdoutTruncatedBytes: 1 } : {}),
});
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
throw new Error("should not extract");
}
return runCommandResult();
});
const result = await installDownloadSkill({
name,
url: `https://example.invalid/${name}.tbz2`,
archive: "tar.bz2",
targetDir,
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("tar listing output was truncated");
expect(
runCommandWithTimeoutMock.mock.calls.some(
(call) => (call[0] as string[])[0] === "tar" && (call[0] as string[])[1] === "xf",
),
).toBe(false);
},
);
it("handles tar.bz2 extraction safety edge-cases", async () => {
for (const testCase of [
{
+9 -7
View File
@@ -14,19 +14,21 @@ import { hasBinary } from "../loading/config.js";
import { parseTarVerboseMetadata } from "./install-tar-verbose.js";
type ArchiveExtractResult = { stdout: string; stderr: string; code: number | null };
type TarListingResult = ArchiveExtractResult & { stdoutTruncatedBytes?: number };
type TarPreflightResult = {
entries: string[];
metadata: ReturnType<typeof parseTarVerboseMetadata>;
};
function commandFailureResult(
result: { stdout: string; stderr: string; code: number | null },
result: TarListingResult,
fallbackStderr: string,
): ArchiveExtractResult {
const truncated = (result.stdoutTruncatedBytes ?? 0) > 0;
return {
stdout: result.stdout,
stderr: result.stderr || fallbackStderr,
code: result.code,
stderr: truncated ? "tar listing output was truncated; refusing to extract" : fallbackStderr,
code: truncated ? 1 : result.code,
};
}
@@ -49,16 +51,16 @@ async function readTarPreflight(params: {
const listResult = await runCommandWithTimeout(["tar", "tf", params.archivePath], {
timeoutMs: params.timeoutMs,
});
if (listResult.code !== 0) {
return commandFailureResult(listResult, "tar list failed");
if (listResult.code !== 0 || listResult.stdoutTruncatedBytes) {
return commandFailureResult(listResult, listResult.stderr || "tar list failed");
}
const entries = normalizeStringEntries(listResult.stdout.split("\n"));
const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], {
timeoutMs: params.timeoutMs,
});
if (verboseResult.code !== 0) {
return commandFailureResult(verboseResult, "tar verbose list failed");
if (verboseResult.code !== 0 || verboseResult.stdoutTruncatedBytes) {
return commandFailureResult(verboseResult, verboseResult.stderr || "tar verbose list failed");
}
const metadata = parseTarVerboseMetadata(verboseResult.stdout);
if (metadata.length !== entries.length) {
@@ -19,5 +19,8 @@ describe("parseTarVerboseMetadata", () => {
expect(() =>
parseTarVerboseMetadata("-rw-r--r-- user/group 9007199254740993 2026-05-28 00:00 SKILL.md"),
).toThrow(/unable to parse tar entry size/u);
expect(() =>
parseTarVerboseMetadata("?rw-r--r-- user/group 1 2026-05-28 00:00 SKILL.md"),
).toThrow(/unable to parse tar entry type/u);
});
});
+3 -1
View File
@@ -34,8 +34,10 @@ function mapTarVerboseTypeChar(typeChar: string): string {
return "Socket";
case "d":
return "Directory";
default:
case "-":
return "File";
default:
throw new Error(`unable to parse tar entry type: ${typeChar}`);
}
}