From 31dc035041eb5b427f5f57e15841671a24afb71d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:16:25 +0800 Subject: [PATCH] fix(domain): reject malformed base64 before byte-range checks --- .../RecordedCrashService.test.ts | 116 +++++++++--------- src/domain/go/goBinary.ts | 7 +- src/domain/native/binaryLayout.ts | 16 +-- src/domain/native/recordedCrash.ts | 9 +- .../native/recordedCrashNoteCoverage.ts | 8 +- src/domain/paddedBase64.ts | 3 +- src/domain/stringPatterns.ts | 7 +- 7 files changed, 76 insertions(+), 90 deletions(-) diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index 5433257b90..53b55fbbaa 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -178,59 +178,65 @@ it("cancels before invocation and after an acquired observation", async () => { ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); }); -it.each(["different-path", "invalid-digest", "outside-range"])( - "rejects provider evidence at the next boundary: %s", - async (problem) => { - const fixture = recordedCrashFixture(); - if (problem === "different-path") fixture.artifact.path = "/different.elf"; - if (problem === "invalid-digest") fixture.artifact.sha256 = "unobserved"; - if (problem === "outside-range") - fixture.segments.push({ - index: 0, - type: "PT_LOAD", - offset: "0x200", - header_location: { offset: "0x0", bytes: "0x38" }, - file_backing: "file", - file_size: "0x1", - memory_size: "0x1", - virtual_address: "0x20000000000001", - physical_address: "0x0", - alignment: "0x1", - flags: "0x5", - }); - const service = new RecordedCrashService({ - inspectAndClose(input, options) { - return this.inspect(input, options); - }, - identity: RECORDED_CRASH_TEST_PROVIDER, - inspect: () => Promise.resolve(ok(fixture)), +it.each([ + "different-path", + "invalid-digest", + "outside-range", + "owner-base64", + "descriptor-base64", +])("rejects provider evidence at the next boundary: %s", async (problem) => { + const fixture = recordedCrashFixture(); + if (problem === "different-path") fixture.artifact.path = "/different.elf"; + if (problem === "invalid-digest") fixture.artifact.sha256 = "unobserved"; + if (problem === "owner-base64") fixture.notes[0]!.owner_bytes_base64 = "A"; + if (problem === "descriptor-base64") + fixture.notes[0]!.descriptor_bytes_base64 = "A"; + if (problem === "outside-range") + fixture.segments.push({ + index: 0, + type: "PT_LOAD", + offset: "0x200", + header_location: { offset: "0x0", bytes: "0x38" }, + file_backing: "file", + file_size: "0x1", + memory_size: "0x1", + virtual_address: "0x20000000000001", + physical_address: "0x0", + alignment: "0x1", + flags: "0x5", }); - expect( - await service.inspect({ path: "/artifacts/source-owned.core" }), - ).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisOutputError", - capturedOutput: fixture.diagnostics, - }, - }); - const controller = new AbortController(); - const lateInvalid = await new RecordedCrashService({ - inspectAndClose(input, options) { - return this.inspect(input, options); - }, - identity: RECORDED_CRASH_TEST_PROVIDER, - inspect: () => { - controller.abort(); - return Promise.resolve(ok(fixture)); - }, - }).inspect( - { path: "/artifacts/source-owned.core" }, - { signal: controller.signal }, - ); - expect(lateInvalid).toMatchObject({ - ok: false, - error: { _tag: "AnalysisOutputError" }, - }); - }, -); + const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: RECORDED_CRASH_TEST_PROVIDER, + inspect: () => Promise.resolve(ok(fixture)), + }); + expect( + await service.inspect({ path: "/artifacts/source-owned.core" }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + capturedOutput: fixture.diagnostics, + }, + }); + const controller = new AbortController(); + const lateInvalid = await new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: RECORDED_CRASH_TEST_PROVIDER, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(fixture)); + }, + }).inspect( + { path: "/artifacts/source-owned.core" }, + { signal: controller.signal }, + ); + expect(lateInvalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); +}); diff --git a/src/domain/go/goBinary.ts b/src/domain/go/goBinary.ts index 2cd8cbb221..46c5fea670 100644 --- a/src/domain/go/goBinary.ts +++ b/src/domain/go/goBinary.ts @@ -158,12 +158,7 @@ export const goBinarySchema = z ["module_bytes_base64", info.module_location, "module"], ] as const) { const encoded = info[field]; - const padding = encoded.endsWith("==") - ? 2 - : encoded.endsWith("=") - ? 1 - : 0; - if ((encoded.length / 4) * 3 - padding !== range.bytes) + if (Buffer.byteLength(encoded, "base64") !== range.bytes) context.addIssue({ code: "custom", path: ["build_info", field], diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index a978b08f64..cf34591b7d 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -426,17 +426,11 @@ const validatePackedRelativeTables = ( tableIndex, "location", ]); - const encodedBytes = - (table.encoded_bytes_base64.length * 3) / 4 - - (table.encoded_bytes_base64.endsWith("==") - ? 2 - : table.encoded_bytes_base64.endsWith("=") - ? 1 - : 0); - if ( - !Number.isInteger(encodedBytes) || - BigInt(encodedBytes) !== BigInt(table.location.bytes) - ) + const encodedBytes = Buffer.byteLength( + table.encoded_bytes_base64, + "base64", + ); + if (BigInt(encodedBytes) !== BigInt(table.location.bytes)) context.addIssue({ code: "custom", path: ["packed_relative_relocations", tableIndex], diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts index 5176625c38..e722dc891b 100644 --- a/src/domain/native/recordedCrash.ts +++ b/src/domain/native/recordedCrash.ts @@ -317,15 +317,10 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine( fail("Invalid original note identity or segment reference."); continue; } - const decodedLength = (raw: string) => - BigInt( - (raw.length / 4) * 3 - - (raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0), - ); if ( - decodedLength(note.owner_bytes_base64) !== + BigInt(Buffer.byteLength(note.owner_bytes_base64, "base64")) !== BigInt(note.owner_location.bytes) || - decodedLength(note.descriptor_bytes_base64) !== + BigInt(Buffer.byteLength(note.descriptor_bytes_base64, "base64")) !== BigInt(note.descriptor_location.bytes) ) fail("Raw note lengths differ from their source ranges."); diff --git a/src/domain/native/recordedCrashNoteCoverage.ts b/src/domain/native/recordedCrashNoteCoverage.ts index dbe7e51cd4..4eb28a84fa 100644 --- a/src/domain/native/recordedCrashNoteCoverage.ts +++ b/src/domain/native/recordedCrashNoteCoverage.ts @@ -32,13 +32,7 @@ const paddingMatches = ( segment: Segment | undefined, ): boolean => { const bytes = BigInt(item.location.bytes); - const decodedLength = - (item.bytes_base64.length / 4) * 3 - - (item.bytes_base64.endsWith("==") - ? 2 - : item.bytes_base64.endsWith("=") - ? 1 - : 0); + const decodedLength = Buffer.byteLength(item.bytes_base64, "base64"); return ( bytes > 0n && bytes < 12n && diff --git a/src/domain/paddedBase64.ts b/src/domain/paddedBase64.ts index b4402f6b5e..6260ae204b 100644 --- a/src/domain/paddedBase64.ts +++ b/src/domain/paddedBase64.ts @@ -3,8 +3,7 @@ import { isPaddedBase64 } from "./stringPatterns.js"; /** Validate padded syntax without imposing canonical zero pad bits. */ export const paddedBase64ByteLength = (value: string): number | undefined => { if (!isPaddedBase64(value)) return undefined; - const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; - return (value.length / 4) * 3 - padding; + return Buffer.byteLength(value, "base64"); }; export const decodePaddedBase64 = (value: string): Buffer | undefined => diff --git a/src/domain/stringPatterns.ts b/src/domain/stringPatterns.ts index 718cc1d1d4..b97d08e45c 100644 --- a/src/domain/stringPatterns.ts +++ b/src/domain/stringPatterns.ts @@ -32,10 +32,13 @@ const paddedBase64SchemaRules = [ { not: { pattern: INVALID_BASE64_PADDING_PATTERN.source } }, ]; -/** Canonical padded base64 with zero pad bits, including the empty encoding. */ +/** Canonical padded base64 with zero pad bits; invalid bytes never enter enclosing range checks. */ export const canonicalBase64Schema = z .string() - .refine(isCanonicalBase64, "Expected canonical padded base64") + .refine(isCanonicalBase64, { + message: "Expected canonical padded base64", + abort: true, + }) .meta({ allOf: [ ...paddedBase64SchemaRules,