import { expect, test } from '@playwright/test';
test('sanitized documents mediate allowed images and prevent cross-origin requests before DOM insertion', async ({ page }) => {
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.route('http://127.0.0.1:4173/course-image.png', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from([137, 80, 78, 71]) }));
await page.route('https://outside.test/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from([137, 80, 78, 71]) }));
await page.goto('/test/browser/fixtures/lifecycle.html');
const result = await page.evaluate(async () => {
const moduleUrl = new URL('/dist/markdown-document/markdown-document.js', location.href).href;
const { MarkdownEngine } = await import(/* webpackIgnore: true */ moduleUrl);
const engine = new MarkdownEngine({ profile: 'core', securityMode: 'sanitized' });
const container = document.createElement('div');
document.body.append(container);
const diagnostics: string[] = [];
const session = await engine.renderDocument(
'  \n\n
',
container,
{
sourceUrl: new URL(location.href),
resourcePolicy: { allowedOrigins: [location.origin] },
onDiagnostic: (diagnostic: { code?: string }) => diagnostics.push(diagnostic.code ?? ''),
},
);
const result = {
imageSources: Array.from(container.querySelectorAll('img'), (image) => image.getAttribute('src')),
svgImageSources: Array.from(container.querySelectorAll('svg image'), (image) => image.getAttribute('href')),
mediaElements: container.querySelectorAll('audio, video').length,
eventHandlers: container.querySelectorAll('[onerror]').length,
diagnostics,
};
await session.dispose();
await engine.dispose();
return result;
});
expect(result.imageSources).toHaveLength(4);
expect(result.imageSources[0]).toMatch(/^blob:/);
expect(result.imageSources.slice(1)).toEqual([null, null, null]);
expect(result.svgImageSources).toHaveLength(2);
expect(result.svgImageSources[0]).toMatch(/^blob:/);
expect(result.svgImageSources[1]).toBeNull();
expect(result.mediaElements).toBe(0);
expect(result.eventHandlers).toBe(0);
expect(result.diagnostics.filter((code) => code === 'resource-url-blocked')).toHaveLength(4);
expect(requests.filter((url) => url.includes('course-image.png'))).toHaveLength(2);
expect(requests.filter((url) => url.includes('redirect.png'))).toHaveLength(1);
expect(requests.some((url) => url.includes('outside.test'))).toBe(false);
});
test('sanitized documents display policy-approved cross-origin images without CORS fetches', async ({ page, browserName }) => {
const requests: { url: string; type: string }[] = [];
const failures: { url: string; type: string; error: string }[] = [];
const loadedResponses = new Set();
page.on('request', (request) => requests.push({ url: request.url(), type: request.resourceType() }));
page.on('requestfailed', (request) => failures.push({ url: request.url(), type: request.resourceType(), error: request.failure()?.errorText ?? '' }));
page.on('response', (response) => {
if (response.request().resourceType() === 'image' && response.ok()) loadedResponses.add(response.url());
});
const svg = '';
await page.route('https://mathjslab.com/**', (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg }));
await page.route('https://dl.circleci.com/**', (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg }));
await page.route('https://badges.gitter.im/Join%20Chat.svg', (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg }));
await page.goto('/test/browser/fixtures/lifecycle.html');
const result = await page.evaluate(async () => {
const moduleUrl = new URL('/dist/markdown-document/markdown-document.js', location.href).href;
const { MarkdownEngine } = await import(/* webpackIgnore: true */ moduleUrl);
const engine = new MarkdownEngine({ profile: 'core', securityMode: 'sanitized' });
const container = document.createElement('div');
document.body.append(container);
const session = await engine.renderDocument(
'  ',
container,
{
sourceUrl: new URL(location.href),
resourcePolicy: { allowedOrigins: [location.origin, 'https://mathjslab.com', 'https://dl.circleci.com', 'https://badges.gitter.im'] },
},
);
const sources = Array.from(container.querySelectorAll('img'), (image) => image.getAttribute('src'));
await Promise.all(Array.from(container.querySelectorAll('img'), (image) => image.decode()));
const loaded = Array.from(container.querySelectorAll('img'), (image) => image.complete && image.naturalWidth > 0);
await session.dispose();
await engine.dispose();
return { sources, loaded };
});
expect(result.sources).toEqual([
'https://mathjslab.com/images/mathjslab-logo.svg',
'https://dl.circleci.com/status-badge/img/gh/MathJSLab/mathjslab-app/tree/main.svg?style=svg',
'https://badges.gitter.im/Join%20Chat.svg',
]);
expect(result.loaded).toEqual([true, true, true]);
const imageRequests = requests.filter(({ url }) => result.sources.includes(url));
expect(imageRequests.length).toBeGreaterThanOrEqual(3);
expect(imageRequests.every(({ type }) => type === 'image')).toBe(true);
for (const url of result.sources) expect(loadedResponses.has(url!)).toBe(true);
// Firefox may cancel intermediate sanitizer image requests. The final images
// must still decode and receive successful responses; all other failures fail.
expect(
failures.filter(
({ url, type, error }) => !(browserName === 'firefox' && type === 'image' && error === 'NS_BINDING_ABORTED' && result.sources.includes(url) && loadedResponses.has(url)),
),
).toEqual([]);
});