Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/utils/capabilityConsentPayload.server.ts
T

157 lines
6.3 KiB
TypeScript

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;
}