authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:37:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:37:25-07:00
log741377ce5fb1578916659cbb93fd403b26dbeb62
treebfe586581bd1245d4c859540b15f6f8bf47a1a7c
parente38b6a188fafd499df347331f8c8279ff320c207
signature Signed by PGP key B5690EEEBB952194

fix(web): file previews handle downloads, in-page links, and repo paths, and favicons stop leaking internal hosts (#16950)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 245 insertions(+), 25 deletions(-)

apps/server/src/http.test.ts+2-2
...@@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => {...@@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => {
674 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),674 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
675 ).toMatchObject({675 ).toMatchObject({
676 "Content-Type": "text/html; charset=utf-8",676 "Content-Type": "text/html; charset=utf-8",
677 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups",677 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads",
678 });678 });
679 });679 });
680 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {680 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
681 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {681 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
682 expect(assetResponseHeaders(path)).toMatchObject({682 expect(assetResponseHeaders(path)).toMatchObject({
683 "Content-Type": "text/html; charset=utf-8",683 "Content-Type": "text/html; charset=utf-8",
684 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups",684 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads",
685 });685 });
686 }686 }
687 });687 });
apps/server/src/http.ts+4-2
...@@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin...@@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin
57// opaque origin: scripts run, but same-origin cookies, storage, and API calls are57// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
58// out of reach. Relative sibling assets still load through their signed URLs.58// out of reach. Relative sibling assets still load through their signed URLs.
59// No modals: agent HTML can open without a click (inline renders, and mobile59// No modals: agent HTML can open without a click (inline renders, and mobile
60// loads it as the top document), and must not raise blocking dialogs.60// loads it as the top document), and must not raise blocking dialogs. Downloads
61const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups";61// stay allowed so download links and buttons in the page work.
62const HTML_CONTENT_SECURITY_POLICY =
63 "sandbox allow-scripts allow-forms allow-popups allow-downloads";
6264
63// Types a browser may render as a document if a proxy strips the disposition65// Types a browser may render as a document if a proxy strips the disposition
64// header. Downloads of these fall back to octet-stream.66// header. Downloads of these fall back to octet-stream.
apps/web/src/components/ChatMarkdown.test.tsx+78-4
...@@ -871,15 +871,15 @@ describe("ChatMarkdown heading levels", () => {...@@ -871,15 +871,15 @@ describe("ChatMarkdown heading levels", () => {
871 />,871 />,
872 );872 );
873873
874 expect(html).toContain('<h1 aria-level="4">Top</h1>');874 expect(html).toContain('<h1 id="user-content-top" aria-level="4">Top</h1>');
875 expect(html).toContain('<h2 aria-level="5">Section</h2>');875 expect(html).toContain('<h2 id="user-content-section" aria-level="5">Section</h2>');
876 expect(html).toContain('<h6 aria-level="6">Fine print</h6>');876 expect(html).toContain('<h6 id="user-content-fine-print" aria-level="6">Fine print</h6>');
877 });877 });
878878
879 it("leaves heading levels alone when the markdown is not nested", () => {879 it("leaves heading levels alone when the markdown is not nested", () => {
880 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text="# Top" />);880 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text="# Top" />);
881881
882 expect(html).toContain("<h1>Top</h1>");882 expect(html).toContain('<h1 id="user-content-top">Top</h1>');
883 });883 });
884});884});
885885
...@@ -1272,3 +1272,77 @@ it.each([...@@ -1272,3 +1272,77 @@ it.each([
1272 }1272 }
1273 },1273 },
1274);1274);
1275
1276describe("ChatMarkdown heading ids", () => {
1277 it("never gives two headings the same id, even when a suffix matches another heading", () => {
1278 const html = renderToStaticMarkup(
1279 <ChatMarkdown
1280 cwd="/tmp/project"
1281 parseRawHtml
1282 text={
1283 '## Setup\n\n## Setup\n\n## Setup-1\n\n<h2 id="install-1">Pinned</h2>\n\n## Install\n\n## Install'
1284 }
1285 />,
1286 );
1287 const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((match) => match[1]);
1288 expect(ids).toEqual([
1289 "user-content-setup",
1290 "user-content-setup-1",
1291 "user-content-setup-1-1",
1292 "user-content-install-1",
1293 "user-content-install",
1294 "user-content-install-2",
1295 ]);
1296 expect(new Set(ids).size).toBe(ids.length);
1297 });
1298});
1299
1300describe("ChatMarkdown in-page links", () => {
1301 it.each([true, false])(
1302 "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)",
1303 async (parseRawHtml) => {
1304 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1305 const { createRoot } = await import("react-dom/client");
1306 const container = document.createElement("div");
1307 document.body.append(container);
1308 const root = createRoot(container);
1309 window.history.replaceState(null, "", "/#/env/thread");
1310 const scrollIntoView = vi.fn();
1311 HTMLElement.prototype.scrollIntoView = scrollIntoView;
1312 try {
1313 await act(async () => {
1314 root.render(
1315 <ChatMarkdown
1316 cwd="/tmp/project"
1317 parseRawHtml={parseRawHtml}
1318 text={
1319 "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model"
1320 }
1321 />,
1322 );
1323 });
1324 const headings = [...container.querySelectorAll("h2")];
1325 expect(headings.map((heading) => heading.id)).toEqual([
1326 "user-content-1-operating-model",
1327 "user-content-1-operating-model-1",
1328 ]);
1329
1330 const [tocLink, missingLink] = [...container.querySelectorAll("a")];
1331 const click = () => new MouseEvent("click", { bubbles: true, cancelable: true });
1332 const tocClick = click();
1333 tocLink!.dispatchEvent(tocClick);
1334 expect(tocClick.defaultPrevented).toBe(true);
1335 expect(scrollIntoView.mock.contexts).toEqual([headings[0]]);
1336
1337 const missingClick = click();
1338 missingLink!.dispatchEvent(missingClick);
1339 expect(missingClick.defaultPrevented).toBe(true);
1340 expect(scrollIntoView).toHaveBeenCalledTimes(1);
1341 expect(window.location.hash).toBe("#/env/thread");
1342 } finally {
1343 await act(async () => root.unmount());
1344 container.remove();
1345 }
1346 },
1347 );
1348});
apps/web/src/components/ChatMarkdown.tsx+73-8
...@@ -1807,16 +1807,77 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>,...@@ -1807,16 +1807,77 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>,
1807 return;1807 return;
1808 }1808 }
18091809
1810 const target = findMarkdownFragmentTarget(event.currentTarget, href);1810 // Never let the browser follow the fragment or write it to the URL: desktop keeps
1811 if (!target) return;1811 // its route in the hash, so replacing the hash navigates away from the thread.
1812
1813 event.preventDefault();1812 event.preventDefault();
1814 const nextUrl = new URL(window.location.href);1813 findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" });
1815 nextUrl.hash = href.slice(1);1814}
1816 window.history.pushState(window.history.state, "", nextUrl);1815
1817 target.scrollIntoView({ block: "nearest" });1816type HeadingHastNode = {
1817 type?: string;
1818 tagName?: string;
1819 properties?: Record<string, unknown>;
1820 children?: HeadingHastNode[];
1821};
1822
1823/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */
1824function githubHeadingSlug(text: string): string {
1825 return text
1826 .trim()
1827 .toLowerCase()
1828 .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
1829 .replace(/ /g, "-");
1830}
1831
1832/**
1833 * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the
1834 * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids;
1835 * fragment lookup strips it.
1836 */
1837function rehypeHeadingIds() {
1838 return (tree: HeadingHastNode) => {
1839 // Every id already in the document, authored or assigned, so a suffix never
1840 // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids.
1841 const taken = new Set<string>();
1842 const collect = (node: HeadingHastNode) => {
1843 const id = node.properties?.id;
1844 if (typeof id === "string") taken.add(id);
1845 node.children?.forEach(collect);
1846 };
1847 collect(tree);
1848 const nextSuffix = new Map<string, number>();
1849 const visit = (node: HeadingHastNode) => {
1850 if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) {
1851 const slug = githubHeadingSlug(hastPlainTextDeep(node));
1852 if (node.properties?.id === undefined && slug) {
1853 let count = nextSuffix.get(slug) ?? 0;
1854 let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`;
1855 while (taken.has(id)) {
1856 count += 1;
1857 id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`;
1858 }
1859 nextSuffix.set(slug, count);
1860 taken.add(id);
1861 node.properties = { ...node.properties, id };
1862 }
1863 return;
1864 }
1865 node.children?.forEach(visit);
1866 };
1867 visit(tree);
1868 };
1818}1869}
18191870
1871// Heading ids are added after sanitizing, which would prefix them a second time.
1872const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [
1873 ...CHAT_MARKDOWN_REHYPE_PLUGINS,
1874 rehypeHeadingIds,
1875] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
1876
1877const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable<
1878 ReactMarkdownOptions["rehypePlugins"]
1879>;
1880
1820function MarkdownExternalLinkContent({1881function MarkdownExternalLinkContent({
1821 host,1882 host,
1822 plainText,1883 plainText,
...@@ -3363,7 +3424,11 @@ function ChatMarkdown({...@@ -3363,7 +3424,11 @@ function ChatMarkdown({
3363 <ChatMarkdownRendererContext value={componentState}>3424 <ChatMarkdownRendererContext value={componentState}>
3364 <ReactMarkdown3425 <ReactMarkdown
3365 remarkPlugins={remarkPlugins}3426 remarkPlugins={remarkPlugins}
3366 rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}3427 rehypePlugins={
3428 parseRawHtml
3429 ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS
3430 : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS
3431 }
3367 skipHtml={false}3432 skipHtml={false}
3368 components={CHAT_MARKDOWN_COMPONENTS}3433 components={CHAT_MARKDOWN_COMPONENTS}
3369 urlTransform={markdownUrlTransform}3434 urlTransform={markdownUrlTransform}
apps/web/src/components/files/BrowserDocumentFrame.tsx+1-1
...@@ -55,7 +55,7 @@ export function BrowserDocumentFrame(props: {...@@ -55,7 +55,7 @@ export function BrowserDocumentFrame(props: {
55 src={props.src}55 src={props.src}
56 title={props.title}56 title={props.title}
57 className={className}57 className={className}
58 sandbox="allow-scripts allow-forms allow-popups"58 sandbox="allow-scripts allow-forms allow-popups allow-downloads"
59 />59 />
60 );60 );
61}61}
apps/web/src/markdown-links.test.ts+29
...@@ -91,6 +91,35 @@ describe("relative links inside a rendered host file", () => {...@@ -91,6 +91,35 @@ describe("relative links inside a rendered host file", () => {
91 workspaceRelativePath: "docs/src/main.ts",91 workspaceRelativePath: "docs/src/main.ts",
92 });92 });
93 });93 });
94
95 it("resolve multi-segment inline code from the workspace root in a workspace file", () => {
96 expect(
97 resolveInlineCodeFileLinkMeta("docs/ai/design.md", "/repo", "/repo/docs/ai"),
98 ).toMatchObject({ filePath: "/repo/docs/ai/design.md" });
99 expect(
100 resolveInlineCodeFileLinkMeta("src/index.ts:4", "/repo", "/repo/packages/a"),
101 ).toMatchObject({ filePath: "/repo/src/index.ts", line: 4 });
102 });
103
104 it("keep sibling and explicitly relative inline code beside the file", () => {
105 expect(resolveInlineCodeFileLinkMeta("design.md:12", "/repo", "/repo/docs/ai")).toMatchObject({
106 filePath: "/repo/docs/ai/design.md",
107 line: 12,
108 });
109 expect(
110 resolveInlineCodeFileLinkMeta("./src/index.ts", "/repo", "/repo/packages/a"),
111 ).toMatchObject({ filePath: "/repo/packages/a/./src/index.ts" });
112 expect(resolveInlineCodeFileLinkMeta("../b/notes.md", "/repo", "/repo/docs/a")).toMatchObject({
113 filePath: "/repo/docs/a/../b/notes.md",
114 });
115 });
116
117 it("keep multi-segment inline code beside a file outside the workspace", () => {
118 expect(resolveInlineCodeFileLinkMeta("src/main.ts", "/repo", "/tmp/report")).toMatchObject({
119 filePath: "/tmp/report/src/main.ts",
120 workspaceRelativePath: null,
121 });
122 });
94});123});
95124
96describe("resolveInlineCodeFileLinkMeta", () => {125describe("resolveInlineCodeFileLinkMeta", () => {
apps/web/src/markdown-links.ts+22-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path";1import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path";
2import {2import {
3 inlineCodeFilePathCandidate,3 inlineCodeFilePathCandidate,
4 isRelativeFilePath,
4 normalizeMarkdownLinkDestination,5 normalizeMarkdownLinkDestination,
5 resolveMarkdownFileLinkTarget,6 resolveMarkdownFileLinkTarget,
6} from "@t3tools/shared/markdownLinks";7} from "@t3tools/shared/markdownLinks";
...@@ -50,7 +51,27 @@ export function resolveInlineCodeFileLinkMeta(...@@ -50,7 +51,27 @@ export function resolveInlineCodeFileLinkMeta(
50 const candidate = inlineCodeFilePathCandidate(codeText);51 const candidate = inlineCodeFilePathCandidate(codeText);
51 if (candidate === null) return null;52 if (candidate === null) return null;
5253
53 return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir);54 return resolveMarkdownFileLinkMeta(
55 candidate,
56 cwd,
57 inlineCodePathNamesFromWorkspaceRoot(candidate, cwd, baseDir) ? cwd : baseDir,
58 );
59}
60
61/**
62 * Prose in a workspace file names other files from the repo root (`docs/ai/design.md`),
63 * unlike an explicit link. Single-segment names (`design.md:12`) and `./`, `../`
64 * paths still read as siblings, and files outside the workspace keep their own base.
65 */
66function inlineCodePathNamesFromWorkspaceRoot(
67 candidate: string,
68 cwd: string | undefined,
69 baseDir: string | undefined,
70): boolean {
71 if (!cwd || !baseDir || !isRelativeFilePath(candidate)) return false;
72 if (/^(?:~|\.{1,2})\//.test(candidate)) return false;
73 if (!splitFilePathPosition(candidate).path.includes("/")) return false;
74 return workspaceRelativeFilePath(baseDir, cwd) !== null;
54}75}
5576
56export function resolveMarkdownFileLinkMeta(77export function resolveMarkdownFileLinkMeta(
packages/shared/src/favicon.test.ts+7-2
...@@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => {...@@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => {
23 "http://service.test",23 "http://service.test",
24 "http://private.onion",24 "http://private.onion",
25 "http://127.1..",25 "http://127.1..",
26 "https://grafana.corp",
27 "https://build.lan",
28 "https://wiki.intranet",
29 "https://grafana.internal.acme-corp.com",
30 "https://jira.corp.acme-corp.com:8443",
26 ])("does not disclose %s to the favicon provider", (origin) => {31 ])("does not disclose %s to the favicon provider", (origin) => {
27 expect(faviconUrlForOrigin(origin)).toBeNull();32 expect(faviconUrlForOrigin(origin)).toBeNull();
28 });33 });
2934
30 it("keeps the public origin, port and requested size", () => {35 it("sends only the public hostname and requested size, never the port", () => {
31 expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe(36 expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe(
32 "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64",37 "https://www.google.com/s2/favicons?domain=github.com&sz=64",
33 );38 );
34 });39 });
3540
packages/shared/src/favicon.ts+7-3
...@@ -82,15 +82,19 @@ export function toolActivityFaviconUrl(...@@ -82,15 +82,19 @@ export function toolActivityFaviconUrl(
82 );82 );
83}83}
8484
85/** Return a public favicon URL without disclosing private or reserved hosts. */85/**
86 * Return a public favicon URL that discloses only a public hostname, never the
87 * port, path, or a private or internal-looking host. Callers show a generic
88 * icon when this returns null.
89 */
86export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {90export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null {
87 if (!rawUrl) return null;91 if (!rawUrl) return null;
88 try {92 try {
89 const url = new URL(rawUrl);93 const url = new URL(rawUrl);
90 if (!url.host) return null;94 if (!url.hostname) return null;
91 if (url.protocol !== "http:" && url.protocol !== "https:") return null;95 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
92 if (!isPublicFaviconHost(url.hostname)) return null;96 if (!isPublicFaviconHost(url.hostname)) return null;
93 return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`;97 return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`;
94 } catch {98 } catch {
95 return null;99 return null;
96 }100 }
packages/shared/src/hostClassification.ts+21-1
...@@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => {...@@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => {
119 );119 );
120};120};
121121
122// Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G
123// records as common on internal networks.
124const PRIVATE_FAVICON_TLDS = [
125 ".alt",
126 ".corp",
127 ".example",
128 ".home",
129 ".internal",
130 ".intranet",
131 ".invalid",
132 ".lan",
133 ".onion",
134 ".private",
135 ".test",
136];
137const INTERNAL_HOST_LABELS: ReadonlySet<string> = new Set(["corp", "internal", "intranet"]);
138
122/** Whether a hostname is eligible to be disclosed to a public favicon provider. */139/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
123export const isPublicFaviconHost = (host: string): boolean => {140export const isPublicFaviconHost = (host: string): boolean => {
124 // A single trailing dot is a valid absolute DNS name. Repeated trailing141 // A single trailing dot is a valid absolute DNS name. Repeated trailing
...@@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => {...@@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => {
127 const normalized = normalizeHostname(host);144 const normalized = normalizeHostname(host);
128 if (isPrivateNetworkHost(normalized)) return false;145 if (isPrivateNetworkHost(normalized)) return false;
129 if (146 if (
130 [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(147 PRIVATE_FAVICON_TLDS.some(
131 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),148 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
132 )149 )
133 ) {150 ) {
134 return false;151 return false;
135 }152 }
153 // Company networks often nest internal services under a public domain
154 // (`grafana.internal.acme.com`); never send those names to a third party.
155 if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false;
136 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);156 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
137 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);157 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
138 if (!normalized.includes(":")) return true;158 if (!normalized.includes(":")) return true;
packages/shared/src/markdownLinks.ts+1-1
...@@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean {...@@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
252 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);252 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
253}253}
254254
255function isRelativeFilePath(path: string): boolean {255export function isRelativeFilePath(path: string): boolean {
256 return (256 return (
257 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||257 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
258 (!path.startsWith("/") && !isWindowsAbsolutePath(path))258 (!path.startsWith("/") && !isWindowsAbsolutePath(path))