Config: bound schema lookup paths

This commit is contained in:
Gustavo Madeira Santana
2026-03-06 02:24:34 -05:00
parent 5fc2d0ff39
commit 0c4d187f6f
4 changed files with 55 additions and 3 deletions

View File

@@ -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<typeof lookupConfigSchema>[0];
expect(lookupConfigSchema(deepSchema, deepPathSegments.join("."))).toBeNull();
});
it("returns null for missing config schema paths", () => {
expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull();
});

View File

@@ -51,6 +51,7 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
"readOnly",
"writeOnly",
]);
const MAX_LOOKUP_PATH_SEGMENTS = 32;
function cloneSchema<T>(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;

View File

@@ -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 },
);

View File

@@ -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",