Files
workinf_Blender_Wasm/web/protocol/library-source-origin.ts
mes123456 380cbed4ff
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Checkpoint web parity through Chromium input tasks
2026-08-19 10:39:03 -04:00

149 lines
9.5 KiB
TypeScript

import { normalizeProjectAssetPath } from "./asset-path";
import type { ErrorCode } from "./error";
export const LIBRARY_SOURCE_ORIGIN_SCHEMA = 1 as const;
export type LibrarySourceKind = "HTTPS_ORIGIN" | "PROJECT_ASSET" | "USER_SELECTED_FILE";
export interface LibrarySourcePolicyIR {
schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA;
declaredHttpsOrigins: string[];
}
export type LibrarySourceRequestIR =
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "HTTPS_ORIGIN"; url: string }
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "PROJECT_ASSET"; path: string }
| { schemaVersion: typeof LIBRARY_SOURCE_ORIGIN_SCHEMA; kind: "USER_SELECTED_FILE"; selectionId: string; fileName: string; byteLength: number; sourceSha256: string };
export interface AcceptedLibrarySourceIR {
status: "READY";
kind: LibrarySourceKind;
canonicalLocator: string;
}
export class LibrarySourceOriginValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(`${code}: ${message}`);
this.name = "LibrarySourceOriginValidationError";
this.code = code;
this.path = path;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const SELECTION_ID = /^file-selection:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const FILE_NAME = /^[^\\/\u0000-\u001f\u007f]{1,255}$/;
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
const MAX_FILE_BYTES = 4 * 1024 * 1024 * 1024;
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be an object`, path);
return value as Record<string, unknown>;
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], path: string): void {
const actual = Object.keys(value).sort();
const allowed = [...expected].sort();
if (actual.length !== allowed.length || actual.some((key, index) => key !== allowed[index])) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} contains undeclared fields`, path);
}
function text(value: unknown, path: string, maximum: number): string {
if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} is invalid`, path);
return value;
}
function digest(value: unknown, path: string): string {
if (typeof value !== "string" || !SHA256.test(value)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", `${path} must be a lowercase SHA-256 digest`, path);
return value;
}
function validateUriText(value: string, path: string): void {
if (value.includes("\\") || CONTROL_CHARACTER.test(value) || /%(?![0-9a-fA-F]{2})/.test(value)) {
throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains unsafe URI characters`, path);
}
}
function validateUriPath(pathname: string, path: string): void {
let decoded: string;
try { decoded = decodeURIComponent(pathname); }
catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains malformed percent encoding`, path); }
if (decoded.includes("%") || decoded.includes("\\") || CONTROL_CHARACTER.test(decoded)) {
throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} contains an unsafe path`, path);
}
}
function canonicalOrigin(value: unknown, path: string): string {
const textValue = text(value, path, 2_048);
validateUriText(textValue, path);
let parsed: URL;
try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); }
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS origin`, path);
// A policy entry is an origin, not a URL whose path/query/fragment is discarded by URL.origin.
// Check the raw suffix too: URL parsing normalizes encoded and literal dot segments before exposing
// pathname, which must not turn an origin-smuggling declaration into an apparently trusted origin.
const schemeSeparator = textValue.indexOf("://");
const authorityAndSuffix = schemeSeparator < 0 ? textValue : textValue.slice(schemeSeparator + 3);
const suffixStart = authorityAndSuffix.search(/[/?#]/);
const rawSuffix = suffixStart < 0 ? "" : authorityAndSuffix.slice(suffixStart);
validateUriPath(parsed.pathname, path);
if (schemeSeparator < 0 || parsed.pathname !== "/" || parsed.search || parsed.hash || rawSuffix !== "" && rawSuffix !== "/") throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must not include a path, query, or fragment`, path);
return parsed.origin;
}
function httpsUrl(value: unknown, path: string): string {
const textValue = text(value, path, 8_192);
validateUriText(textValue, path);
let parsed: URL;
try { parsed = new URL(textValue); } catch { throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} is not an absolute URL`, path); }
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", `${path} must be a credential-free HTTPS URL`, path);
validateUriPath(parsed.pathname, path);
return parsed.href;
}
export function parseLibrarySourcePolicy(value: unknown): LibrarySourcePolicyIR {
const policy = record(value, "policy");
exactKeys(policy, ["schemaVersion", "declaredHttpsOrigins"], "policy");
if (policy.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA || !Array.isArray(policy.declaredHttpsOrigins) || policy.declaredHttpsOrigins.length === 0 || policy.declaredHttpsOrigins.length > 1_024) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported or empty library source policy", "policy");
const declaredHttpsOrigins = policy.declaredHttpsOrigins.map((origin, index) => canonicalOrigin(origin, `declaredHttpsOrigins[${index}]`));
if (new Set(declaredHttpsOrigins).size !== declaredHttpsOrigins.length) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "declared HTTPS origins must be unique", "declaredHttpsOrigins");
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, declaredHttpsOrigins };
}
export function parseLibrarySourceRequest(value: unknown): LibrarySourceRequestIR {
const request = record(value, "request");
if (request.schemaVersion !== LIBRARY_SOURCE_ORIGIN_SCHEMA) throw new LibrarySourceOriginValidationError("PROTOCOL_MISMATCH", "Unsupported library source request schema", "schemaVersion");
if (request.kind === "HTTPS_ORIGIN") {
exactKeys(request, ["schemaVersion", "kind", "url"], "request");
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "HTTPS_ORIGIN", url: httpsUrl(request.url, "url") };
}
if (request.kind === "PROJECT_ASSET") {
exactKeys(request, ["schemaVersion", "kind", "path"], "request");
let path: string;
try { path = normalizeProjectAssetPath(text(request.path, "path", 2_048)); }
catch (error) { throw new LibrarySourceOriginValidationError(error instanceof Error && error.message === "ASSET_PATH_INVALID" ? "ASSET_MANIFEST_INVALID" : "IO_EXTERNAL_URI_BLOCKED", "project asset path is outside the project", "path"); }
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "PROJECT_ASSET", path };
}
if (request.kind === "USER_SELECTED_FILE") {
exactKeys(request, ["schemaVersion", "kind", "selectionId", "fileName", "byteLength", "sourceSha256"], "request");
if (typeof request.selectionId !== "string" || !SELECTION_ID.test(request.selectionId)) throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "selectionId is invalid", "selectionId");
if (typeof request.fileName !== "string" || !FILE_NAME.test(request.fileName) || request.fileName === "." || request.fileName === "..") throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "fileName is invalid", "fileName");
if (typeof request.byteLength !== "number" || !Number.isSafeInteger(request.byteLength) || request.byteLength <= 0 || request.byteLength > MAX_FILE_BYTES) throw new LibrarySourceOriginValidationError("ASSET_BUDGET_EXCEEDED", "selected file length is outside the budget", "byteLength");
return { schemaVersion: LIBRARY_SOURCE_ORIGIN_SCHEMA, kind: "USER_SELECTED_FILE", selectionId: request.selectionId, fileName: request.fileName, byteLength: request.byteLength, sourceSha256: digest(request.sourceSha256, "sourceSha256") };
}
throw new LibrarySourceOriginValidationError("ASSET_MANIFEST_INVALID", "source kind is unsupported", "kind");
}
export function acceptLibrarySource(policyValue: unknown, requestValue: unknown): AcceptedLibrarySourceIR {
const policy = parseLibrarySourcePolicy(policyValue);
const request = parseLibrarySourceRequest(requestValue);
if (request.kind === "HTTPS_ORIGIN") {
const origin = new URL(request.url).origin;
if (!policy.declaredHttpsOrigins.includes(origin)) throw new LibrarySourceOriginValidationError("IO_EXTERNAL_URI_BLOCKED", "HTTPS origin is not declared by the policy", "url");
return { status: "READY", kind: request.kind, canonicalLocator: request.url };
}
if (request.kind === "PROJECT_ASSET") return { status: "READY", kind: request.kind, canonicalLocator: `project-assets/${request.path}` };
return { status: "READY", kind: request.kind, canonicalLocator: `user-file/${request.selectionId}/${request.fileName}` };
}