mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 03:30:34 +00:00
* fix(gateway): serve XML artifacts as attachments to block same-origin script
GET /api/threads/{id}/artifacts/{path} forced only text/html,
application/xhtml+xml and image/svg+xml to download. Every other XML
document was served inline from the application origin: `.xml` guesses
to text/xml or application/xml depending on the host's mime.types, and
both fell through to the inline text branches. Browsers render any XML
MIME type as a document and run an XHTML-namespaced <script> inside it,
so a report.xml written by a prompt-injected agent and opened from a
chat link executed with the viewer's session: the HttpOnly access_token
rides same-origin fetches, and the double-submit csrf_token cookie is
JS-readable, so state-changing calls are reachable as well.
Treat HTML plus every WHATWG XML MIME type (text/xml, application/xml,
any +xml subtype) and text/xsl, which Blink also renders as XML, as
active content. A single helper owns the rule for both the regular-file
and the .skill-archive-member branches. The artifacts panel already
previews .xml as code through a ranged fetch, so preview and editing
keep working against the attachment response.
* docs(frontend): name XML among the artifacts the Gateway downloads
Review follow-up on #5353: resolveArtifactOpenURL's comment still named
only HTML/SVG as the active content the Gateway serves as a download.
XML documents now join that bucket, so the frontend note matches the
Gateway rule. Comment-only; no behavior change.
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
import { resolveStaticDemoArtifact } from "@/core/threads/static-demo";
|
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
|
|
|
import { getTabularDelimiter } from "./preview";
|
|
import { urlOfArtifact } from "./utils";
|
|
|
|
/** Standalone route that renders a stored artifact with the app's own renderer. */
|
|
export const ARTIFACT_VIEWER_ROUTE = "/artifacts/view";
|
|
|
|
export type ArtifactViewerTarget = {
|
|
filepath: string;
|
|
threadId: string;
|
|
isMock: boolean;
|
|
};
|
|
|
|
/**
|
|
* Language the artifacts panel renders a *stored* artifact with.
|
|
*
|
|
* `.skill` archives are ZIPs whose `SKILL.md` member is what the panel loads
|
|
* (see `loadArtifactContent`), so they render as markdown like the panel does.
|
|
* Kept here rather than inlined in the panel so the standalone viewer and the
|
|
* panel cannot drift on what "this is markdown" means.
|
|
*/
|
|
export function resolveStoredArtifactLanguage(filepath: string) {
|
|
if (filepath.endsWith(".skill")) {
|
|
return "markdown";
|
|
}
|
|
return checkCodeFile(filepath).language;
|
|
}
|
|
|
|
/**
|
|
* Target for the artifacts panel's "open in new window" action.
|
|
*
|
|
* Markdown and tabular files go to the in-app viewer route, which renders it with the same
|
|
* components as the panel instead of handing the browser a `text/markdown`
|
|
* response it can only show as raw source. Everything else keeps the raw
|
|
* Gateway URL — notably HTML, SVG, and other XML documents, which the Gateway
|
|
* deliberately serves as a download so active content never executes in the
|
|
* application origin.
|
|
*/
|
|
export function resolveArtifactOpenURL({
|
|
filepath,
|
|
threadId,
|
|
isMock = false,
|
|
}: {
|
|
filepath: string;
|
|
threadId: string;
|
|
isMock?: boolean;
|
|
}) {
|
|
const language = resolveStoredArtifactLanguage(filepath);
|
|
if (language !== "markdown" && getTabularDelimiter(language) === null) {
|
|
return urlOfArtifact({ filepath, threadId, isMock });
|
|
}
|
|
return buildArtifactViewerURL({ filepath, threadId, isMock });
|
|
}
|
|
|
|
/**
|
|
* Address of the viewer window for *target*.
|
|
*
|
|
* Unlike `resolveArtifactOpenURL` this never falls back to the Gateway URL:
|
|
* callers that already are the viewer window — the auth guard rebuilding its
|
|
* own address for a post-login return — need the route itself.
|
|
*/
|
|
export function buildArtifactViewerURL({
|
|
filepath,
|
|
threadId,
|
|
isMock,
|
|
}: ArtifactViewerTarget) {
|
|
const params = new URLSearchParams({ path: filepath, thread_id: threadId });
|
|
if (isMock) {
|
|
params.set("mock", "true");
|
|
}
|
|
return `${ARTIFACT_VIEWER_ROUTE}?${params.toString()}`;
|
|
}
|
|
|
|
/** Read a viewer target back out of the route's query string. */
|
|
export function parseArtifactViewerParams(
|
|
params: URLSearchParams,
|
|
): ArtifactViewerTarget | null {
|
|
const filepath = params.get("path")?.trim();
|
|
const threadId = params.get("thread_id")?.trim();
|
|
if (!filepath || !threadId) {
|
|
return null;
|
|
}
|
|
return { filepath, threadId, isMock: params.get("mock") === "true" };
|
|
}
|
|
|
|
/**
|
|
* Parse a Next.js `searchParams` record into a viewer target.
|
|
*
|
|
* A repeated query parameter arrives as an array; take the first value rather
|
|
* than letting a second `?path=` appended to a shared link decide what the
|
|
* window loads.
|
|
*/
|
|
export function parseArtifactViewerQuery(
|
|
query: Record<string, string | string[] | undefined> | undefined,
|
|
): ArtifactViewerTarget | null {
|
|
if (!query) {
|
|
return null;
|
|
}
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(query)) {
|
|
const first = Array.isArray(value) ? value[0] : value;
|
|
if (first !== undefined) {
|
|
params.set(key, first);
|
|
}
|
|
}
|
|
return parseArtifactViewerParams(params);
|
|
}
|
|
|
|
/**
|
|
* Browser-tab title for the viewer window.
|
|
*
|
|
* Applied through the route's `generateMetadata`, not `document.title`: the
|
|
* App Router owns the title element and re-applies the layout's metadata over
|
|
* anything an effect writes.
|
|
*/
|
|
export function artifactViewerTitle(filepath: string | undefined) {
|
|
return filepath ? `${getFileName(filepath)} - DeerFlow` : "DeerFlow";
|
|
}
|
|
|
|
/**
|
|
* Whether the viewer window has to sit behind the user-auth gate.
|
|
*
|
|
* Public `/showcase` threads render with `isMock`, and their artifacts are
|
|
* served by the unauthenticated demo route, which answers only for an
|
|
* allowlisted set of files. Gating those would bounce every logged-out
|
|
* showcase visitor to /login for a document that is already public — and the
|
|
* raw artifact URL this window replaced stayed reachable.
|
|
*
|
|
* The allowlist is the authority, not the flag: `mock=true` is caller-supplied
|
|
* and on its own grants nothing, because a target the demo route would answer
|
|
* with 404 still needs a session.
|
|
*/
|
|
export function requiresAuthenticatedViewer(target: ArtifactViewerTarget) {
|
|
if (!target.isMock) {
|
|
return true;
|
|
}
|
|
const segments = target.filepath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment));
|
|
return resolveStaticDemoArtifact(target.threadId, segments) === null;
|
|
}
|