157 lines
6.3 KiB
TypeScript
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;
|
|
}
|