From aa48d494e9726d469a1b9ed917f021d8a1a4c7ea Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sun, 1 Nov 2026 21:24:01 +0800 Subject: [PATCH] fix(pe): enforce certificate name string alphabets (#8494) * fix(pe): enforce certificate name string alphabets * test(mcp): assert structured absolute-path diagnostics * test(mcp): assert typed selector and stdio diagnostics * fix(pe): bound IA5 name scans before validation --- src/native/pe/PeCertificateAsn1.ts | 14 +++- src/native/pe/PeCertificateParser.test.ts | 82 ++++++++++++++++++++++ tests/acceptance/peCertificatesCli.test.ts | 43 ++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 673ca12351..1f8985b048 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -186,9 +186,19 @@ const decodeDirectoryString = ( return decodeUtf32Be(node.content, budget); case 0x1e: return decodeBmpString(node.content, budget); - case 0x0c: - case 0x13: + case 0x13: { + const decoded = decodeUtf8(node.content, budget); + if (/[^A-Za-z0-9 '()+,\-./:=?]/u.test(decoded)) + return fail("PrintableString holds a character outside its alphabet."); + return decoded; + } case 0x16: + chargeDecodedName(budget, node.content.length); + if (node.content.some((byte) => byte > 0x7f)) + return fail("IA5String holds a non-ASCII character."); + // Validated ASCII needs no UTF-8 round trip or second budget charge. + return node.content.toString("ascii"); + case 0x0c: case 0x17: case 0x18: return decodeUtf8(node.content, budget); diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index 8b78fafa9c..9b6828e812 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -829,3 +829,85 @@ it("preserves valid BMPString code units on both sides of the surrogate range", parseCertificate(readAsn1(namedCertificate(name, name))).subject.text, ).toBe("commonName=\ud7ff\ue000\ufeff"); }); + +it.each([ + [ + "PrintableString", + 0x13, + [ + "name@host", + "under_score", + "A\u0000B", + "A\tB", + "A\n", + "A\r", + "A\u007f", + "é", + "中", + ], + ], + ["IA5String", 0x16, ["é", "中"]], +] as const)( + "rejects characters outside the %s alphabet in certificate names", + (_label, tag, values) => { + for (const value of values) { + const invalid = directoryName(der(tag, Buffer.from(value, "utf8"))); + const valid = directoryName(der(0x0c, Buffer.from(value, "utf8"))); + for (const [issuer, subject] of [ + [invalid, valid], + [valid, invalid], + ] as const) { + expect(() => + parseCertificate(readAsn1(namedCertificate(issuer, subject))), + ).toThrow(PeCertificateAsn1Failure); + } + const certificate = parseCertificate( + readAsn1(namedCertificate(valid, valid)), + ); + expect(certificate.issuer.attributes[0]?.value).toBe(value); + expect(certificate.subject.attributes[0]?.value).toBe(value); + } + }, +); + +it("bounds IA5String inspection before alphabet validation and charges text once", () => { + const ascii = Buffer.alloc(Math.floor(MAX_DECODED_NAME_BYTES / 3), 0x41); + const within = directoryName(der(0x16, ascii)); + const certificate = parseCertificate( + readAsn1(namedCertificate(within, within)), + ); + expect(certificate.issuer.attributes[0]?.value).toBe(ascii.toString("ascii")); + expect(certificate.subject.attributes[0]?.value).toBe( + ascii.toString("ascii"), + ); + for (const ending of [Buffer.from("A"), Buffer.from("é")]) { + const oversized = Buffer.concat([ + Buffer.alloc(MAX_DECODED_NAME_BYTES, 0x41), + ending, + ]); + const parseOversized = () => + parseCertificate( + readAsn1(namedCertificate(directoryName(der(0x16, oversized)), within)), + ); + expect(parseOversized).toThrow( + expect.objectContaining({ + exhausted: { allowance: "decoded-text", limit: MAX_DECODED_NAME_BYTES }, + }), + ); + } +}); + +it.each([ + [0x13, "AZaz09 '()+,-./:=?"], + [0x16, "mail_box@example.test\u0000\t\u007f"], +] as const)( + "preserves valid character boundaries for name tag %i", + (tag, value) => { + const name = directoryName(der(tag, Buffer.from(value, "ascii"))); + const certificate = parseCertificate( + readAsn1(namedCertificate(name, name)), + ); + expect(certificate.issuer.attributes[0]?.value).toBe(value); + expect(certificate.subject.attributes[0]?.value).toBe(value); + }, +); diff --git a/tests/acceptance/peCertificatesCli.test.ts b/tests/acceptance/peCertificatesCli.test.ts index 860041988e..c068c47ecb 100644 --- a/tests/acceptance/peCertificatesCli.test.ts +++ b/tests/acceptance/peCertificatesCli.test.ts @@ -346,3 +346,46 @@ cliTest.for([ ); }, ); + +cliTest.for([ + [0x13, "REA@Fixture"], + [0x16, "REAéFixture"], +] as const)( + "rejects an invalid certificate name alphabet for tag %i through the public CLI", + async ([tag, name], { cli }) => { + const root = await createTestTempDirectory("rea-pe-cert-name-alphabet-"); + const path = join(root, "name.exe"); + const payload = pkcs7SignedDataFixture(name); + const content = Buffer.from(name, "utf8"); + const marker = Buffer.concat([ + Buffer.from([0x0c, content.length]), + content, + ]); + const at = payload.indexOf(marker); + expect(at).toBeGreaterThanOrEqual(0); + payload[at] = tag; + await writeFile(path, peCertificateFixture(false, payload)); + const invalid = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(invalid.exitCode).toBe(1); + expect(invalid.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_certificates" }, + }); + // Only restore the string tag: the same bytes are valid UTF8String content. + payload[at] = 0x0c; + await writeFile(path, peCertificateFixture(false, payload)); + const valid = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(valid.exitCode, valid.stderr).toBe(0); + const evidence = toolContract("inspect_pe_certificates").outputSchema.parse( + valid.json, + ); + expect( + evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]?.issuer + .attributes[0]?.value, + ).toBe(name); + }, +);