feat: integrate XSLT Tools with toolbox portal
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
export async function createSha256Hash(text: string): Promise<string> {
|
||||
if (!window.crypto?.subtle) {
|
||||
return createFallbackHash(text);
|
||||
throw new Error(
|
||||
'SHA-256 is unavailable. Open this app in a secure browser context before trusting or running a stylesheet.'
|
||||
);
|
||||
}
|
||||
|
||||
const data = new TextEncoder().encode(text);
|
||||
@@ -8,13 +10,3 @@ export async function createSha256Hash(text: string): Promise<string> {
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function createFallbackHash(text: string): string {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash = (hash << 5) - hash + text.charCodeAt(index);
|
||||
hash |= 0;
|
||||
}
|
||||
|
||||
return `fallback-${Math.abs(hash).toString(16)}`;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ type SaxonXdmMap = {
|
||||
inSituPut: (key: unknown, value: unknown[]) => void;
|
||||
};
|
||||
|
||||
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
|
||||
|
||||
let saxonLoadPromise: Promise<void> | null = null;
|
||||
|
||||
export function resolveSaxonScriptUrl(
|
||||
baseUrl = import.meta.env.BASE_URL,
|
||||
pageUrl = window.location.href
|
||||
): string {
|
||||
const appBase = new URL(baseUrl, pageUrl);
|
||||
return new URL('vendor/saxon/SaxonJS2.js', appBase).href;
|
||||
}
|
||||
|
||||
type SaxonPrivateRuntime = {
|
||||
getPlatform?: () => {
|
||||
resource?: (name: string) => unknown;
|
||||
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
|
||||
if (window.SaxonJS) return;
|
||||
|
||||
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
|
||||
const saxonScriptUrl = resolveSaxonScriptUrl();
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${SAXON_SCRIPT_URL}"]`
|
||||
`script[src="${saxonScriptUrl}"]`
|
||||
);
|
||||
|
||||
if (existingScript) {
|
||||
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = SAXON_SCRIPT_URL;
|
||||
script.src = saxonScriptUrl;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));
|
||||
|
||||
16
src/transform/stylesheetTrust.ts
Normal file
16
src/transform/stylesheetTrust.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface TrustedStylesheet {
|
||||
hash: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export function isStylesheetExecutionTrusted(
|
||||
trust: TrustedStylesheet | null,
|
||||
currentHash: string,
|
||||
currentRevision: number
|
||||
): boolean {
|
||||
return (
|
||||
trust !== null &&
|
||||
trust.hash === currentHash &&
|
||||
trust.revision === currentRevision
|
||||
);
|
||||
}
|
||||
65
src/transform/transformLimits.ts
Normal file
65
src/transform/transformLimits.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { TransformRequest, TransformResult } from './transformTypes';
|
||||
|
||||
export const TRANSFORM_LIMITS = {
|
||||
maxXmlBytes: 5 * 1024 * 1024,
|
||||
maxXsltBytes: 2 * 1024 * 1024,
|
||||
maxOutputBytes: 10 * 1024 * 1024,
|
||||
timeoutMs: 20_000,
|
||||
} as const;
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
export function assertTransformInputWithinLimits(
|
||||
request: TransformRequest
|
||||
): void {
|
||||
const xmlBytes = utf8Bytes(request.xmlText);
|
||||
if (xmlBytes > TRANSFORM_LIMITS.maxXmlBytes) {
|
||||
throw new Error(
|
||||
`XML input exceeds the ${TRANSFORM_LIMITS.maxXmlBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
|
||||
const xsltBytes = utf8Bytes(request.xsltText);
|
||||
if (xsltBytes > TRANSFORM_LIMITS.maxXsltBytes) {
|
||||
throw new Error(
|
||||
`XSLT stylesheet exceeds the ${TRANSFORM_LIMITS.maxXsltBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTransformOutputWithinLimits(
|
||||
result: TransformResult
|
||||
): void {
|
||||
if (utf8Bytes(result.output) > TRANSFORM_LIMITS.maxOutputBytes) {
|
||||
throw new Error(
|
||||
`Transformation output exceeds the ${TRANSFORM_LIMITS.maxOutputBytes}-byte execution limit.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTransformationTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number = TRANSFORM_LIMITS.timeoutMs
|
||||
): Promise<T> {
|
||||
let timer: number | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(operation),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = window.setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Transformation exceeded the ${timeoutMs}-millisecond execution limit.`
|
||||
)
|
||||
),
|
||||
timeoutMs
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,21 @@ import type {
|
||||
TransformRequest,
|
||||
TransformResult,
|
||||
} from './transformTypes';
|
||||
import {
|
||||
assertTransformInputWithinLimits,
|
||||
assertTransformOutputWithinLimits,
|
||||
withTransformationTimeout,
|
||||
} from './transformLimits';
|
||||
import { createSha256Hash } from './hash';
|
||||
import {
|
||||
isStylesheetExecutionTrusted,
|
||||
type TrustedStylesheet,
|
||||
} from './stylesheetTrust';
|
||||
|
||||
export interface TransformationAuthorization {
|
||||
trustedStylesheet: TrustedStylesheet;
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
const engines: Record<TransformEngineId, TransformEngine> = {
|
||||
'saxon-js-dynamic': saxonJsDynamicEngine,
|
||||
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
|
||||
}
|
||||
|
||||
export async function runTransformation(
|
||||
request: TransformRequest
|
||||
request: TransformRequest,
|
||||
authorization: TransformationAuthorization
|
||||
): Promise<TransformResult> {
|
||||
const stylesheetHash = await createSha256Hash(request.xsltText);
|
||||
if (
|
||||
!authorization ||
|
||||
!isStylesheetExecutionTrusted(
|
||||
authorization.trustedStylesheet,
|
||||
stylesheetHash,
|
||||
authorization.currentRevision
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Transformation refused: review and trust this exact stylesheet first.'
|
||||
);
|
||||
}
|
||||
|
||||
assertTransformInputWithinLimits(request);
|
||||
const engine = getTransformEngine(request.engine);
|
||||
return engine.transform(request);
|
||||
const result = await withTransformationTimeout(() =>
|
||||
engine.transform(request)
|
||||
);
|
||||
assertTransformOutputWithinLimits(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const availableTransformEngines = Object.values(engines);
|
||||
|
||||
Reference in New Issue
Block a user