Config: harden schema lookup traversal

This commit is contained in:
Gustavo Madeira Santana
2026-03-06 02:16:07 -05:00
parent 50721d84f4
commit dc2a885050
4 changed files with 120 additions and 15 deletions

View File

@@ -216,6 +216,13 @@ describe("config schema", () => {
expect(schema?.properties).toBeUndefined();
});
it("returns a shallow lookup schema without nested composition keywords", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
expect(lookup?.path).toBe("agents.list.0.runtime");
expect(lookup?.hintPath).toBe("agents.list[].runtime");
expect(lookup?.schema).toEqual({});
});
it("matches wildcard ui hints for concrete lookup paths", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
@@ -258,6 +265,12 @@ describe("config schema", () => {
expect((lookup?.schema as { items?: unknown } | undefined)?.items).toBeUndefined();
});
it("rejects prototype-chain lookup segments", () => {
expect(() => lookupConfigSchema(baseSchema, "constructor")).not.toThrow();
expect(lookupConfigSchema(baseSchema, "constructor")).toBeNull();
expect(lookupConfigSchema(baseSchema, "__proto__.polluted")).toBeNull();
});
it("returns null for missing config schema paths", () => {
expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull();
});

View File

@@ -20,6 +20,38 @@ type JsonSchemaObject = JsonSchemaNode & {
items?: JsonSchemaObject | JsonSchemaObject[];
};
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
"$id",
"$schema",
"title",
"description",
"format",
"pattern",
"contentEncoding",
"contentMediaType",
]);
const LOOKUP_SCHEMA_NUMBER_KEYS = new Set([
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"minItems",
"maxItems",
"minProperties",
"maxProperties",
]);
const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
"additionalProperties",
"uniqueItems",
"deprecated",
"readOnly",
"writeOnly",
]);
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
@@ -530,9 +562,13 @@ function resolveLookupChildSchema(
schema: JsonSchemaObject,
segment: string,
): JsonSchemaObject | null {
const explicit = schema.properties?.[segment];
if (explicit) {
return explicit;
if (FORBIDDEN_LOOKUP_SEGMENTS.has(segment)) {
return null;
}
const properties = schema.properties;
if (properties && Object.hasOwn(properties, segment)) {
return asSchemaObject(properties[segment]);
}
const itemIndex = /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : undefined;
@@ -548,15 +584,54 @@ function resolveLookupChildSchema(
return null;
}
function stripNestedSchema(schema: JsonSchemaObject): JsonSchemaNode {
const next = cloneSchema(schema);
delete next.properties;
if (next.additionalProperties && typeof next.additionalProperties === "object") {
delete next.additionalProperties;
}
if (next.items !== undefined) {
delete next.items;
function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
const next: JsonSchemaNode = {};
for (const [key, value] of Object.entries(schema)) {
if (LOOKUP_SCHEMA_STRING_KEYS.has(key) && typeof value === "string") {
next[key] = value;
continue;
}
if (LOOKUP_SCHEMA_NUMBER_KEYS.has(key) && typeof value === "number") {
next[key] = value;
continue;
}
if (LOOKUP_SCHEMA_BOOLEAN_KEYS.has(key) && typeof value === "boolean") {
next[key] = value;
continue;
}
if (key === "type") {
if (typeof value === "string") {
next[key] = value;
} else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
next[key] = [...value];
}
continue;
}
if (key === "enum" && Array.isArray(value)) {
const entries = value.filter(
(entry) =>
entry === null ||
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean",
);
if (entries.length === value.length) {
next[key] = [...entries];
}
continue;
}
if (
key === "const" &&
(value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean")
) {
next[key] = value;
}
}
return next;
}
@@ -623,7 +698,7 @@ export function lookupConfigSchema(
const resolvedHint = resolveUiHintMatch(response.uiHints, normalizedPath);
return {
path: normalizedPath,
schema: stripNestedSchema(current),
schema: stripSchemaForLookup(current),
hint: resolvedHint?.hint,
hintPath: resolvedHint?.path,
children: buildLookupChildren(current, normalizedPath, response.uiHints),

View File

@@ -120,6 +120,14 @@ function parseRawConfigOrRespond(
return rawValue;
}
function sanitizeLookupPathForLog(path: string): string {
const sanitized = Array.from(path, (char) => {
const code = char.charCodeAt(0);
return code < 0x20 || code === 0x7f ? "?" : char;
}).join("");
return sanitized.length > 120 ? `${sanitized.slice(0, 117)}...` : sanitized;
}
function parseValidateConfigFromRawOrRespond(
params: unknown,
requestName: string,
@@ -278,14 +286,14 @@ export const configHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `config schema path not found: ${path}`),
errorShape(ErrorCodes.INVALID_REQUEST, "config schema path not found"),
);
return;
}
if (!validateConfigSchemaLookupResult(result)) {
const errors = validateConfigSchemaLookupResult.errors ?? [];
context.logGateway.warn(
`config.schema.lookup produced invalid payload for ${path}: ${formatValidationErrors(errors)}`,
`config.schema.lookup produced invalid payload for ${sanitizeLookupPathForLog(path)}: ${formatValidationErrors(errors)}`,
);
respond(
false,

View File

@@ -75,7 +75,7 @@ describe("gateway config methods", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("config schema path not found");
expect(res.error?.message).toBe("config schema path not found");
});
it("rejects config.schema.lookup when the path is only whitespace", async () => {
@@ -87,6 +87,15 @@ describe("gateway config methods", () => {
expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params");
});
it("rejects prototype-chain config.schema.lookup paths without reflecting them", async () => {
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", {
path: "constructor",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toBe("config schema path not found");
});
it("rejects config.patch when raw is not an object", async () => {
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", {
raw: "[]",