diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9e710d89717e..6ea97f243038 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => { assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }), ).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); }); it("serves HTML assets as utf-8 inside a sandboxed origin", () => { for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { expect(assetResponseHeaders(path)).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); } }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 057e64e96d9d..de4ce26cc915 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin // opaque origin: scripts run, but same-origin cookies, storage, and API calls are // out of reach. Relative sibling assets still load through their signed URLs. // No modals: agent HTML can open without a click (inline renders, and mobile -// loads it as the top document), and must not raise blocking dialogs. -const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups"; +// loads it as the top document), and must not raise blocking dialogs. Downloads +// stay allowed so download links and buttons in the page work. +const HTML_CONTENT_SECURITY_POLICY = + "sandbox allow-scripts allow-forms allow-popups allow-downloads"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 08fb61eac0bb..b62431a08383 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -871,15 +871,15 @@ describe("ChatMarkdown heading levels", () => { />, ); - expect(html).toContain('

Top

'); - expect(html).toContain('

Section

'); - expect(html).toContain('
Fine print
'); + expect(html).toContain('

Top

'); + expect(html).toContain('

Section

'); + expect(html).toContain('
Fine print
'); }); it("leaves heading levels alone when the markdown is not nested", () => { const html = renderToStaticMarkup(); - expect(html).toContain("

Top

"); + expect(html).toContain('

Top

'); }); }); @@ -1272,3 +1272,77 @@ it.each([ } }, ); + +describe("ChatMarkdown heading ids", () => { + it("never gives two headings the same id, even when a suffix matches another heading", () => { + const html = renderToStaticMarkup( + Pinned\n\n## Install\n\n## Install' + } + />, + ); + const ids = [...html.matchAll(/

match[1]); + expect(ids).toEqual([ + "user-content-setup", + "user-content-setup-1", + "user-content-setup-1-1", + "user-content-install-1", + "user-content-install", + "user-content-install-2", + ]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("ChatMarkdown in-page links", () => { + it.each([true, false])( + "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", + async (parseRawHtml) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + window.history.replaceState(null, "", "/#/env/thread"); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + await act(async () => { + root.render( + , + ); + }); + const headings = [...container.querySelectorAll("h2")]; + expect(headings.map((heading) => heading.id)).toEqual([ + "user-content-1-operating-model", + "user-content-1-operating-model-1", + ]); + + const [tocLink, missingLink] = [...container.querySelectorAll("a")]; + const click = () => new MouseEvent("click", { bubbles: true, cancelable: true }); + const tocClick = click(); + tocLink!.dispatchEvent(tocClick); + expect(tocClick.defaultPrevented).toBe(true); + expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); + + const missingClick = click(); + missingLink!.dispatchEvent(missingClick); + expect(missingClick.defaultPrevented).toBe(true); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(window.location.hash).toBe("#/env/thread"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }, + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42b4a0618b6b..4c712365475d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1807,16 +1807,77 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent, return; } - const target = findMarkdownFragmentTarget(event.currentTarget, href); - if (!target) return; - + // Never let the browser follow the fragment or write it to the URL: desktop keeps + // its route in the hash, so replacing the hash navigates away from the thread. event.preventDefault(); - const nextUrl = new URL(window.location.href); - nextUrl.hash = href.slice(1); - window.history.pushState(window.history.state, "", nextUrl); - target.scrollIntoView({ block: "nearest" }); + findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); +} + +type HeadingHastNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: HeadingHastNode[]; +}; + +/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ +function githubHeadingSlug(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") + .replace(/ /g, "-"); +} + +/** + * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the + * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids; + * fragment lookup strips it. + */ +function rehypeHeadingIds() { + return (tree: HeadingHastNode) => { + // Every id already in the document, authored or assigned, so a suffix never + // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. + const taken = new Set(); + const collect = (node: HeadingHastNode) => { + const id = node.properties?.id; + if (typeof id === "string") taken.add(id); + node.children?.forEach(collect); + }; + collect(tree); + const nextSuffix = new Map(); + const visit = (node: HeadingHastNode) => { + if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { + const slug = githubHeadingSlug(hastPlainTextDeep(node)); + if (node.properties?.id === undefined && slug) { + let count = nextSuffix.get(slug) ?? 0; + let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; + while (taken.has(id)) { + count += 1; + id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; + } + nextSuffix.set(slug, count); + taken.add(id); + node.properties = { ...node.properties, id }; + } + return; + } + node.children?.forEach(visit); + }; + visit(tree); + }; } +// Heading ids are added after sanitizing, which would prefix them a second time. +const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ + ...CHAT_MARKDOWN_REHYPE_PLUGINS, + rehypeHeadingIds, +] satisfies NonNullable; + +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + function MarkdownExternalLinkContent({ host, plainText, @@ -3363,7 +3424,11 @@ function ChatMarkdown({ ); } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index a8e01e834c7a..3aaed9058c01 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -91,6 +91,35 @@ describe("relative links inside a rendered host file", () => { workspaceRelativePath: "docs/src/main.ts", }); }); + + it("resolve multi-segment inline code from the workspace root in a workspace file", () => { + expect( + resolveInlineCodeFileLinkMeta("docs/ai/design.md", "/repo", "/repo/docs/ai"), + ).toMatchObject({ filePath: "/repo/docs/ai/design.md" }); + expect( + resolveInlineCodeFileLinkMeta("src/index.ts:4", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/src/index.ts", line: 4 }); + }); + + it("keep sibling and explicitly relative inline code beside the file", () => { + expect(resolveInlineCodeFileLinkMeta("design.md:12", "/repo", "/repo/docs/ai")).toMatchObject({ + filePath: "/repo/docs/ai/design.md", + line: 12, + }); + expect( + resolveInlineCodeFileLinkMeta("./src/index.ts", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/packages/a/./src/index.ts" }); + expect(resolveInlineCodeFileLinkMeta("../b/notes.md", "/repo", "/repo/docs/a")).toMatchObject({ + filePath: "/repo/docs/a/../b/notes.md", + }); + }); + + it("keep multi-segment inline code beside a file outside the workspace", () => { + expect(resolveInlineCodeFileLinkMeta("src/main.ts", "/repo", "/tmp/report")).toMatchObject({ + filePath: "/tmp/report/src/main.ts", + workspaceRelativePath: null, + }); + }); }); describe("resolveInlineCodeFileLinkMeta", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d25d089f6528..3aa188d27ec2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,6 +1,7 @@ import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; import { inlineCodeFilePathCandidate, + isRelativeFilePath, normalizeMarkdownLinkDestination, resolveMarkdownFileLinkTarget, } from "@t3tools/shared/markdownLinks"; @@ -50,7 +51,27 @@ export function resolveInlineCodeFileLinkMeta( const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); + return resolveMarkdownFileLinkMeta( + candidate, + cwd, + inlineCodePathNamesFromWorkspaceRoot(candidate, cwd, baseDir) ? cwd : baseDir, + ); +} + +/** + * Prose in a workspace file names other files from the repo root (`docs/ai/design.md`), + * unlike an explicit link. Single-segment names (`design.md:12`) and `./`, `../` + * paths still read as siblings, and files outside the workspace keep their own base. + */ +function inlineCodePathNamesFromWorkspaceRoot( + candidate: string, + cwd: string | undefined, + baseDir: string | undefined, +): boolean { + if (!cwd || !baseDir || !isRelativeFilePath(candidate)) return false; + if (/^(?:~|\.{1,2})\//.test(candidate)) return false; + if (!splitFilePathPosition(candidate).path.includes("/")) return false; + return workspaceRelativeFilePath(baseDir, cwd) !== null; } export function resolveMarkdownFileLinkMeta( diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index 676f7811011e..e9b4607ca89b 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => { "http://service.test", "http://private.onion", "http://127.1..", + "https://grafana.corp", + "https://build.lan", + "https://wiki.intranet", + "https://grafana.internal.acme-corp.com", + "https://jira.corp.acme-corp.com:8443", ])("does not disclose %s to the favicon provider", (origin) => { expect(faviconUrlForOrigin(origin)).toBeNull(); }); - it("keeps the public origin, port and requested size", () => { + it("sends only the public hostname and requested size, never the port", () => { expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe( - "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64", + "https://www.google.com/s2/favicons?domain=github.com&sz=64", ); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index 2c4847115b90..877f178d6072 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -82,15 +82,19 @@ export function toolActivityFaviconUrl( ); } -/** Return a public favicon URL without disclosing private or reserved hosts. */ +/** + * Return a public favicon URL that discloses only a public hostname, never the + * port, path, or a private or internal-looking host. Callers show a generic + * icon when this returns null. + */ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { if (!rawUrl) return null; try { const url = new URL(rawUrl); - if (!url.host) return null; + if (!url.hostname) return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isPublicFaviconHost(url.hostname)) return null; - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`; + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`; } catch { return null; } diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index ac56cd640af9..3f6dee4bf9da 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => { ); }; +// Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G +// records as common on internal networks. +const PRIVATE_FAVICON_TLDS = [ + ".alt", + ".corp", + ".example", + ".home", + ".internal", + ".intranet", + ".invalid", + ".lan", + ".onion", + ".private", + ".test", +]; +const INTERNAL_HOST_LABELS: ReadonlySet = new Set(["corp", "internal", "intranet"]); + /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ export const isPublicFaviconHost = (host: string): boolean => { // A single trailing dot is a valid absolute DNS name. Repeated trailing @@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (isPrivateNetworkHost(normalized)) return false; if ( - [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + PRIVATE_FAVICON_TLDS.some( (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), ) ) { return false; } + // Company networks often nest internal services under a public domain + // (`grafana.internal.acme.com`); never send those names to a third party. + if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false; const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); if (!normalized.includes(":")) return true; diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 875f764d448d..4055ccb08967 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); } -function isRelativeFilePath(path: string): boolean { +export function isRelativeFilePath(path: string): boolean { return ( RELATIVE_PATH_PREFIX_PATTERN.test(path) || (!path.startsWith("/") && !isWindowsAbsolutePath(path))