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 } | { 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 { 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 }; } catch { return { ok: false, error: "invalid_json" }; } } export async function readPluginConsentUrlEncodedFormData(request: Request): Promise { 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(); 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(); 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 { 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 { 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; }