From 24390c00e939a0cf460ec7f014c08aa288ef97ed Mon Sep 17 00:00:00 2001 From: Emre Tinaztepe Date: Sun, 26 Apr 2026 20:19:37 +0800 Subject: [PATCH 1/2] fix(core): reject ZIP entries not listed in MANIFEST (CWE-349) verifyManifestHashes only walked manifest.files, so any extra file appended to a signed ZIP was silently accepted as part of the authenticated package. After signing, an attacker could re-zip the archive with additional files (e.g. postinstall.sh) and verifyPackage / verifyFromAuthor still returned valid: true. Add a bidirectional check that fails closed when a ZIP entry is not listed in the manifest (MANIFEST.json itself excepted). Apply in both verifyPackage and verifyZipFromAuthor. Introduce VerifyErrorCode.UNEXPECTED_FILE and a regression test reproducing the PoC from issue #1. Fixes #1 Made-with: Cursor -e Signed-off-by: Emre Tinaztepe --- packages/core/src/types.ts | 1 + packages/core/src/verify.ts | 37 ++++++++++++++++++++++++------- packages/core/test/verify.spec.ts | 15 +++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ce1d6e9..0bf39ee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,7 @@ export enum VerifyErrorCode { KEY_FETCH_FAILED = "KEY_FETCH_FAILED", MISSING_MANIFEST = "MISSING_MANIFEST", MISSING_FILE = "MISSING_FILE", + UNEXPECTED_FILE = "UNEXPECTED_FILE", HASH_MISMATCH = "HASH_MISMATCH", INVALID_FRONT_MATTER = "INVALID_FRONT_MATTER", NETWORK_ERROR = "NETWORK_ERROR", diff --git a/packages/core/src/verify.ts b/packages/core/src/verify.ts index 1a2de42..36648ba 100644 --- a/packages/core/src/verify.ts +++ b/packages/core/src/verify.ts @@ -129,6 +129,21 @@ async function sha256Hex(data: Uint8Array): Promise { .join(""); } +function findUnexpectedFiles( + manifest: PackageManifest, + files: Map, +): FileIntegrityResult[] { + const allowed = new Set(Object.keys(manifest.files)); + const extras: FileIntegrityResult[] = []; + for (const path of files.keys()) { + if (path === "MANIFEST.json") continue; + if (!allowed.has(path)) { + extras.push({ path, valid: false, code: VerifyErrorCode.UNEXPECTED_FILE }); + } + } + return extras; +} + async function verifyManifestHashes( manifest: PackageManifest, files: Map, @@ -235,16 +250,15 @@ export async function verifyPackage( }; } - const fileResults = await verifyManifestHashes(manifest, files); + const extras = findUnexpectedFiles(manifest, files); + const fileResults = [...(await verifyManifestHashes(manifest, files)), ...extras]; const hasFailedFile = fileResults.some((f) => !f.valid); if (hasFailedFile) { const firstFailed = fileResults.find((f) => !f.valid)!; return { valid: false, code: firstFailed.code, - reason: firstFailed.code === VerifyErrorCode.MISSING_FILE - ? `Missing file: ${firstFailed.path}` - : `Hash mismatch for file: ${firstFailed.path}`, + reason: failedFileReason(firstFailed), details: { ...docMeta(manifest), signers, files: fileResults }, }; } @@ -255,6 +269,14 @@ export async function verifyPackage( }; } +function failedFileReason(f: FileIntegrityResult): string { + switch (f.code) { + case VerifyErrorCode.MISSING_FILE: return `Missing file: ${f.path}`; + case VerifyErrorCode.UNEXPECTED_FILE: return `Unexpected file not in manifest: ${f.path}`; + default: return `Hash mismatch for file: ${f.path}`; + } +} + // -- DNS TXT ------------------------------------------------------------------ export function parseDnsTxtRecord(txt: string): DnsTxtKeyRecord | null { @@ -709,16 +731,15 @@ async function verifyZipFromAuthor( files.set(path, data); } - const fileResults = await verifyManifestHashes(manifest, files); + const extras = findUnexpectedFiles(manifest, files); + const fileResults = [...(await verifyManifestHashes(manifest, files)), ...extras]; const hasFailedFile = fileResults.some((f) => !f.valid); if (hasFailedFile) { const firstFailed = fileResults.find((f) => !f.valid)!; return { valid: false, code: firstFailed.code, - reason: firstFailed.code === VerifyErrorCode.MISSING_FILE - ? `Missing file: ${firstFailed.path}` - : `Hash mismatch for file: ${firstFailed.path}`, + reason: failedFileReason(firstFailed), details: { ...docMeta(manifest), signers, files: fileResults }, }; } diff --git a/packages/core/test/verify.spec.ts b/packages/core/test/verify.spec.ts index b30519c..a85bda0 100644 --- a/packages/core/test/verify.spec.ts +++ b/packages/core/test/verify.spec.ts @@ -202,6 +202,21 @@ describe("verifyPackage", () => { expect(result.details?.signers![0].code).toBe(VerifyErrorCode.MALFORMED_SIGNATURE); }); + it("rejects ZIP with extra unsigned file not in manifest", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const zip = zipSync({ "safe.txt": enc("safe content") }); + const signed = await signPackage(zip, PKG_META, privateKey); + const entries = unzipSync(signed); + entries["evil.sh"] = enc("#!/bin/bash\nmalicious"); + const tampered = zipSync(entries); + const result = await verifyPackage(tampered, publicKey); + expect(result.valid).toBe(false); + expect(result.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + const extra = result.details?.files!.find((f) => f.path === "evil.sh"); + expect(extra?.valid).toBe(false); + expect(extra?.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + }); + it("verifies package with multiple files", async () => { const { publicKey, privateKey } = await generateKeyPair(); const zip = zipSync({ From 1ea22def4f5406f3896337431d78a6125a1b2948 Mon Sep 17 00:00:00 2001 From: Emre Tinaztepe Date: Sun, 26 Apr 2026 20:30:32 +0800 Subject: [PATCH 2/2] test(core): cover verifyFromAuthor path for UNEXPECTED_FILE Address Copilot review feedback on #3: the fix is applied in both verifyPackage and verifyZipFromAuthor, but only verifyPackage was covered by a regression test. Add a test exercising verifyFromAuthor with an injected unsigned file to ensure the author-verification entry point cannot regress. Made-with: Cursor -e Signed-off-by: Emre Tinaztepe --- packages/core/test/verify.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/core/test/verify.spec.ts b/packages/core/test/verify.spec.ts index a85bda0..912c21d 100644 --- a/packages/core/test/verify.spec.ts +++ b/packages/core/test/verify.spec.ts @@ -470,6 +470,29 @@ signatures: expect(result.code).toBe(VerifyErrorCode.NO_SIGNATURES); }); + it("rejects ZIP with extra unsigned file via verifyFromAuthor (UNEXPECTED_FILE)", async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const zip = zipSync({ "safe.txt": enc("safe content") }); + const signed = await signPackage(zip, PKG_META, privateKey); + const entries = unzipSync(signed); + entries["evil.sh"] = enc("#!/bin/bash\nmalicious"); + const tampered = zipSync(entries); + + const pubKeyB64 = uint8ToBase64(publicKey); + const futureDate = new Date("2030-01-01T00:00:00Z").toISOString(); + const result = await verifyFromAuthor(tampered, { + fetch: mockFetchWithKeys([ + { keyId: "key_test", algorithm: "ed25519", publicKey: pubKeyB64, expires: futureDate }, + ]) as typeof globalThis.fetch, + resolveTxt: false, + }); + expect(result.valid).toBe(false); + expect(result.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + const extra = result.details?.files!.find((f) => f.path === "evil.sh"); + expect(extra?.valid).toBe(false); + expect(extra?.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + }); + it("unsigned ZIP returns MISSING_MANIFEST", async () => { const zip = zipSync({ "file.txt": enc("hello") }); const result = await verifyFromAuthor(zip, {