fix(artifacts): authenticate isolated HTML preview resources (#6701)

* fix(artifacts): authenticate isolated HTML preview resources

* docs(artifacts): retain precise path guidance within size budget

* fix: preserve HTML preview auth and rendering semantics

* fix(artifacts): quote rewritten CSS URLs

* docs: keep merged artifact guidance within size limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Lucien 2026-10-15 10:15:44 +08:00 committed by GitHub
parent a6b3552362
commit 8008a6195f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1250 additions and 38 deletions

View File

@ -2467,6 +2467,18 @@ CSV and TSV artifacts open as tables in the artifact panel and in a separate win
HTML artifact previews resolve relative assets from the artifact directory unless the document contains a real `<base>` element. Base-tag examples in comments or script text do not change that resolution.
With authentication enabled, static resources in the same thread's artifact route
(images, responsive images, classic scripts and CSS, including nested imports)
are fetched by the parent and inlined without weakening the opaque iframe sandbox.
Preparation has resource, byte, CSS-depth and time limits and reports partial
previews. Dynamic JavaScript requests, ES module imports, nested iframes and
navigation remain subject to sandbox restrictions; see the
[frontend preview notes](frontend/README.md#development-notes) for the supported
scope and limits.
Optional resource failures, including 401 responses, keep the conversation open
with a partial-preview notice. HTML doctypes and doctype-less rendering modes
are preserved, and CSS references inside inline SVG styles are prepared too.
Markdown report previews in the artifact panel and separate window resolve local
images from the report's own directory and conversation. Both relative paths
such as `./charts/summary.png` and absolute `/mnt/...` paths are supported;

View File

@ -143,6 +143,25 @@ src/
## Development Notes
HTML artifact previews keep an opaque iframe sandbox. Static sibling images,
responsive image sources, classic scripts, stylesheets, CSS imports and CSS
`url()` resources in the same thread's artifact route are fetched by the
authenticated parent and inlined as data URLs. Existing external
resources and external `<base>` URLs retain their browser behavior. Resource
preparation is capped at 32 resources, 4 MiB per resource, 16 MiB total, eight
nested CSS imports, 24 MiB of expanded references and ten seconds; unavailable or oversized resources show a
partial-preview notice. Switching artifacts cancels preparation and releases
the generated document Blob URL.
Resource 401 responses remain local to the partial preview; only the document
load uses the application's login-redirect policy. Preparation preserves the
original doctype (including its absence) and handles inline SVG styles.
This supports static generated reports rather than a complete website runtime.
ES module imports, resources created dynamically by JavaScript, `fetch()`/XHR,
iframes and navigation still follow opaque-origin browser restrictions and do
not receive the parent session. View source or download the files when a report
requires those features.
- Uses pnpm workspaces (see `packageManager` in package.json)
- Webpack is the default development bundler until the upstream Turbopack PostCSS worker leak is fixed in a stable Next.js release (#5132). Set `DEER_FLOW_DEV_BUNDLER=turbo` to opt in to Turbopack for local diagnosis, or `DEER_FLOW_DEV_BUNDLER=webpack` to select Webpack explicitly. Reconsider the default after the stable fix is verified on macOS arm64 and Linux.
- Environment validation can be skipped with `SKIP_ENV_VALIDATION=1` (useful for Docker)

View File

@ -10,18 +10,20 @@ failure/replay-gap paths. Draft and confirmation regressions live in
Answer details use `workspace/message-details` descriptors; skill run scoping
is documented in `docs/skill-usage-ui.md`.
Encode raw paths preserving literal percent escapes. Decode Markdown destinations
once; match image names to raw paths. Preserve sanitization; key Streamdown by
file/thread/mock context plus scroll key.
File-type detection uses the basename so extensionless `Dockerfile` and
`Makefile` artifacts remain recognizable under nested or dotted directories.
HTML preview base detection skips DOM construction without case-insensitive `base`
text; otherwise an inert template distinguishes real tags from comments, scripts
and nested templates without rewriting source. Tests: `preview-base.dom.test.ts`
(happy-dom); textarea/title RCDATA needs real-browser checks due to parser gaps.
Other preview helpers retain the node test environment.
Language-map membership checks only own properties; inherited names such as
`constructor` and `__proto__` must keep the unknown-file download fallback.
Encode raw paths preserving literal percent escapes. Decode Markdown once;
match image names to raw paths.
Sanitize URLs; key Streamdown by file/thread/mock context and scroll key.
Detect file types by basename, including Dockerfile/Makefile.
Language maps use own properties; `constructor`/`__proto__` keep fallback.
HTML bases skip DOM without base text; inert templates ignore comments/scripts
and templates. `preview-base.dom.test.ts` (happy-dom); RCDATA needs
browser checks; other helpers use node.
HTML assets: credential GET, no 401 navigation; current-thread only, no redirects,
bound bytes/count/CSS depth/time/expansion; keep doctype, rewrite SVG style.
Keep opaque sandbox/data URLs; external refs stay browser-loaded.
Abort stale work; revoke URLs; gate content/URL/scroll.
Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`,
`tests/e2e/artifact-preview.spec.ts`.
1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming. Voice transcript joining separates every adjacent pair of trimmed word segments, preserves CJK joins and provider whitespace, and attaches leading punctuation/percent signs while retaining spaces after sentence punctuation. Regression cases live in `tests/unit/core/voice-input/speech-recognition.test.ts`.
2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream.

View File

@ -6,9 +6,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defaultUrlTransform, type UrlTransform } from "streamdown";
import { Button } from "@/components/ui/button";
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
import {
getTabularDelimiter,
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
createHtmlPreviewScrollKey,
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
@ -19,6 +19,7 @@ import {
urlOfArtifact,
} from "@/core/artifacts/utils";
import { extractCitationSources } from "@/core/citations/sources";
import { useI18n } from "@/core/i18n/hooks";
import {
SafeStreamdown,
toStreamdownComponents,
@ -146,6 +147,7 @@ export function ArtifactFilePreview({
truncated?: boolean;
active?: boolean;
}) {
const { t } = useI18n();
const { filepath, threadId, isMock } = artifact ?? {};
const transformUrl = useCallback<UrlTransform>(
(src, key, node) =>
@ -164,7 +166,18 @@ export function ArtifactFilePreview({
() => createHtmlPreviewScrollKey(scrollKey),
[scrollKey],
);
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const previewIdentity = useMemo(
() => ({ content, language, scrollKey, url }),
[content, language, scrollKey, url],
);
const [htmlPreview, setHtmlPreview] = useState<{
identity: typeof previewIdentity;
url?: string;
failedResources?: number;
error?: boolean;
}>();
const currentPreview =
htmlPreview?.identity === previewIdentity ? htmlPreview : undefined;
const citationSources = useMemo(
() =>
language === "markdown" ? extractCitationSources(content ?? "") : [],
@ -216,24 +229,39 @@ export function ArtifactFilePreview({
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
}
const previewContent = appendHtmlPreviewScrollRestoration(
appendHtmlPreviewBaseHref(content ?? "", url),
scrollKey,
const controller = new AbortController();
let objectUrl: string | undefined;
void prepareHtmlPreview(content ?? "", url, controller.signal).then(
(prepared) => {
if (controller.signal.aborted) {
return;
}
const previewContent = appendHtmlPreviewScrollRestoration(
prepared.content,
scrollKey,
);
objectUrl = URL.createObjectURL(
new Blob([previewContent], { type: "text/html;charset=utf-8" }),
);
setHtmlPreview({
identity: previewIdentity,
url: objectUrl,
failedResources: prepared.failedResources,
});
},
() => {
if (!controller.signal.aborted)
setHtmlPreview({ identity: previewIdentity, error: true });
},
);
const blob = new Blob([previewContent], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setHtmlPreviewUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [content, language, scrollKey, url]);
}, [content, language, scrollKey, url, previewIdentity]);
const delimiter = getTabularDelimiter(language);
if (delimiter !== null) {
@ -266,18 +294,29 @@ export function ArtifactFilePreview({
}
if (language === "html") {
return (
<iframe
ref={iframeRef}
className="size-full"
title="Artifact preview"
// allow-scripts is needed for the scroll-restoration injected
// script (appendHtmlPreviewScrollRestoration) which communicates
// via postMessage. allow-same-origin is deliberately omitted: the
// opaque origin prevents access to parent.document and cookies,
// and postMessage(..., "*") works fine from it.
sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl}
/>
<div className="flex size-full flex-col">
{(!currentPreview?.url || !!currentPreview.failedResources) && (
<p className="text-muted-foreground px-4 py-2 text-sm" role="status">
{currentPreview?.error
? t.artifactPreview.previewFailed
: currentPreview?.failedResources
? t.artifactPreview.resourcesUnavailable
: t.artifactPreview.loadingPreview}
</p>
)}
<iframe
ref={iframeRef}
className="min-h-0 w-full flex-1"
title="Artifact preview"
// allow-scripts is needed for the scroll-restoration injected
// script (appendHtmlPreviewScrollRestoration) which communicates
// via postMessage. allow-same-origin is deliberately omitted: the
// opaque origin prevents access to parent.document and cookies,
// and postMessage(..., "*") works fine from it.
sandbox="allow-scripts allow-forms"
src={currentPreview?.url}
/>
</div>
);
}
return null;

View File

@ -0,0 +1,366 @@
import { appendHtmlPreviewBaseHref } from "./preview";
const MAX_RESOURCES = 32;
const MAX_RESOURCE_BYTES = 4 * 1024 * 1024;
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
const MAX_CSS_DEPTH = 8;
const MAX_INLINE_BYTES = 24 * 1024 * 1024;
const EMPTY_RESOURCE = "data:,";
/** Locate CSS URL tokens without treating comments or ordinary strings as URLs. */
async function rewriteCss(
css: string,
replace: (value: string, stylesheet: boolean) => Promise<string>,
) {
const quoteUrl = (value: string) =>
`"${value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/[\r\n\f]/g, "\\A ")}"`;
const tokens = /\/\*|["']|\burl\s*\(|@import\s+/gi;
let result = "";
let copied = 0;
let match: RegExpExecArray | null;
const stringEnd = (start: number, quote: string) => {
let end = start;
for (; end < css.length; end++) {
if (css[end] === "\\") end++;
else if (css[end] === quote) break;
}
return end;
};
while ((match = tokens.exec(css))) {
const token = match[0];
if (token === "/*") {
const end = css.indexOf("*/", tokens.lastIndex);
tokens.lastIndex = end < 0 ? css.length : end + 2;
continue;
}
if (token === '"' || token === "'") {
tokens.lastIndex = stringEnd(tokens.lastIndex, token) + 1;
continue;
}
const imported = token.toLowerCase().startsWith("@import");
let start = tokens.lastIndex;
while (/\s/.test(css[start] ?? "")) start++;
if (imported && css.slice(start, start + 3).toLowerCase() === "url") {
const opening = /^url\s*\(/i.exec(css.slice(start));
if (!opening) continue;
start += opening[0].length;
while (/\s/.test(css[start] ?? "")) start++;
} else if (imported && css[start] !== '"' && css[start] !== "'") {
continue;
}
const quote = css[start] === '"' || css[start] === "'" ? css[start]! : null;
const valueStart = quote ? start + 1 : start;
let end = quote ? stringEnd(valueStart, quote) : valueStart;
if (!quote) {
for (; end < css.length; end++) {
if (css[end] === "\\") end++;
else if (css[end] === ")") break;
}
}
if (end >= css.length) break;
const raw = css.slice(valueStart, end);
const encoded = quote ? raw : raw.trim();
const value = encoded.replace(
/\\([0-9a-f]{1,6}\s?|\r\n|[\n\r\f]|.)/gi,
(_escape, escaped: string) => {
if (/^[0-9a-f]/i.test(escaped)) {
const code = Number.parseInt(escaped.trim(), 16);
return String.fromCodePoint(
code === 0 || code > 0x10ffff ? 0xfffd : code,
);
}
return /^[\n\r\f]/.test(escaped) ? "" : escaped;
},
);
const replacement = await replace(value, imported);
const formattedReplacement =
replacement === value || quote
? replacement === value
? raw
: replacement
: quoteUrl(replacement);
result += css.slice(copied, valueStart) + formattedReplacement;
copied = end;
tokens.lastIndex = end + (quote ? 1 : 0);
}
return result + css.slice(copied);
}
/** Follow srcset URL boundaries, including commas within data URLs. */
async function rewriteSrcset(
value: string,
replace: (url: string) => Promise<string>,
) {
let result = "";
let copied = 0;
let position = 0;
while (position < value.length) {
while (/[\s,]/.test(value[position] ?? "")) position++;
const start = position;
while (position < value.length && !/\s/.test(value[position]!)) position++;
let end = position;
while (value[end - 1] === ",") end--;
if (end > start) {
result +=
value.slice(copied, start) + (await replace(value.slice(start, end)));
copied = end;
}
if (end < position) continue;
let parentheses = 0;
while (position < value.length) {
const character = value[position++];
if (character === "(") parentheses++;
if (character === ")") parentheses--;
if (character === "," && parentheses === 0) break;
}
}
return result + value.slice(copied);
}
/**
* Load only this thread's artifact resources with the parent's session. The
* sandbox remains opaque; data URLs work across that origin boundary whereas
* parent-owned Blob URLs are blocked by Chromium's storage partition checks.
*/
export async function prepareHtmlPreview(
content: string,
artifactUrl?: string,
signal?: AbortSignal,
): Promise<{ content: string; failedResources: number }> {
const unchanged = {
content: appendHtmlPreviewBaseHref(content, artifactUrl),
failedResources: 0,
};
if (!artifactUrl) return unchanged;
const source = new URL(artifactUrl, window.location.href);
const route = /^(.*\/api\/threads\/[^/]+\/artifacts\/)/.exec(
source.pathname,
)?.[1];
// Public demos and non-thread document previews retain their existing path.
if (!route || !["http:", "https:"].includes(source.protocol))
return unchanged;
const controller = new AbortController();
const abort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
const timeout = setTimeout(() => controller.abort(), 10_000);
const resources = new Map<string, string>();
const loading = new Set<string>();
let totalBytes = 0;
let failedResources = 0;
let inlineBytes = 0;
function inline(value: string) {
inlineBytes += value.length;
if (inlineBytes > MAX_INLINE_BYTES) {
failedResources++;
return EMPTY_RESOURCE;
}
return value;
}
function localUrl(value: string, base: string) {
if (!value || value.startsWith("#")) return null;
const target = new URL(value, base);
if (target.origin !== source.origin || !target.pathname.startsWith(route!))
return null;
if (target.username || target.password) return null;
try {
if (
target.pathname
.slice(route!.length)
.split("/")
.some((segment) => /[/\\\u0000]/.test(decodeURIComponent(segment)))
)
return null;
} catch {
return null;
}
return target;
}
async function resource(
value: string,
base: string,
stylesheet = false,
depth = 0,
resolveRelative = false,
integrity?: string,
): Promise<string> {
if (!value || value.startsWith("#")) return value;
let target: URL | null;
try {
target = localUrl(value, base);
} catch {
return value;
}
if (!target) {
// An inlined stylesheet has no original URL of its own. Keep resources
// outside the allowed route browser-loaded at their original location.
return resolveRelative ? new URL(value, base).href : value;
}
const fragment = target.hash;
target.hash = "";
const key = `${stylesheet ? "css:" : "asset:"}${target.href}:${integrity ?? ""}`;
const cached = resources.get(key);
if (cached !== undefined)
return inline(cached + (cached === EMPTY_RESOURCE ? "" : fragment));
if (
loading.has(key) ||
resources.size + loading.size >= MAX_RESOURCES ||
totalBytes >= MAX_TOTAL_BYTES ||
inlineBytes >= MAX_INLINE_BYTES ||
controller.signal.aborted ||
depth > MAX_CSS_DEPTH
) {
failedResources++;
return EMPTY_RESOURCE;
}
loading.add(key);
try {
controller.signal.throwIfAborted();
// A failed optional asset must not trigger the API client's page-wide
// login redirect. The top-level document load keeps that auth policy.
const response = await globalThis.fetch(target.href, {
credentials: "include",
signal: controller.signal,
redirect: "error",
...(integrity ? { integrity } : {}),
});
if (!response.ok || !response.body)
throw new Error("Artifact resource unavailable");
const reader = response.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let bytes = 0;
try {
while (true) {
const { done, value: chunk } = await reader.read();
if (done) break;
bytes += chunk.byteLength;
totalBytes += chunk.byteLength;
if (bytes > MAX_RESOURCE_BYTES || totalBytes > MAX_TOTAL_BYTES)
throw new Error("Artifact preview resource limit exceeded");
chunks.push(new Uint8Array(chunk));
}
} finally {
await reader.cancel().catch(() => undefined);
}
let blob = new Blob(chunks, {
type:
response.headers.get("Content-Type") ?? "application/octet-stream",
});
if (stylesheet) {
const css = await rewriteCss(await blob.text(), (reference, imported) =>
resource(reference, target.href, imported, depth + 1, true),
);
blob = new Blob([css], { type: "text/css;charset=utf-8" });
}
controller.signal.throwIfAborted();
const encodedBytes = new Uint8Array(await blob.arrayBuffer());
let binary = "";
for (let start = 0; start < encodedBytes.length; start += 0x8000) {
binary += String.fromCharCode(
...encodedBytes.subarray(start, start + 0x8000),
);
}
const url = `data:${blob.type};base64,${btoa(binary)}`;
resources.set(key, url);
return inline(url + fragment);
} catch (error) {
if (signal?.aborted) throw error;
failedResources++;
resources.set(key, EMPTY_RESOURCE);
return EMPTY_RESOURCE;
} finally {
loading.delete(key);
}
}
try {
const document = new DOMParser().parseFromString(content, "text/html");
const base = new URL(
document.querySelector("base[href]")?.getAttribute("href") ?? source.href,
source.href,
).href;
for (const element of document.querySelectorAll(
"[src], [srcset], [poster], link[href], style, [style]",
)) {
for (const attribute of ["src", "poster"]) {
const value = element.getAttribute(attribute);
if (value && element.tagName !== "IFRAME") {
const rewritten = await resource(
value,
base,
false,
0,
false,
element.getAttribute("integrity") ?? undefined,
);
element.setAttribute(attribute, rewritten);
if (rewritten !== value) element.removeAttribute("integrity");
}
}
const srcset = element.getAttribute("srcset");
if (srcset)
element.setAttribute(
"srcset",
await rewriteSrcset(srcset, (reference) => resource(reference, base)),
);
if (
element.tagName === "LINK" &&
element
.getAttribute("rel")
?.toLowerCase()
.split(/\s+/)
.includes("stylesheet")
) {
const value = element.getAttribute("href");
if (value) {
const rewritten = await resource(
value,
base,
true,
0,
false,
element.getAttribute("integrity") ?? undefined,
);
element.setAttribute("href", rewritten);
// Fetch checked the original bytes. Rewritten CSS has a new hash.
if (rewritten !== value) element.removeAttribute("integrity");
}
}
if (element.localName === "style")
element.textContent = await rewriteCss(
element.textContent ?? "",
(reference, imported) => resource(reference, base, imported),
);
const style = element.getAttribute("style");
if (style)
element.setAttribute(
"style",
await rewriteCss(style, (reference, imported) =>
resource(reference, base, imported),
),
);
}
signal?.throwIfAborted();
return {
content: appendHtmlPreviewBaseHref(
Array.from(document.childNodes, (node) =>
node === document.documentElement
? document.documentElement.outerHTML
: new XMLSerializer().serializeToString(node),
).join(""),
artifactUrl,
),
failedResources,
};
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
}
}

View File

@ -297,6 +297,9 @@ export const enUS: Translations = {
},
artifactPreview: {
loadingPreview: "Preparing preview...",
resourcesUnavailable:
"Some preview resources could not be loaded. View the source or download the files for the complete content.",
limited: (previewSize, totalSize) =>
totalSize
? `Showing the first ${previewSize} of ${totalSize}.`

View File

@ -234,6 +234,8 @@ export interface Translations {
};
artifactPreview: {
loadingPreview: string;
resourcesUnavailable: string;
limited: (previewSize: string, totalSize?: string) => string;
loadFullFile: string;
loadingFullFile: string;

View File

@ -284,6 +284,9 @@ export const zhCN: Translations = {
},
artifactPreview: {
loadingPreview: "正在准备预览...",
resourcesUnavailable:
"部分预览资源无法加载。可查看源代码或下载文件以获取完整内容。",
limited: (previewSize, totalSize) =>
totalSize
? `当前显示 ${totalSize} 中的前 ${previewSize}。`

View File

@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
@ -89,6 +91,259 @@ function presentFilesMessages(path = PRESENTED_ARTIFACT_PATH) {
}
test.describe("Artifact preview stability", () => {
for (const [index, fixture] of [
{
name: "fragment",
declaration: "",
mode: "BackCompat",
publicId: "",
systemId: "",
},
{
name: "HTML5",
declaration: "<!doctype html>",
mode: "CSS1Compat",
publicId: "",
systemId: "",
},
{
name: "legacy quirks",
declaration:
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">',
mode: "BackCompat",
publicId: "-//W3C//DTD HTML 4.01 Transitional//EN",
systemId: "",
},
{
name: "XHTML transitional",
declaration:
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
mode: "CSS1Compat",
publicId: "-//W3C//DTD XHTML 1.0 Transitional//EN",
systemId: "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd",
},
].entries()) {
test(`keeps a ${fixture.name} report open after resource 401 and rewrites SVG styles`, async ({
page,
context,
baseURL,
}) => {
const threadId = `00000000-0000-0000-0000-00000000315${index}`;
const filepath = "/mnt/user-data/outputs/partial-report.html";
const content = `<!--before-->${fixture.declaration}<html><body><h1>Partial report</h1><img src="expired.png"><svg xmlns="http://www.w3.org/2000/svg"><style>.chart{filter:url(filters.svg#blur)}</style><rect class="chart" style="fill:url(paint.svg#paint)" width="40" height="40"/></svg></body></html><!--after-->`;
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
messages: presentFilesMessages(filepath),
artifacts: [filepath],
},
],
});
await context.addCookies([
{
name: "access_token",
value: "preview-session",
url: baseURL!,
httpOnly: true,
sameSite: "Lax",
},
]);
const resources: Array<{ filename: string; authenticated: boolean }> = [];
await page.route(
`**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
async (route) => {
const filename = new URL(route.request().url()).pathname
.split("/")
.pop()!;
const headers = await route.request().allHeaders();
const authenticated =
headers.cookie?.includes("access_token=preview-session") ?? false;
if (filename !== "partial-report.html")
resources.push({ filename, authenticated });
if (!authenticated || filename === "expired.png")
return route.fulfill({
status: 401,
contentType: "application/json",
body: '{"detail":"Unauthorized"}',
});
if (filename === "partial-report.html")
return route.fulfill({ contentType: "text/html", body: content });
await route.fulfill({
contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="blur"><feGaussianBlur stdDeviation="1"/></filter><linearGradient id="paint"><stop stop-color="blue"/></linearGradient></defs></svg>',
});
},
);
await page.goto(`/workspace/chats/${threadId}`);
await page
.getByText("partial-report.html", { exact: true })
.first()
.click();
const panel = page.locator("#artifacts");
await expect(panel.getByRole("status")).toContainText(
"Some preview resources could not be loaded",
);
const iframe = panel.locator('iframe[title="Artifact preview"]');
await expect(iframe).toHaveAttribute(
"sandbox",
"allow-scripts allow-forms",
);
const frame = iframe.contentFrame();
await expect(frame.locator("h1")).toHaveText("Partial report");
const result = await frame.locator("body").evaluate(() => ({
mode: document.compatMode,
doctype: document.doctype
? {
publicId: document.doctype.publicId,
systemId: document.doctype.systemId,
}
: null,
stylesheet: document.querySelector("svg style")?.textContent,
inline: document.querySelector("svg rect")?.getAttribute("style"),
comments: Array.from(document.childNodes)
.filter((node) => node.nodeType === Node.COMMENT_NODE)
.map((node) => node.textContent),
}));
expect(result.mode).toBe(fixture.mode);
expect(result.doctype).toEqual(
fixture.declaration
? { publicId: fixture.publicId, systemId: fixture.systemId }
: null,
);
expect(result.stylesheet).toContain("data:image/svg+xml;base64,");
expect(result.inline).toContain("data:image/svg+xml;base64,");
expect(result.stylesheet).toContain("#blur");
expect(result.inline).toContain("#paint");
expect(result.comments).toEqual(["before", "after"]);
expect(resources).toHaveLength(3);
expect(resources.every((resource) => resource.authenticated)).toBe(true);
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${threadId}$`));
});
}
test("loads protected HTML sibling resources while retaining an opaque sandbox", async ({
page,
context,
baseURL,
}, testInfo) => {
const threadId = "00000000-0000-0000-0000-000000003140";
const filepath = "/mnt/user-data/outputs/authenticated-report.html";
const stylesheet =
'@import "nested.css"; h1{background-image:url(chart.png)}';
const integrity = `sha256-${createHash("sha256").update(stylesheet).digest("base64")}`;
const content = `<link rel="stylesheet" href="style.css" integrity="${integrity}"><script src="app.js"></script><h1>Authenticated report</h1><img id="chart" src="chart.png">`;
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
messages: presentFilesMessages(filepath),
artifacts: [filepath],
},
],
});
await context.addCookies([
{
name: "access_token",
value: "preview-session",
url: baseURL!,
httpOnly: true,
sameSite: "Lax",
},
]);
const resources: Array<{ filename: string; authenticated: boolean }> = [];
await page.route(
`**/api/threads/${threadId}/artifacts/mnt/user-data/outputs/*`,
async (route) => {
const filename = new URL(route.request().url()).pathname
.split("/")
.pop()!;
const headers = await route.request().allHeaders();
const authenticated =
headers.cookie?.includes("access_token=preview-session") ?? false;
if (filename !== "authenticated-report.html")
resources.push({ filename, authenticated });
if (!authenticated)
return route.fulfill({ status: 401, body: "unauthorized" });
const bodies: Record<
string,
{ contentType: string; body: string | Buffer }
> = {
"authenticated-report.html": {
contentType: "text/html",
body: content,
},
"style.css": {
contentType: "text/css",
body: stylesheet,
},
"nested.css": {
contentType: "text/css",
body: "h1{color:rgb(1,2,3)}",
},
"app.js": {
contentType: "application/javascript",
body: "window.addEventListener('DOMContentLoaded',()=>{document.body.dataset.scriptLoaded='true'})",
},
"chart.png": {
contentType: "image/png",
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yA1kAAAAASUVORK5CYII=",
"base64",
),
},
};
const response = bodies[filename];
if (!response) return route.fulfill({ status: 404 });
await route.fulfill(response);
},
);
await page.goto(`/workspace/chats/${threadId}`);
await page
.getByText("authenticated-report.html", { exact: true })
.first()
.click();
const iframe = page.locator('#artifacts iframe[title="Artifact preview"]');
await expect(iframe).toHaveAttribute(
"sandbox",
"allow-scripts allow-forms",
);
const frame = iframe.contentFrame();
await expect(frame.locator("body")).toHaveAttribute(
"data-script-loaded",
"true",
);
await expect(frame.locator("h1")).toHaveCSS("color", "rgb(1, 2, 3)");
await expect
.poll(() =>
frame
.locator("#chart")
.evaluate((image) => (image as HTMLImageElement).naturalWidth),
)
.toBe(1);
expect(resources).toHaveLength(4);
expect(resources.every((resource) => resource.authenticated)).toBe(true);
expect(
await frame.locator("body").evaluate(() => {
try {
return !!window.parent.document;
} catch {
return false;
}
}),
).toBe(false);
expect(
await frame.locator("body").evaluate(() => {
try {
return document.cookie;
} catch {
return "blocked";
}
}),
).toBe("blocked");
await iframe.screenshot({ path: testInfo.outputPath("html-preview.png") });
});
for (const [index, filename] of ["constructor", "data.__proto__"].entries()) {
test(`keeps ${filename} artifacts on the download fallback`, async ({
page,

View File

@ -0,0 +1,81 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts/artifact-file-preview";
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
rs.mock("@/core/artifacts/html-assets", () => ({
prepareHtmlPreview: rs.fn(),
}));
rs.mock("@/core/streamdown/components", () => ({
SafeStreamdown: () => null,
toStreamdownComponents: () => ({}),
}));
afterEach(() => {
cleanup();
rs.restoreAllMocks();
rs.mocked(prepareHtmlPreview).mockReset();
});
test("cancels obsolete preparation, hides previous content and releases document URLs", async () => {
let resolveOld!: (
result: Awaited<ReturnType<typeof prepareHtmlPreview>>,
) => void;
const prepare = rs.mocked(prepareHtmlPreview);
prepare.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOld = resolve;
}),
);
prepare.mockResolvedValue({
content: "<h1>Current</h1>",
failedResources: 0,
});
const create = rs
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:current");
const revoke = rs
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => undefined);
const component = (content: string) => (
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<ArtifactFilePreview
content={content}
language="html"
scrollKey={content}
url={`/api/threads/${content}/artifacts/report.html`}
/>
</I18nContext.Provider>
);
const result = render(component("old"));
const oldSignal = prepare.mock.calls[0]![2]!;
result.rerender(component("current"));
expect(oldSignal.aborted).toBe(true);
await waitFor(() =>
expect(result.container.querySelector("iframe")?.getAttribute("src")).toBe(
"blob:current",
),
);
await act(async () => {
resolveOld({ content: "<h1>Old</h1>", failedResources: 0 });
});
expect(create).toHaveBeenCalledTimes(1);
expect(result.container.querySelector("iframe")?.getAttribute("src")).toBe(
"blob:current",
);
prepare.mockImplementationOnce(() => new Promise(() => undefined));
result.rerender(component("pending"));
expect(result.container.querySelector("iframe")?.hasAttribute("src")).toBe(
false,
);
expect(revoke).toHaveBeenCalledWith("blob:current");
const pendingSignal = prepare.mock.calls[2]![2]!;
result.unmount();
expect(pendingSignal.aborted).toBe(true);
});

View File

@ -0,0 +1,35 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
afterEach(() => {
rs.restoreAllMocks();
});
test("keeps resource 401 responses local to the preview without navigating to login", async () => {
const navigate = rs
.spyOn(window.location, "href", "set")
.mockImplementation(() => undefined);
const request = rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response('{"detail":"Unauthorized"}', {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
const url =
"https://app.example/gateway/api/threads/chat/artifacts/mnt/user-data/outputs/report.html";
const result = await prepareHtmlPreview(
'<h1>Report</h1><img src="chart.png">',
url,
);
expect(result.failedResources).toBe(1);
expect(result.content).toContain("Report");
expect(result.content).toContain('src="data:,"');
expect(navigate).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
url.replace("report.html", "chart.png"),
expect.objectContaining({ credentials: "include", redirect: "error" }),
);
});

View File

@ -0,0 +1,395 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { prepareHtmlPreview } from "@/core/artifacts/html-assets";
const url =
"https://app.example/api/threads/chat/artifacts/mnt/user-data/outputs/report.html";
const request = rs.spyOn(globalThis, "fetch");
afterEach(() => {
request.mockReset();
});
test("preserves missing and legacy doctypes and comments outside the HTML element", async () => {
for (const declaration of [
"",
"<!doctype html>",
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">',
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
]) {
const content = `<!--before-->${declaration}<html><body><h1>Report</h1></body></html><!--after-->`;
const result = await prepareHtmlPreview(content, url);
const expected = new DOMParser().parseFromString(content, "text/html");
const actual = new DOMParser().parseFromString(result.content, "text/html");
expect(actual.doctype?.name).toBe(expected.doctype?.name);
expect(actual.doctype?.publicId).toBe(expected.doctype?.publicId);
expect(actual.doctype?.systemId).toBe(expected.doctype?.systemId);
expect(result.content).toContain("<!--before-->");
expect(result.content).toContain("<!--after-->");
expect(actual.querySelector("base")?.getAttribute("href")).toBe(
url.replace("report.html", ""),
);
}
});
test("rewrites URLs inside SVG style elements and inline style attributes", async () => {
request.mockImplementation(async () => new Response("image"));
// happy-dom's HTML parser drops SVG children; create the namespace nodes
// directly. The real parser is also exercised by the browser regression.
const fixture = document.implementation.createHTMLDocument("");
const svg = fixture.createElementNS("http://www.w3.org/2000/svg", "svg");
const style = fixture.createElementNS("http://www.w3.org/2000/svg", "style");
style.textContent = ".chart{fill:url(chart.svg#paint)}";
const rect = fixture.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("style", "filter:url(filters.svg#blur)");
svg.append(style, rect);
fixture.body.append(svg);
const parse = rs
.spyOn(DOMParser.prototype, "parseFromString")
.mockReturnValueOnce(fixture);
const result = await prepareHtmlPreview(
'<svg xmlns="http://www.w3.org/2000/svg"><style>.chart{fill:url(chart.svg#paint)}</style><rect class="chart" style="filter:url(filters.svg#blur)"/></svg>',
url,
);
parse.mockRestore();
expect(
request.mock.calls.map(([input]) =>
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url,
),
).toEqual([
url.replace("report.html", "chart.svg"),
url.replace("report.html", "filters.svg"),
]);
expect(style.textContent).toContain("data:");
expect(rect.getAttribute("style")).toContain("data:");
expect(result.content).toContain("#paint");
expect(result.content).toContain("#blur");
});
test("authenticates images, scripts and nested CSS assets in the parent", async () => {
request.mockImplementation(async (input) => {
const pathname = new URL(
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url,
).pathname;
if (pathname.endsWith("main.css"))
return new Response(
'@import "nested.css" screen; .hero{background:url("chart%2520.png")}',
);
if (pathname.endsWith("nested.css"))
return new Response(".icon{background:url(../uploads/icon.png)}");
return new Response(
pathname.endsWith("app.js") ? "window.loaded=true" : "image",
);
});
const result = await prepareHtmlPreview(
'<link rel="stylesheet" href="main.css"><script src="app.js"></script><img src="chart%2520.png">',
url,
);
expect(result.failedResources).toBe(0);
expect(
request.mock.calls.map(([input]) =>
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url,
),
).toEqual([
url.replace("report.html", "main.css"),
url.replace("report.html", "nested.css"),
url.replace("outputs/report.html", "uploads/icon.png"),
url.replace("report.html", "chart%2520.png"),
url.replace("report.html", "app.js"),
]);
expect(result.content).not.toContain('src="chart%2520.png"');
const document = new DOMParser().parseFromString(result.content, "text/html");
const css = atob(
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
);
expect(css).toContain("data:text/css");
expect(
request.mock.calls.every(([, init]) => init?.redirect === "error"),
).toBe(true);
});
test("never fetches another thread, external origin, non-artifact route or encoded separators", async () => {
const result = await prepareHtmlPreview(
`<img src="https://evil.example/image"><img src="/api/threads/other/artifacts/mnt/a.png"><img src="/api/memory"><img src="..%2fsecret.png"><img src="%5csecret.png"><img src="data:image/png;base64,AA">`,
url,
);
expect(request).not.toHaveBeenCalled();
expect(result.content).toContain("https://evil.example/image");
});
test("bounds cycles and failed resources without losing the HTML", async () => {
request.mockImplementation(async (input) => {
if (
(typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url
).endsWith("a.css")
)
return new Response('@import "b.css";');
if (
(typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url
).endsWith("b.css")
)
return new Response('@import "a.css";');
return new Response("missing", { status: 404 });
});
const result = await prepareHtmlPreview(
'<h1>Report</h1><link rel="stylesheet" href="a.css"><img src="missing.png">',
url,
);
expect(request).toHaveBeenCalledTimes(3);
expect(result.failedResources).toBe(2);
expect(result.content).toContain("Report");
expect(result.content).not.toContain('src="missing.png"');
});
test("rewrites inline CSS and ignores commented URL text", async () => {
request.mockResolvedValue(new Response("image"));
const result = await prepareHtmlPreview(
'<style>/*url(no.png)*/ .x{background:url("chart.png")}</style><div style="background:url(chart.png)"></div>',
url,
);
expect(request).toHaveBeenCalledTimes(1);
expect(result.content).toContain("/*url(no.png)*/");
expect(result.content).toContain("data:");
});
test("leaves public demos and previews without a resource URL on their existing path", async () => {
const content = '<img src="chart.png">';
for (const value of [
undefined,
"https://app.example/demo/threads/chat/report.html",
]) {
const result = await prepareHtmlPreview(content, value);
expect(result.content).toContain(content);
expect(request).not.toHaveBeenCalled();
}
});
test("preserves external bases and escaped external CSS references", async () => {
const result = await prepareHtmlPreview(
'<base href="https://cdn.example/"><img src="image.png"><style>.x{background:url(https://cdn.example/a\\)b.png)}</style>',
url,
);
expect(request).not.toHaveBeenCalled();
expect(result.content).toContain('src="image.png"');
expect(result.content).toContain("a\\)b.png");
});
test("supports responsive pictures, SVG fragments and CSS escaped filenames", async () => {
request.mockImplementation(async () => new Response("image"));
const result = await prepareHtmlPreview(
'<picture><source srcset="one.png 1x, two.png 2x"><img srcset="data:image/png;base64,AA 1x, one.png 2x" src="icons.svg#chart"></picture><style>.x{background:url("chart\\20 image.png")}</style>',
url,
);
expect(
request.mock.calls.map(([input]) =>
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url,
),
).toEqual([
url.replace("report.html", "one.png"),
url.replace("report.html", "two.png"),
url.replace("report.html", "icons.svg"),
url.replace("report.html", "chart%20image.png"),
]);
expect(result.content).toContain("data:image/png;base64,AA 1x");
expect(result.content).toContain("#chart");
expect(result.failedResources).toBe(0);
});
test("caps distinct resources and CSS import depth", async () => {
request.mockImplementation(async () => new Response("image"));
const result = await prepareHtmlPreview(
Array.from({ length: 40 }, (_, i) => `<img src="${i}.png">`).join(""),
url,
);
expect(request).toHaveBeenCalledTimes(32);
expect(result.failedResources).toBe(8);
request.mockClear();
request.mockImplementation(async (input) => {
const name = new URL(
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url,
).pathname
.split("/")
.pop()!;
return new Response(`@import "${Number.parseInt(name) + 1}.css";`);
});
const nested = await prepareHtmlPreview(
'<link rel="stylesheet" href="0.css">',
url,
);
expect(request).toHaveBeenCalledTimes(9);
expect(nested.failedResources).toBe(1);
});
test("caps streamed resource bytes and the aggregate budget", async () => {
request.mockImplementation(
async () => new Response(new Uint8Array(4 * 1024 * 1024 + 1)),
);
const oversized = await prepareHtmlPreview('<img src="huge.png">', url);
expect(oversized.failedResources).toBe(1);
expect(oversized.content).toContain('src="data:,"');
request.mockClear();
request.mockImplementation(
async () => new Response(new Uint8Array(4 * 1024 * 1024)),
);
const aggregate = await prepareHtmlPreview(
Array.from({ length: 5 }, (_, i) => `<img src="${i}.png">`).join(""),
url,
);
expect(aggregate.failedResources).toBe(1);
expect(request).toHaveBeenCalledTimes(4);
});
test("bounds repeated data URLs and preserves out-of-route CSS resolution", async () => {
request.mockImplementation(
async () => new Response(new Uint8Array(4 * 1024 * 1024)),
);
const repeated = await prepareHtmlPreview(
Array.from({ length: 8 }, () => '<img src="chart.png">').join(""),
url,
);
expect(request).toHaveBeenCalledTimes(1);
expect(repeated.failedResources).toBeGreaterThan(0);
expect(repeated.content.length).toBeLessThan(24 * 1024 * 1024);
request.mockClear();
request.mockResolvedValue(
new Response(".x{background:url(/public/chart.png)}"),
);
const css = await prepareHtmlPreview(
'<link rel="stylesheet" href="style.css">',
url,
);
const document = new DOMParser().parseFromString(css.content, "text/html");
const stylesheet = atob(
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
);
expect(stylesheet).toContain("https://app.example/public/chart.png");
expect(request).toHaveBeenCalledTimes(1);
});
test("quotes absolute CSS URLs that replace escaped unquoted references", async () => {
request.mockResolvedValue(
new Response(".chart{background:url(//cdn.example/chart\\(1\\).png)}"),
);
const result = await prepareHtmlPreview(
'<link rel="stylesheet" href="style.css">',
url,
);
const document = new DOMParser().parseFromString(result.content, "text/html");
const stylesheet = atob(
document.querySelector("link")!.getAttribute("href")!.split(",")[1]!,
);
expect(stylesheet).toContain('url("https://cdn.example/chart(1).png")');
expect(result.failedResources).toBe(0);
});
test("aborts late responses before exposing prepared HTML", async () => {
const controller = new AbortController();
request.mockImplementation(async (input) => {
if (
(typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url
).endsWith("late.png")
)
controller.abort();
return new Response("image");
});
await expect(
prepareHtmlPreview(
'<img src="first.png"><img src="late.png">',
url,
controller.signal,
),
).rejects.toThrow();
});
test("validates original integrity before rewriting resources", async () => {
request.mockImplementation(async () => new Response("body"));
const result = await prepareHtmlPreview(
'<link rel="stylesheet" href="style.css" integrity="sha256-original"><script src="app.js" integrity="sha256-script"></script><link rel="stylesheet" href="https://cdn.example/style.css" integrity="sha256-external">',
url,
);
expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
"sha256-original",
"sha256-script",
]);
expect(result.content).not.toContain("sha256-original");
expect(result.content).not.toContain("sha256-script");
expect(result.content).toContain("sha256-external");
});
test("never reuses an unverified resource for a distinct integrity constraint", async () => {
request.mockImplementation(async (_input, init) => {
if (init?.integrity === "sha256-wrong")
throw new TypeError("Integrity mismatch");
return new Response("image");
});
const result = await prepareHtmlPreview(
'<img src="chart.png"><img src="chart.png" integrity="sha256-valid"><img src="chart.png" integrity="sha256-wrong">',
url,
);
expect(request.mock.calls.map(([, init]) => init?.integrity)).toEqual([
undefined,
"sha256-valid",
"sha256-wrong",
]);
expect(result.failedResources).toBe(1);
expect(result.content).toContain('src="data:,"');
});
test("returns a partial preview when the preparation deadline expires", async () => {
rs.useFakeTimers();
try {
request.mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new DOMException("Aborted", "AbortError")),
{ once: true },
);
}),
);
const pending = prepareHtmlPreview(
'<h1>Report</h1><img src="slow.png"><img src="next.png">',
url,
);
await rs.advanceTimersByTimeAsync(10_000);
const result = await pending;
expect(result.failedResources).toBe(2);
expect(request).toHaveBeenCalledTimes(1);
expect(result.content).toContain("Report");
} finally {
rs.useRealTimers();
}
});