fix(protobuf): reject ambiguous descriptor identities

Validate message-local field numbers and fully qualified registry identities during existing descriptor admission before constructing the schema registry. Preserve repeated wire occurrences, enum aliases and independent scopes, with bounded name allocation and retained capture provenance through CLI/MCP refusals.

Fixes #6834
This commit is contained in:
morluto committed 2026-10-24 04:42:09 +08:00
1 parent 3b94f2c2ef
commit 3e3c9eadb5
4 files changed
+520 -19

No files matched your search

+9 -1
View File
@@ -166,7 +166,15 @@ fully qualified message type. All imports must be included. REA never fetches
schemas or executes the selected application. Descriptor identity and parser
version remain in the body facts. Scalars, repeated numeric packing and nested
messages are interpreted alongside the unchanged wire facts; disagreements
remain explicit. Map semantics, extensions and required-field validation are
remain explicit. Descriptor admission rejects duplicate field numbers within
each message, including nested messages and oneof fields, before schema
annotation. This does not reject repeated occurrences in captured wire bytes
or reuse of a number in a different message. Fully qualified message, enum,
service and extension names must also identify one declaration across the
descriptor set, including nested and synthetic map-entry messages. Different
packages or enclosing messages may reuse a local type name. Schema-admission
failures retain the partial capture Evidence and its original artifact identity.
Map semantics, extensions and required-field validation are
unsupported. A supplied schema does not establish the sender's actual schema.
An invalid nested interpretation records `schema_status: "invalid-message"`
and `schema_error` locally; later outer fields remain available. Valid string
+36 -18
View File
@@ -39,42 +39,60 @@ const scalarWire = (scalar: ScalarType): ProtobufField["wire_type"] => {
const floatingValue = (value: number): string =>
Object.is(value, -0) ? "-0" : String(value);
/** Qualified names can expand a small descriptor into a much larger registry. */
const chargeRegistryExpansion = async (
/** Admit descriptor identities and bound qualified names before registry construction. */
const admitRegistryDescriptor = async (
descriptor: FileDescriptorSet,
budget: ProtobufBudget,
): Promise<void> => {
const registryNames = new Set<string>();
const chargeQualifiedName = (name: string, prefix: string) =>
budget.charge((prefix.length + 1 + name.length) * 8 + 128);
const admitTypeName = (name: string, prefix: string): string => {
chargeQualifiedName(name, prefix);
budget.charge(32);
const qualified = prefix === "" ? name : `${prefix}.${name}`;
if (registryNames.has(qualified))
throw new Error(
"Duplicate fully qualified type name in FileDescriptorSet",
);
registryNames.add(qualified);
return qualified;
};
for (const file of descriptor.file) {
budget.charge(file.name.length * 8 + 128);
const stack = file.messageType.map((message) => ({
message,
prefix: file.package.length,
prefix: file.package,
}));
const chargeQualifiedName = (name: string, prefix: number) =>
budget.charge((prefix + 1 + name.length) * 8 + 128);
for (const enumeration of file.enumType)
chargeQualifiedName(enumeration.name, file.package.length);
admitTypeName(enumeration.name, file.package);
for (const extension of file.extension)
chargeQualifiedName(extension.name, file.package.length);
admitTypeName(extension.name, file.package);
for (const service of file.service) {
const length = file.package.length + 1 + service.name.length;
budget.charge(length * 8 + 128);
const qualified = admitTypeName(service.name, file.package);
for (const method of service.method)
chargeQualifiedName(method.name, length);
chargeQualifiedName(method.name, qualified);
}
while (stack.length > 0) {
await budget.checkpoint();
const { message, prefix } = stack.pop()!;
const length = prefix + 1 + message.name.length;
budget.charge(length * 8 + 128);
for (const field of message.field)
chargeQualifiedName(field.name, length);
const qualified = admitTypeName(message.name, prefix);
budget.charge(message.field.length * 32);
const fieldNumbers = new Set<number>();
for (const field of message.field) {
chargeQualifiedName(field.name, qualified);
if (fieldNumbers.has(field.number))
throw new Error(
"Duplicate field number in FileDescriptorSet message",
);
fieldNumbers.add(field.number);
}
for (const extension of message.extension)
chargeQualifiedName(extension.name, length);
admitTypeName(extension.name, qualified);
for (const enumeration of message.enumType)
chargeQualifiedName(enumeration.name, length);
admitTypeName(enumeration.name, qualified);
for (const nested of message.nestedType)
stack.push({ message: nested, prefix: length });
stack.push({ message: nested, prefix: qualified });
}
}
};
@@ -151,7 +169,7 @@ export class ProtobufSchema implements WireSchema {
readUnknownFields: false,
recursionLimit: WEB_NETWORK_CAPTURE_LIMITS.depth,
});
await chargeRegistryExpansion(descriptor, budget);
await admitRegistryDescriptor(descriptor, budget);
const message = createFileRegistry(descriptor).getMessage(
typeName.replace(/^\./, ""),
);
@@ -0,0 +1,257 @@
import { create, toBinary } from "@bufbuild/protobuf";
import {
FieldDescriptorProtoSchema,
FileDescriptorSetSchema,
OneofDescriptorProtoSchema,
} from "@bufbuild/protobuf/wkt";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
import { protobufHar } from "../../fixtures/protobufCapture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const sha256 = (bytes: string | Uint8Array) =>
createHash("sha256").update(bytes).digest("hex");
// Two occurrences of first=1, second=3, then child { value=4 }.
const body = Buffer.from("0801080210031a020804", "hex");
const descriptorDefinition = () =>
create(FileDescriptorSetSchema, {
file: [
{
name: "identity.proto",
package: "capture",
syntax: "proto3",
messageType: [
{
name: "Message",
field: [
{ name: "first", number: 1, type: 5, label: 3 },
{ name: "second", number: 2, type: 5, label: 1 },
{
name: "child",
number: 3,
type: 11,
typeName: ".capture.Message.Child",
label: 1,
},
],
nestedType: [
{
name: "Child",
field: [{ name: "value", number: 1, type: 5, label: 1 }],
},
],
},
],
},
],
});
const retainedDetailsSchema = z.object({
partial_observation: z.strictObject({
kind: z.literal("retained-evidence"),
evidence_id: z.string(),
}),
});
cliTest(
"refuses ambiguous descriptor field identities through CLI/MCP while retaining source Evidence",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-protobuf-field-identity-");
const capture = join(root, "capture.har");
const text = protobufHar(body, "application/x-protobuf");
await writeFile(capture, text);
const call = await cli.sharedMcpCall!();
for (const variant of ["forward", "reverse", "nested", "oneof"]) {
const definition = descriptorDefinition();
const message = definition.file[0]!.messageType[0]!;
if (variant === "nested") {
message.nestedType[0]!.field.push(
create(FieldDescriptorProtoSchema, {
name: "alias",
number: 1,
type: 5,
label: 1,
}),
);
} else if (variant === "oneof") {
message.oneofDecl.push(
create(OneofDescriptorProtoSchema, { name: "choice" }),
);
message.field.push(
create(FieldDescriptorProtoSchema, {
name: "alias",
number: 1,
type: 5,
label: 1,
oneofIndex: 0,
}),
);
} else {
message.field[1]!.number = 1;
if (variant === "reverse") message.field.reverse();
}
const descriptor = join(root, `${variant}.pb`);
const bytes = toBinary(FileDescriptorSetSchema, definition);
await writeFile(descriptor, bytes);
const output = await cli.sharedCliRun!({
arguments: [
"inspect-web-network-capture",
capture,
"har",
"--record",
"0",
"--protobuf-schema",
descriptor,
"--protobuf-message",
"capture.Message",
"--json",
],
});
const expected = {
code: "invalid_request",
details: {
issues: [{ path: ["protobuf", "schema"], reason: "invalid_format" }],
},
};
expect(output.exitCode, variant).toBe(1);
expect(output.json, variant).toMatchObject(expected);
const response = await call("inspect_web_network_capture", {
capture_path: capture,
format: "har",
record_ordinals: [0],
protobuf: {
schema: {
descriptor_set_path: descriptor,
message_type: "capture.Message",
},
},
});
const error = parseMcpToolError(response).error;
expect(error, variant).toMatchObject(expected);
const source = retainedDetailsSchema.parse(
error.details,
).partial_observation;
const retained = await call("inspect_analysis_view", {
source,
view: { kind: "summary" },
});
expect(retained.isError, JSON.stringify(retained)).not.toBe(true);
expect(
analysisViewResultSchema.parse(
parseEvidence(retained.structuredContent).normalized_result,
),
).toMatchObject({
summary: {
artifact: {
path: capture,
bytes: Buffer.byteLength(text),
sha256: sha256(text),
},
retained_records: 1,
protobuf_coverage: { status: "partial", inspected_records: 0 },
},
});
expect(await readFile(descriptor)).toEqual(Buffer.from(bytes));
}
expect(await readFile(capture, "utf8")).toBe(text);
},
);
cliTest(
"keeps repeated wire occurrences and message-local number reuse through CLI/MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-protobuf-field-control-");
const capture = join(root, "capture.har");
const descriptor = join(root, "valid.pb");
const definition = toBinary(
FileDescriptorSetSchema,
descriptorDefinition(),
);
await writeFile(capture, protobufHar(body, "application/x-protobuf"));
await writeFile(descriptor, definition);
const output = await cli.sharedCliRun!({
arguments: [
"inspect-web-network-capture",
capture,
"har",
"--record",
"0",
"--protobuf-schema",
descriptor,
"--protobuf-message",
"capture.Message",
"--json",
],
});
expect(output.exitCode, output.stdout + output.stderr).toBe(0);
const evidence = parseEvidence(output.json);
const report = webNetworkCaptureSchema.parse(evidence.normalized_result);
expect(report.protobuf_coverage?.status).toBe("complete");
const decoded = report.records[0]?.protobuf?.[0];
expect(decoded?.schema).toMatchObject({
descriptor_set_path: descriptor,
descriptor_set_sha256: sha256(definition),
descriptor_set_bytes: definition.length,
message_type: "capture.Message",
});
expect(decoded?.fields).toMatchObject([
{
field_id: "offset:0",
number: 1,
schema_name: "first",
schema_value: "1",
},
{
field_id: "offset:2",
number: 1,
schema_name: "first",
schema_value: "2",
},
{
field_id: "offset:4",
number: 2,
schema_name: "second",
schema_value: "3",
},
{
field_id: "offset:6",
number: 3,
schema_name: "child",
schema_type: "capture.Message.Child",
},
{
field_id: "offset:8",
number: 1,
schema_name: "value",
schema_value: "4",
},
]);
expect(decoded?.fields).toHaveLength(5);
const call = await cli.sharedMcpCall!();
const response = await call("inspect_web_network_capture", {
capture_path: capture,
format: "har",
record_ordinals: [0],
protobuf: {
schema: {
descriptor_set_path: descriptor,
message_type: "capture.Message",
},
},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(parseEvidence(response.structuredContent).evidence_id).toBe(
evidence.evidence_id,
);
expect(await readFile(descriptor)).toEqual(Buffer.from(definition));
},
);
@@ -0,0 +1,218 @@
import { create, toBinary } from "@bufbuild/protobuf";
import {
DescriptorProtoSchema,
EnumDescriptorProtoSchema,
FileDescriptorSetSchema,
ServiceDescriptorProtoSchema,
} from "@bufbuild/protobuf/wkt";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { protobufHar } from "../../fixtures/protobufCapture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const scalarMessage = (name: string, fieldName: string) =>
create(DescriptorProtoSchema, {
name,
field: [{ name: fieldName, number: 1, type: 5, label: 1 }],
});
const descriptorDefinition = () =>
create(FileDescriptorSetSchema, {
file: [
{
name: "identity.proto",
package: "capture",
syntax: "proto3",
messageType: [scalarMessage("Message", "alpha")],
},
],
});
const collidingDescriptor = (scope: string, reverse: boolean) => {
const definition = descriptorDefinition();
const file = definition.file[0]!;
let messageType = "capture.Message";
if (scope === "same-file") {
file.messageType.push(scalarMessage("Message", "beta"));
if (reverse) file.messageType.reverse();
} else if (["across-files", "enum", "service"].includes(scope)) {
const other = structuredClone(file);
other.name = "other.proto";
if (scope === "enum") {
other.messageType = [];
other.enumType.push(
create(EnumDescriptorProtoSchema, {
name: "Message",
value: [{ name: "ZERO", number: 0 }],
}),
);
} else if (scope === "service") {
other.messageType = [];
other.service.push(
create(ServiceDescriptorProtoSchema, { name: "Message" }),
);
} else other.messageType[0]!.field[0]!.name = "beta";
definition.file.push(other);
if (reverse) definition.file.reverse();
} else {
const alternative =
scope === "map-entry"
? create(DescriptorProtoSchema, {
name: "Message",
options: { mapEntry: true },
field: [
{ name: "key", number: 1, type: 9, label: 1 },
{ name: "value", number: 2, type: 5, label: 1 },
],
})
: scalarMessage("Message", "beta");
const children = [file.messageType[0]!, alternative];
if (reverse) children.reverse();
file.messageType = [
create(DescriptorProtoSchema, { name: "Outer", nestedType: children }),
];
messageType = "capture.Outer.Message";
}
return { bytes: toBinary(FileDescriptorSetSchema, definition), messageType };
};
cliTest(
"refuses ambiguous registry identities independently of declaration order through CLI/MCP",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-protobuf-registry-identity-",
);
const capture = join(root, "capture.har");
const text = protobufHar(Buffer.from([8, 1]), "application/x-protobuf");
await writeFile(capture, text);
const call = await cli.sharedMcpCall!();
for (const scope of [
"same-file",
"across-files",
"nested",
"enum",
"service",
"map-entry",
])
for (const reverse of [false, true]) {
const { bytes, messageType } = collidingDescriptor(scope, reverse);
const descriptor = join(root, `${scope}-${reverse}.pb`);
await writeFile(descriptor, bytes);
const expected = {
code: "invalid_request",
details: {
issues: [
{ path: ["protobuf", "schema"], reason: "invalid_format" },
],
},
};
// Both orders cross the actual CLI adapter; other geometries reuse
// the production MCP session and the same application workflow.
if (scope === "same-file") {
const output = await cli.sharedCliRun!({
arguments: [
"inspect-web-network-capture",
capture,
"har",
"--record",
"0",
"--protobuf-schema",
descriptor,
"--protobuf-message",
messageType,
"--json",
],
});
expect(output.exitCode).toBe(1);
expect(output.json).toMatchObject(expected);
}
const response = await call("inspect_web_network_capture", {
capture_path: capture,
format: "har",
record_ordinals: [0],
protobuf: {
schema: {
descriptor_set_path: descriptor,
message_type: messageType,
},
},
});
expect(
parseMcpToolError(response).error,
`${scope}-${reverse}`,
).toMatchObject(expected);
expect(await readFile(descriptor)).toEqual(Buffer.from(bytes));
}
expect(await readFile(capture, "utf8")).toBe(text);
},
);
cliTest(
"keeps empty-package, package, parent and case-sensitive registry identities distinct",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-protobuf-registry-scopes-");
const capture = join(root, "capture.har");
const descriptor = join(root, "scopes.pb");
const definition = create(FileDescriptorSetSchema, {
file: [
{
name: "root.proto",
syntax: "proto3",
messageType: [
scalarMessage("Message", "root_value"),
scalarMessage("message", "case_value"),
create(DescriptorProtoSchema, {
name: "Outer",
nestedType: [scalarMessage("Message", "nested_value")],
}),
],
},
{
name: "package.proto",
package: "capture",
syntax: "proto3",
messageType: [scalarMessage("Message", "package_value")],
},
],
});
const bytes = toBinary(FileDescriptorSetSchema, definition);
await writeFile(
capture,
protobufHar(Buffer.from([8, 1]), "application/x-protobuf"),
);
await writeFile(descriptor, bytes);
const call = await cli.sharedMcpCall!();
for (const [messageType, fieldName] of [
["Message", "root_value"],
["message", "case_value"],
["Outer.Message", "nested_value"],
["capture.Message", "package_value"],
] as const) {
const response = await call("inspect_web_network_capture", {
capture_path: capture,
format: "har",
record_ordinals: [0],
protobuf: {
schema: {
descriptor_set_path: descriptor,
message_type: messageType,
},
},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const report = webNetworkCaptureSchema.parse(
parseEvidence(response.structuredContent).normalized_result,
);
expect(report.protobuf_coverage?.status).toBe("complete");
expect(report.records[0]?.protobuf?.[0]?.fields).toMatchObject([
{ number: 1, schema_name: fieldName, schema_value: "1" },
]);
}
expect(await readFile(descriptor)).toEqual(Buffer.from(bytes));
},
);