149 lines
9.5 KiB
TypeScript
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}` };
|
|
}
|