Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+122
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS,
|
||||
MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH,
|
||||
parsePluginCapabilityGrantArray,
|
||||
parsePluginCapabilityNameArray,
|
||||
readPluginConsentJsonObject,
|
||||
readPluginConsentUrlEncodedFormData,
|
||||
} from "./capabilityConsentPayload.server";
|
||||
|
||||
describe("plugin capability consent payload helpers", () => {
|
||||
test("parses valid capability name arrays", () => {
|
||||
expect(parsePluginCapabilityNameArray(["http.outbound", "log"])).toEqual({
|
||||
ok: true,
|
||||
capabilityNames: ["http.outbound", "log"],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed capability name arrays", () => {
|
||||
const cases: unknown[] = [
|
||||
[],
|
||||
Array.from({ length: MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS + 1 }, (_, index) => `capability.${index}`),
|
||||
["http.outbound", "http.outbound"],
|
||||
[" http.outbound"],
|
||||
["x".repeat(MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH + 1)],
|
||||
["log", null],
|
||||
];
|
||||
|
||||
for (const value of cases) {
|
||||
expect(parsePluginCapabilityNameArray(value)).toEqual({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
test("parses valid capability grant arrays", () => {
|
||||
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }])).toEqual({
|
||||
ok: true,
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects duplicate capability grants", () => {
|
||||
expect(
|
||||
parsePluginCapabilityGrantArray([
|
||||
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } },
|
||||
{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api2.example.com"] } },
|
||||
])
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("rejects unsafe capability grant scopes", () => {
|
||||
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: ["api.example.com"] }])).toEqual({ ok: false });
|
||||
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { __proto__: { polluted: true } } }])).toEqual({ ok: false });
|
||||
expect(parsePluginCapabilityGrantArray([{ capabilityName: "http.outbound", scopeJson: { note: "x".repeat(16 * 1024) } }])).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("reads bounded JSON objects", async () => {
|
||||
const response = await readPluginConsentJsonObject(
|
||||
new Request("http://localhost/plugins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pluginId: "com.acme.hello" }),
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(response).toEqual({ ok: true, value: { pluginId: "com.acme.hello" } });
|
||||
});
|
||||
|
||||
test("rejects JSON object bodies without an application/json content type", async () => {
|
||||
for (const headers of [{ "content-type": "text/plain" }, {}]) {
|
||||
await expect(
|
||||
readPluginConsentJsonObject(
|
||||
new Request("http://localhost/plugins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pluginId: "com.acme.hello" }),
|
||||
headers,
|
||||
})
|
||||
)
|
||||
).resolves.toEqual({ ok: false, error: "unsupported_media_type" });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects oversized request bodies before parsing", async () => {
|
||||
const response = await readPluginConsentUrlEncodedFormData(
|
||||
new Request("http://localhost/plugins", {
|
||||
method: "POST",
|
||||
body: "op=enable&id=pl-1",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"content-length": String(129 * 1024),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("accepts only exact urlencoded form content types", async () => {
|
||||
const valid = await readPluginConsentUrlEncodedFormData(
|
||||
new Request("http://localhost/plugins", {
|
||||
method: "POST",
|
||||
body: "op=enable&id=pl-1",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
|
||||
})
|
||||
);
|
||||
expect(valid.ok).toBe(true);
|
||||
if (valid.ok) {
|
||||
expect(valid.formData.get("op")).toBe("enable");
|
||||
}
|
||||
|
||||
for (const contentType of ["text/plain; application/x-www-form-urlencoded", "application/x-www-form-urlencoded-malformed"]) {
|
||||
await expect(
|
||||
readPluginConsentUrlEncodedFormData(
|
||||
new Request("http://localhost/plugins", {
|
||||
method: "POST",
|
||||
body: "op=enable&id=pl-1",
|
||||
headers: { "content-type": contentType },
|
||||
})
|
||||
)
|
||||
).resolves.toEqual({ ok: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { CapabilityGrant } from "~/modules/plugins/services/.server/PluginLifecycleService";
|
||||
|
||||
export const PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES = 128 * 1024;
|
||||
export const MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS = 64;
|
||||
export const MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH = 128;
|
||||
const MAX_PLUGIN_CONSENT_SCOPE_BYTES = 16 * 1024;
|
||||
const MAX_PLUGIN_CONSENT_SCOPE_DEPTH = 6;
|
||||
const MAX_PLUGIN_CONSENT_SCOPE_NODES = 512;
|
||||
const MAX_PLUGIN_CONSENT_SCOPE_ARRAY_ITEMS = 128;
|
||||
const MAX_PLUGIN_CONSENT_SCOPE_OBJECT_KEYS = 128;
|
||||
|
||||
type JsonObjectParseError = "invalid_json" | "unsupported_media_type";
|
||||
type JsonObjectResult = { ok: true; value: Record<string, unknown> } | { ok: false; error: JsonObjectParseError };
|
||||
type CapabilityGrantParseResult = { ok: true; grants: CapabilityGrant[] } | { ok: false };
|
||||
type CapabilityNamesParseResult = { ok: true; capabilityNames: string[] } | { ok: false };
|
||||
type FormDataParseResult = { ok: true; formData: FormData } | { ok: false };
|
||||
|
||||
export async function readPluginConsentJsonObject(request: Request): Promise<JsonObjectResult> {
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.split(";", 1)[0]?.trim() !== "application/json") {
|
||||
return { ok: false, error: "unsupported_media_type" };
|
||||
}
|
||||
if (isRequestBodyOverLimit(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES)) {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
try {
|
||||
const text = await readLimitedRequestText(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES);
|
||||
if (text === null) {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
const value = JSON.parse(text) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
return { ok: true, value: value as Record<string, unknown> };
|
||||
} catch {
|
||||
return { ok: false, error: "invalid_json" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPluginConsentUrlEncodedFormData(request: Request): Promise<FormDataParseResult> {
|
||||
if (isRequestBodyOverLimit(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES)) {
|
||||
return { ok: false };
|
||||
}
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.split(";", 1)[0]?.trim() !== "application/x-www-form-urlencoded") {
|
||||
return { ok: false };
|
||||
}
|
||||
const text = await readLimitedRequestText(request, PLUGIN_CONSENT_MAX_REQUEST_BODY_BYTES);
|
||||
if (text === null) {
|
||||
return { ok: false };
|
||||
}
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of new URLSearchParams(text)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
return { ok: true, formData };
|
||||
}
|
||||
|
||||
export function parsePluginCapabilityGrantArray(value: unknown): CapabilityGrantParseResult {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS) return { ok: false };
|
||||
const grants: CapabilityGrant[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const grant of value) {
|
||||
if (!isPlainObject(grant)) return { ok: false };
|
||||
const capabilityName = grant.capabilityName;
|
||||
if (!isValidCapabilityName(capabilityName) || seen.has(capabilityName)) {
|
||||
return { ok: false };
|
||||
}
|
||||
seen.add(capabilityName);
|
||||
const scopeJson = parseGrantScope(grant.scopeJson);
|
||||
if (scopeJson === undefined) return { ok: false };
|
||||
grants.push({ capabilityName, scopeJson });
|
||||
}
|
||||
return { ok: true, grants };
|
||||
}
|
||||
|
||||
export function parsePluginCapabilityNameArray(value: unknown): CapabilityNamesParseResult {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_CONSENT_CAPABILITY_ITEMS) return { ok: false };
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const name of value) {
|
||||
if (!isValidCapabilityName(name) || seen.has(name)) {
|
||||
return { ok: false };
|
||||
}
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
}
|
||||
return { ok: true, capabilityNames: names };
|
||||
}
|
||||
|
||||
function isRequestBodyOverLimit(request: Request, maxBytes: number): boolean {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
||||
return Number.isFinite(contentLength) && contentLength > maxBytes;
|
||||
}
|
||||
|
||||
async function readLimitedRequestText(request: Request, maxBytes: number): Promise<string | null> {
|
||||
if (!request.body) return "";
|
||||
const reader = request.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function isValidCapabilityName(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= MAX_PLUGIN_CONSENT_CAPABILITY_NAME_LENGTH && value.trim() === value;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isSafeJsonValue(value: unknown, depth = 0, budget = { nodes: 0 }): boolean {
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > MAX_PLUGIN_CONSENT_SCOPE_NODES || depth > MAX_PLUGIN_CONSENT_SCOPE_DEPTH) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.length <= MAX_PLUGIN_CONSENT_SCOPE_ARRAY_ITEMS && value.every((item) => isSafeJsonValue(item, depth + 1, budget));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const entries = Object.entries(value);
|
||||
return (
|
||||
entries.length <= MAX_PLUGIN_CONSENT_SCOPE_OBJECT_KEYS &&
|
||||
entries.every(([key, nested]) => key !== "__proto__" && key !== "constructor" && key !== "prototype" && isSafeJsonValue(nested, depth + 1, budget))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseGrantScope(value: unknown): unknown | undefined {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const encoded = JSON.stringify(value);
|
||||
if (encoded.length > MAX_PLUGIN_CONSENT_SCOPE_BYTES || !isSafeJsonValue(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCapabilityReviewDigest } from "./capabilityReviewDigest";
|
||||
|
||||
function plugin(overrides: Partial<{ pluginId: string; version: string; scope: string | null }> = {}) {
|
||||
return {
|
||||
pluginId: overrides.pluginId ?? "com.example.digest",
|
||||
version: overrides.version ?? "1.0.0",
|
||||
manifest: {
|
||||
capabilities: [
|
||||
{ name: "secret.read", scope: overrides.scope ?? "tenant" },
|
||||
{ name: "http.outbound", scope: "tenant" },
|
||||
{ name: "log", scope: null },
|
||||
],
|
||||
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createCapabilityReviewDigest", () => {
|
||||
it("is stable regardless of requested capability order", () => {
|
||||
expect(createCapabilityReviewDigest(plugin(), ["secret.read", "http.outbound"])).toBe(
|
||||
createCapabilityReviewDigest(plugin(), ["http.outbound", "secret.read"])
|
||||
);
|
||||
});
|
||||
|
||||
it("only binds selected capabilities into the review digest", () => {
|
||||
const selected = createCapabilityReviewDigest(plugin(), ["log"]);
|
||||
const selectedWithUnmanifested = createCapabilityReviewDigest(plugin(), ["log", "db.write"]);
|
||||
const broaderGrant = createCapabilityReviewDigest(plugin(), ["log", "secret.read"]);
|
||||
|
||||
expect(selectedWithUnmanifested).toBe(selected);
|
||||
expect(broaderGrant).not.toBe(selected);
|
||||
});
|
||||
|
||||
it("binds manifest hook consent items into the review digest", () => {
|
||||
const hookReviewDigest = createCapabilityReviewDigest(plugin(), ["hook:workflow.executed"]);
|
||||
const capabilityReviewDigest = createCapabilityReviewDigest(plugin(), ["log"]);
|
||||
const combinedReviewDigest = createCapabilityReviewDigest(plugin(), ["log", "hook:workflow.executed"]);
|
||||
|
||||
expect(hookReviewDigest).not.toBe(capabilityReviewDigest);
|
||||
expect(combinedReviewDigest).not.toBe(capabilityReviewDigest);
|
||||
expect(combinedReviewDigest).not.toBe(hookReviewDigest);
|
||||
});
|
||||
|
||||
it("does not allow a partial review digest to stand in for a broader submitted grant set", () => {
|
||||
const partialReviewDigest = createCapabilityReviewDigest(plugin(), ["log"]);
|
||||
const submittedGrantSetDigest = createCapabilityReviewDigest(plugin(), ["log", "secret.read"]);
|
||||
|
||||
expect(submittedGrantSetDigest).not.toBe(partialReviewDigest);
|
||||
});
|
||||
|
||||
it("changes when plugin identity version or capability scope changes", () => {
|
||||
const baseline = createCapabilityReviewDigest(plugin(), ["secret.read"]);
|
||||
|
||||
expect(createCapabilityReviewDigest(plugin({ pluginId: "com.example.other" }), ["secret.read"])).not.toBe(baseline);
|
||||
expect(createCapabilityReviewDigest(plugin({ version: "1.0.1" }), ["secret.read"])).not.toBe(baseline);
|
||||
expect(createCapabilityReviewDigest(plugin({ scope: "workspace" }), ["secret.read"])).not.toBe(baseline);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PluginListItem } from "../services/.server/PluginLifecycleService";
|
||||
import { getPluginConsentRequirements } from "./pluginConsentRequirements";
|
||||
|
||||
export function createCapabilityReviewDigest(item: Pick<PluginListItem, "pluginId" | "version" | "manifest">, capabilityNames: ReadonlyArray<string>) {
|
||||
const selectedCapabilities = new Set(capabilityNames);
|
||||
const reviewPayload = {
|
||||
pluginId: item.pluginId,
|
||||
version: item.version,
|
||||
capabilities: getPluginConsentRequirements(item.manifest)
|
||||
.filter((requirement) => selectedCapabilities.has(requirement.name))
|
||||
.map((requirement) => ({
|
||||
name: requirement.name,
|
||||
scope: requirement.scope ?? null,
|
||||
kind: requirement.kind,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
};
|
||||
return createHash("sha256").update(stableJsonStringify(reviewPayload)).digest("base64url");
|
||||
}
|
||||
|
||||
function stableJsonStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJsonStringify(item)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableJsonStringify(nestedValue)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginManifest } from "@toolctrl/plugin-runtime/manifest";
|
||||
|
||||
export const PLUGIN_HOOK_CONSENT_PREFIX = "hook:";
|
||||
|
||||
export type PluginConsentRequirement = {
|
||||
name: string;
|
||||
scope: PluginManifest["capabilities"][number]["scope"] | null;
|
||||
kind: "capability" | "hook";
|
||||
};
|
||||
|
||||
export function getPluginHookConsentName(hookName: string): string {
|
||||
return `${PLUGIN_HOOK_CONSENT_PREFIX}${hookName}`;
|
||||
}
|
||||
|
||||
export function isPluginHookConsentName(name: string): boolean {
|
||||
return name.startsWith(PLUGIN_HOOK_CONSENT_PREFIX);
|
||||
}
|
||||
|
||||
export function getPluginConsentRequirements(manifest: PluginManifest): PluginConsentRequirement[] {
|
||||
return [
|
||||
...(manifest.capabilities ?? []).map((capability) => ({
|
||||
name: capability.name as string,
|
||||
scope: capability.scope ?? null,
|
||||
kind: "capability" as const,
|
||||
})),
|
||||
...(manifest.hooks ?? []).map((hook) => ({
|
||||
name: getPluginHookConsentName(hook.name),
|
||||
scope: null,
|
||||
kind: "hook" as const,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PLUGIN_SETTING_ARRAY_MAX_LENGTH,
|
||||
PLUGIN_SETTING_KEY_MAX_LENGTH,
|
||||
PLUGIN_SETTING_OBJECT_MAX_KEYS,
|
||||
PLUGIN_SETTING_VALUE_MAX_BYTES,
|
||||
isPluginSettingKeyFormatSafe,
|
||||
isPluginSettingKeyReserved,
|
||||
isPluginSettingKeySecretLike,
|
||||
isPluginSettingValue,
|
||||
isPluginSettingValueShapeSafe,
|
||||
isPluginSettingValueWithinSize,
|
||||
isSafePluginSettingKey,
|
||||
} from "./pluginSettingsValidation";
|
||||
|
||||
describe("plugin settings validation", () => {
|
||||
it("keeps frame and persistence setting keys on the same safe-key contract", () => {
|
||||
expect(isSafePluginSettingKey("theme.accent:color-1")).toBe(true);
|
||||
expect(isPluginSettingKeyFormatSafe("k".repeat(PLUGIN_SETTING_KEY_MAX_LENGTH))).toBe(true);
|
||||
expect(isPluginSettingKeyFormatSafe("k".repeat(PLUGIN_SETTING_KEY_MAX_LENGTH + 1))).toBe(false);
|
||||
|
||||
for (const key of [
|
||||
"../theme",
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"apiKey",
|
||||
"access-token",
|
||||
"password",
|
||||
"secret.value",
|
||||
"authorization",
|
||||
"credentials",
|
||||
"auth.header",
|
||||
"bearer",
|
||||
"cookie",
|
||||
"sessionId",
|
||||
"private_key",
|
||||
"clientSecret",
|
||||
"webhookUrl",
|
||||
]) {
|
||||
expect(isSafePluginSettingKey(key)).toBe(false);
|
||||
}
|
||||
expect(isPluginSettingKeyReserved("__proto__")).toBe(true);
|
||||
expect(isPluginSettingKeySecretLike("refresh-token")).toBe(true);
|
||||
expect(isPluginSettingKeySecretLike("authorization")).toBe(true);
|
||||
expect(isPluginSettingKeySecretLike("clientSecret")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects values that cannot cross the iframe-to-persistence boundary safely", () => {
|
||||
expect(isPluginSettingValue({ theme: "dark", flags: [true, false], count: 2 })).toBe(true);
|
||||
expect(isPluginSettingValue(Number.NaN)).toBe(false);
|
||||
expect(isPluginSettingValue(Array.from({ length: PLUGIN_SETTING_ARRAY_MAX_LENGTH + 1 }, () => true))).toBe(false);
|
||||
expect(isPluginSettingValue(Object.fromEntries(Array.from({ length: PLUGIN_SETTING_OBJECT_MAX_KEYS + 1 }, (_, index) => [`key${index}`, true])))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isPluginSettingValue(JSON.parse('{"theme":"dark","__proto__":{"polluted":true}}'))).toBe(false);
|
||||
expect(isPluginSettingValue({ theme: { accessToken: "must-not-persist" } })).toBe(false);
|
||||
expect(isPluginSettingValue({ request: { authorization: "Bearer must-not-persist" } })).toBe(false);
|
||||
expect(isPluginSettingValue({ endpoint: { webhookUrl: "https://hooks.example.test/secret" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("separates JSON shape errors from serialized size errors for server error mapping", () => {
|
||||
const oversized = "x".repeat(PLUGIN_SETTING_VALUE_MAX_BYTES);
|
||||
|
||||
expect(isPluginSettingValueShapeSafe(oversized)).toBe(true);
|
||||
expect(isPluginSettingValueWithinSize(oversized)).toBe(false);
|
||||
expect(isPluginSettingValue(oversized)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
export const PLUGIN_SETTING_KEY_MAX_LENGTH = 128;
|
||||
export const PLUGIN_SETTING_VALUE_MAX_BYTES = 32 * 1024;
|
||||
export const PLUGIN_SETTING_VALUE_MAX_DEPTH = 8;
|
||||
export const PLUGIN_SETTING_VALUE_MAX_NODES = 2048;
|
||||
export const PLUGIN_SETTING_ARRAY_MAX_LENGTH = 512;
|
||||
export const PLUGIN_SETTING_OBJECT_MAX_KEYS = 256;
|
||||
|
||||
const SAFE_PLUGIN_SETTING_KEY_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/u;
|
||||
const SECRET_LIKE_SETTING_KEY_PATTERN =
|
||||
/(?:secret|token|password|credential|authorization|auth[-_.:]|[-_.:]auth(?:$|[-_.:])|bearer|cookie|session|private[-_.:]?key|client[-_.:]?secret|webhook|apikey|api[-_.:]?key)/iu;
|
||||
const RESERVED_PLUGIN_SETTING_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
export type PluginSettingValue = null | string | number | boolean | PluginSettingValue[] | { readonly [key: string]: PluginSettingValue };
|
||||
|
||||
export function isPluginSettingKeyFormatSafe(value: unknown): value is string {
|
||||
return typeof value === "string" && SAFE_PLUGIN_SETTING_KEY_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isPluginSettingKeyReserved(value: string): boolean {
|
||||
return RESERVED_PLUGIN_SETTING_KEYS.has(value);
|
||||
}
|
||||
|
||||
export function isPluginSettingKeySecretLike(value: string): boolean {
|
||||
return SECRET_LIKE_SETTING_KEY_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isSafePluginSettingKey(value: unknown): value is string {
|
||||
return isPluginSettingKeyFormatSafe(value) && !isPluginSettingKeyReserved(value) && !isPluginSettingKeySecretLike(value);
|
||||
}
|
||||
|
||||
export function isPluginSettingValue(value: unknown): value is PluginSettingValue {
|
||||
return isPluginSettingValueShapeSafe(value) && isPluginSettingValueWithinSize(value);
|
||||
}
|
||||
|
||||
export function isPluginSettingValueShapeSafe(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
budget: { nodes: number; seen: WeakSet<object> } = { nodes: 0, seen: new WeakSet<object>() }
|
||||
): value is PluginSettingValue {
|
||||
budget.nodes += 1;
|
||||
if (depth > PLUGIN_SETTING_VALUE_MAX_DEPTH || budget.nodes > PLUGIN_SETTING_VALUE_MAX_NODES) return false;
|
||||
if (value === null) return true;
|
||||
if (typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (budget.seen.has(value)) return false;
|
||||
budget.seen.add(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > PLUGIN_SETTING_ARRAY_MAX_LENGTH) return false;
|
||||
return value.every((item) => isPluginSettingValueShapeSafe(item, depth + 1, budget));
|
||||
}
|
||||
if (!isPlainPluginSettingsObject(value)) return false;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > PLUGIN_SETTING_OBJECT_MAX_KEYS) return false;
|
||||
return entries.every(
|
||||
([key, nested]) => !isPluginSettingKeyReserved(key) && !isPluginSettingKeySecretLike(key) && isPluginSettingValueShapeSafe(nested, depth + 1, budget)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPluginSettingValueWithinSize(value: PluginSettingValue): boolean {
|
||||
return getUtf8ByteLength(JSON.stringify(value)) <= PLUGIN_SETTING_VALUE_MAX_BYTES;
|
||||
}
|
||||
|
||||
function isPlainPluginSettingsObject(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function getUtf8ByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
Reference in new issue
Block a user