fix(erlang): validate table framing before row capacity (#9606)

fix(erlang): validate BEAM table extents before capacity
This commit is contained in:
morluto authored and GitHub committed 2026-11-09 19:35:01 +08:00
1 parent 7c821cfea1
commit 97fe58d3fe
5 files changed
+94 -27

No files matched your search

+12
View File
@@ -98,6 +98,18 @@ export const erlangBeamDeclaredRowsFixture = (
return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form);
};
/** BEAM table frames with all declared fixed-width row bytes present. */
export const erlangBeamRowsFixture = (
counts: readonly (readonly ["ExpT" | "ImpT" | "FunT", number])[],
): Buffer =>
erlangBeamChunksFixture(
counts.map(([name, count]) => {
const payload = Buffer.alloc(4 + count * (name === "FunT" ? 24 : 12));
payload.writeUInt32BE(count);
return [name, payload] as const;
}),
);
/** BEAM form whose atom table exceeds a caller-selected atom budget. */
export const erlangBeamManyAtomsFixture = (count: number): Buffer => {
const names = Array.from(
+16 -4
View File
@@ -8,7 +8,7 @@ import {
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
erlangBeamChunksFixture,
erlangBeamDeclaredRowsFixture,
erlangBeamRowsFixture,
erlangBeamFixture,
erlangBeamLegacyAtomsFixture,
erlangBeamManyAtomsFixture,
@@ -280,7 +280,7 @@ it("rejects combined table rows before allocating them", () => {
const overflow = MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1;
expect(() =>
parse(
erlangBeamDeclaredRowsFixture([
erlangBeamRowsFixture([
["ExpT", below],
["ImpT", below],
["FunT", overflow],
@@ -293,10 +293,22 @@ it("rejects combined table rows before allocating them", () => {
message: expect.stringContaining("row budget"),
}),
);
const complete = Buffer.alloc(
4 + (MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1) * 12,
);
complete.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1);
expect(() =>
parse(
erlangBeamChunksFixture([
["ExpT", complete],
["ImpT", Buffer.alloc(1)],
]),
),
).toThrow(/import table is truncated/);
const count = Buffer.alloc(4);
count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1);
expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow(
/row budget/,
/truncated/,
);
count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS);
expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow(
@@ -359,7 +371,7 @@ it.each([
},
{
resource: "table-rows",
bytes: erlangBeamDeclaredRowsFixture([
bytes: erlangBeamRowsFixture([
["ExpT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1],
]),
settings: {},
+32 -20
View File
@@ -172,15 +172,19 @@ const parseAtoms = (
return atoms;
};
const tableCount = (payload: Buffer, width: number, label: string): number => {
if (payload.length < 4) fail(`BEAM ${label} table is truncated.`);
const count = u32(payload, 0);
if (count > Math.floor((payload.length - 4) / (width * 4)))
fail(`BEAM ${label} table is truncated.`);
return count;
};
const parseRows = (
payload: Buffer,
width: number,
label: string,
count: number,
): number[][] => {
if (payload.length < 4) fail(`BEAM ${label} table is truncated.`);
const count = u32(payload, 0);
const need = 4 + count * width * 4;
if (payload.length < need) fail(`BEAM ${label} table is truncated.`);
const rows: number[][] = [];
for (let index = 0; index < count; index += 1) {
const row: number[] = [];
@@ -278,47 +282,55 @@ export const parseErlangBeam = (
atomChunk === undefined || atomFormat === null
? null
: parseAtoms(atomChunk.payload, input.max_atoms, atomFormat);
const declaredRows = (name: string): number => {
const payload = payloads.get(name)?.payload;
return payload !== undefined && payload.length >= 4 ? u32(payload, 0) : 0;
};
const exportChunk = payloads.get("ExpT");
const importChunk = payloads.get("ImpT");
const lambdaChunk = payloads.get("FunT");
const exportCount =
exportChunk === undefined
? 0
: tableCount(exportChunk.payload, 3, "export");
const importCount =
importChunk === undefined
? 0
: tableCount(importChunk.payload, 3, "import");
// OTP skips the optional lambda parser for a zero-byte selected chunk.
const lambdaCount =
lambdaChunk === undefined || lambdaChunk.payload.length === 0
? 0
: tableCount(lambdaChunk.payload, 6, "lambda");
if (
declaredRows("ExpT") + declaredRows("ImpT") + declaredRows("FunT") >
exportCount + importCount + lambdaCount >
MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS
)
throw new BeamCapacityFailure(
"table-rows",
MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS,
`BEAM tables exceed the ${String(
MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS,
)}-row budget.`,
`BEAM tables exceed the ${String(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS)}-row budget.`,
);
const exports =
payloads.get("ExpT") === undefined
exportChunk === undefined
? null
: parseRows(payloads.get("ExpT")!.payload, 3, "export").map((row) => ({
: parseRows(exportChunk.payload, 3, exportCount).map((row) => ({
function_index: row[0]!,
function: atomAt(atoms, row[0]!),
arity: row[1]!,
label: row[2]!,
}));
const imports =
payloads.get("ImpT") === undefined
importChunk === undefined
? null
: parseRows(payloads.get("ImpT")!.payload, 3, "import").map((row) => ({
: parseRows(importChunk.payload, 3, importCount).map((row) => ({
module_index: row[0]!,
module: atomAt(atoms, row[0]!),
function_index: row[1]!,
function: atomAt(atoms, row[1]!),
arity: row[2]!,
}));
const lambdaChunk = payloads.get("FunT");
// OTP skips the optional lambda parser for a zero-byte selected chunk.
// A four-byte zero count remains a decoded, explicitly empty table.
const lambdas =
lambdaChunk === undefined || lambdaChunk.payload.length === 0
? null
: parseRows(lambdaChunk.payload, 6, "lambda").map((row) => ({
: parseRows(lambdaChunk.payload, 6, lambdaCount).map((row) => ({
function_index: row[0]!,
function: atomAt(atoms, row[0]!),
arity: row[1]!,
+2 -2
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { createBeamService } from "../../../src/composition/erlang.js";
import { MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS } from "../../../src/domain/erlang/beam.js";
import {
erlangBeamDeclaredRowsFixture,
erlangBeamRowsFixture,
erlangBeamFixture,
} from "../../../src/erlang/Beam.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -66,7 +66,7 @@ describe("Erlang BEAM stable filesystem boundary", () => {
const below = Math.floor(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS / 2);
await writeFile(
path,
erlangBeamDeclaredRowsFixture([
erlangBeamRowsFixture([
["ExpT", below],
["ImpT", below],
["FunT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1],
+32 -1
View File
@@ -17,6 +17,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
erlangBeamDeclaredRowsFixture,
erlangBeamRowsFixture,
erlangBeamFixture,
} from "../../../src/erlang/Beam.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -106,7 +107,7 @@ mcpTest(
],
[
"rows",
erlangBeamDeclaredRowsFixture([
erlangBeamRowsFixture([
["ExpT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1],
]),
{},
@@ -177,3 +178,33 @@ mcpTest(
}
},
);
mcpTest(
"reports impossible BEAM table counts as malformed input",
async ({ mcp }) => {
const { client, session } = await connect(mcp);
const root = await createTestTempDirectory("rea-beam-malformed-rows-");
try {
for (const chunk of ["ExpT", "ImpT", "FunT"]) {
const path = join(root, `${chunk}.beam`);
await writeFile(
path,
erlangBeamDeclaredRowsFixture([
[chunk, MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1],
]),
);
const response = await client.callTool({
name: "inspect_erlang_beam",
arguments: { path },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response).error).toMatchObject({
code: "invalid_request",
});
}
} finally {
await session.close();
}
},
60_000,
);