mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 11:36:17 +00:00
* fix(frontend): gate tool-step links through the href scheme allowlist The chain-of-thought renderer turned web_fetch args and web_search / image_search result URLs straight into <a href>. Markdown links already pass isSafeHref, but these tool-step links bypassed it, so a prompt-injected tool call could put file:, ms-msdt:, vscode: or other OS protocol-handler links into the chat. React 19 only rewrites javascript: hrefs. All three sites now reuse the markdown allowlist and render an unsafe URL as plain text (the image thumbnail stays, unlinked). Tests render MessageGroup for each tool with unsafe schemes plus a web-URL control. * docs(changelog): note tool-step link scheme gating (#5526) * fix(frontend): mark omitted tool-step links and guard web_fetch url type Review follow-up. Tool steps dropped an unsafe URL to bare text, while markdown and artifact links show a dotted "Unsafe link omitted" span, so the two surfaces applying the same rule degraded differently. That span was already duplicated between markdown-link.tsx and artifact-link.tsx; it is now one UnsafeLink component used by all three renderers. It passes extra props through so the image tile still works as a Radix tooltip trigger. web_fetch also read args.url with a cast only. A non-string url (models occasionally emit one mid-stream) reached JSX as an object and threw, taking down the message list. It is now typeof-guarded. * fix(frontend): default missing tool-call args before rendering steps Review follow-up. The web_fetch typeof guard dropped the optional chaining of the cast it replaced, so a tool call without an args object threw again. Other branches were already exposed the same way: seven tool kinds (web_fetch, web_search, image_search, read_file, write_file, str_replace, browser_*) threw on a missing or null args while building their labels. convertToSteps now defaults args to {} once, so every ToolCall branch receives an object.
106 lines
3.6 KiB
TypeScript
106 lines
3.6 KiB
TypeScript
import { describe, expect, it } from "@rstest/core";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import {
|
|
createMarkdownLinkComponent,
|
|
isSafeHref,
|
|
} from "@/components/workspace/messages/markdown-link";
|
|
|
|
describe("isSafeHref", () => {
|
|
it("allows web URLs and same-origin paths", () => {
|
|
expect(isSafeHref("https://example.com/path")).toBe(true);
|
|
expect(isSafeHref("http://example.com/path")).toBe(true);
|
|
expect(isSafeHref("/workspace/chats/1")).toBe(true);
|
|
expect(isSafeHref("#section")).toBe(true);
|
|
});
|
|
|
|
it("allows scheme-less relative references", () => {
|
|
expect(isSafeHref("report.md")).toBe(true);
|
|
expect(isSafeHref("./report.md")).toBe(true);
|
|
expect(isSafeHref("../assets/chart.png")).toBe(true);
|
|
});
|
|
|
|
it("allows non-executing contact schemes", () => {
|
|
expect(isSafeHref("mailto:someone@example.com")).toBe(true);
|
|
expect(isSafeHref("tel:+15551234567")).toBe(true);
|
|
});
|
|
|
|
it("rejects executable, local, and protocol-relative URLs", () => {
|
|
expect(isSafeHref("javascript:alert(1)")).toBe(false);
|
|
expect(isSafeHref("data:text/html,<script>alert(1)</script>")).toBe(false);
|
|
expect(isSafeHref("file:///etc/passwd")).toBe(false);
|
|
expect(isSafeHref("//example.com/path")).toBe(false);
|
|
expect(isSafeHref("\\\\evil.com")).toBe(false);
|
|
expect(isSafeHref(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Render-level coverage: these tests exercise the component itself, so
|
|
// removing (or inverting) the isSafeHref guard inside MarkdownLink fails
|
|
// them even though isSafeHref stays untouched.
|
|
describe("MarkdownLink rendering", () => {
|
|
const MarkdownLink = createMarkdownLinkComponent();
|
|
|
|
it("renders an unsafe href as a disabled span, never an anchor", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(MarkdownLink, { href: "javascript:alert(1)" }, "click me"),
|
|
);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).toContain("<span");
|
|
expect(html).toContain("click me");
|
|
expect(html).not.toContain("href=");
|
|
expect(html).toContain('aria-label="Unsafe link omitted"');
|
|
});
|
|
|
|
it("blocks unsafe hrefs before the citation branch", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "javascript:alert(1)" },
|
|
"citation:evil",
|
|
),
|
|
);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).not.toContain("href=");
|
|
});
|
|
|
|
it("renders a safe https href as a hardened anchor", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "https://example.com/report" },
|
|
"report",
|
|
),
|
|
);
|
|
expect(html).toContain("<a");
|
|
expect(html).toContain('href="https://example.com/report"');
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
|
|
it("renders citation labels when children are React element arrays", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "https://example.com/report" },
|
|
createElement("span", { key: "prefix" }, "citation:"),
|
|
createElement("span", { key: "title" }, "Test Source"),
|
|
),
|
|
);
|
|
expect(html).not.toContain("citation:");
|
|
expect(html).toContain("Test Source");
|
|
});
|
|
|
|
it("renders a scheme-less relative href as a navigable anchor", () => {
|
|
const tests = ["report.md", "./report.md", "../assets/chart.png"];
|
|
for (const href of tests) {
|
|
const html = renderToStaticMarkup(
|
|
createElement(MarkdownLink, { href }, "doc"),
|
|
);
|
|
expect(html).toContain("<a");
|
|
expect(html).toContain(`href="${href}"`);
|
|
}
|
|
});
|
|
});
|