mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 20:51:10 +00:00
Config: harden schema lookup traversal
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "[]",
|
||||
|
||||
Reference in New Issue
Block a user