diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 2c1780c5c6f..2d420efd868 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -271,6 +271,31 @@ describe("config schema", () => { expect(lookupConfigSchema(baseSchema, "__proto__.polluted")).toBeNull(); }); + it("rejects overly deep lookup paths", () => { + const buildNestedObjectSchema = (segments: string[]) => { + const [head, ...rest] = segments; + if (!head) { + return { type: "string" }; + } + return { + type: "object", + properties: { + [head]: buildNestedObjectSchema(rest), + }, + }; + }; + + const deepPathSegments = Array.from({ length: 33 }, (_, index) => `a${index}`); + const deepSchema = { + schema: buildNestedObjectSchema(deepPathSegments), + uiHints: {}, + version: "test", + generatedAt: "test", + } as unknown as Parameters[0]; + + expect(lookupConfigSchema(deepSchema, deepPathSegments.join("."))).toBeNull(); + }); + it("returns null for missing config schema paths", () => { expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull(); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index bd4dbbac9cd..83227a375d5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -51,6 +51,7 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([ "readOnly", "writeOnly", ]); +const MAX_LOOKUP_PATH_SEGMENTS = 32; function cloneSchema(value: T): T { if (typeof structuredClone === "function") { @@ -682,12 +683,16 @@ export function lookupConfigSchema( if (!normalizedPath) { return null; } + const parts = splitLookupPath(normalizedPath); + if (parts.length === 0 || parts.length > MAX_LOOKUP_PATH_SEGMENTS) { + return null; + } let current = asSchemaObject(response.schema); if (!current) { return null; } - for (const segment of splitLookupPath(normalizedPath)) { + for (const segment of parts) { const next = resolveLookupChildSchema(current, segment); if (!next) { return null; diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index d3701fe00a0..78159549255 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -1,7 +1,11 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString } from "./primitives.js"; -const NonWhitespaceString = Type.String({ minLength: 1, pattern: ".*\\S.*" }); +const ConfigSchemaLookupPathString = Type.String({ + minLength: 1, + maxLength: 1024, + pattern: "^[A-Za-z0-9_.\\[\\]\\-*]+$", +}); export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false }); @@ -31,7 +35,7 @@ export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: export const ConfigSchemaLookupParamsSchema = Type.Object( { - path: NonWhitespaceString, + path: ConfigSchemaLookupPathString, }, { additionalProperties: false }, ); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index ad0974d8e39..44daced1684 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -87,6 +87,24 @@ describe("gateway config methods", () => { expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params"); }); + it("rejects config.schema.lookup when the path exceeds the protocol limit", async () => { + const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { + path: `gateway.${"a".repeat(1020)}`, + }); + + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params"); + }); + + it("rejects config.schema.lookup when the path contains invalid characters", async () => { + const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { + path: "gateway.auth\nspoof", + }); + + expect(res.ok).toBe(false); + 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",