fix(studio): block arbitrary external image URLs in markdown renderer (#5602)
* fix(studio): block arbitrary external image URLs in markdown renderer Model-emitted <img src="http://attacker.com/..."> tags were causing the browser to issue HTTP requests to arbitrary origins, leaking the user's IP address, User-Agent, and Referer header to any domain a prompt-injected model could emit (tracking-pixel vector, issue #5596). Add a urlTransform function passed to <Streamdown> that only allows: - data: URIs (inline images, mermaid SVG, user attachments) - blob: URIs (locally generated object URLs) - relative paths without a scheme (same-origin assets) All other schemes (http:, https:, ftp:, etc.) return null, causing Streamdown to omit the <img> element entirely. Existing iframes are already stripped by Streamdown's default sanitizer; event-handler attributes (onerror, onload, etc.) are also stripped by the default schema. * fix(studio): strip control chars and block backslash URL variants Two bypass vectors found after review: 1. Backslash-normalised URLs: \\attacker.com\pixel has no colon and does not start with // so the earlier guards allowed it as a relative path. Browsers normalise leading backslash pairs to // before resolving, so the request still reaches the external origin. 2. Embedded control characters: /\n/attacker.com passes trim() unchanged, startsWith("//") is false, and no-colon check passes it as relative. Browsers strip ASCII controls (U+0000-U+001F, U+007F) before URL resolution, so the value resolves to the attacker origin. Fix: strip all ASCII control characters from the raw URL before any guard, then block any URL whose normalized form starts with two chars from [/\\] to cover //, \\, /\, and \/ in one regex. * fix(studio): delegate non-image URLs to defaultUrlTransform Returning the raw URL for non-img nodes bypassed Streamdown's built-in link sanitization, allowing model-emitted javascript: hrefs to reach the DOM unfiltered. Pass non-image URLs through defaultUrlTransform so the library's own javascript:/data: sanitization stays active for links. * fix(studio): use scheme regex instead of includes() for colon check A colon anywhere in the URL (e.g. /api/image?id=model:v2 or /snapshots/2026-06-04T12:00:00Z.png) was incorrectly treated as an explicit scheme and the URL was dropped. Replace the includes(':') check with a proper scheme regex that only matches when a valid scheme token appears before any path separator. * Studio: shorten safeImageUrl comments in markdown renderer --------- Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
parent
fcfbf166ff
commit
256d17e2e1
1 changed files with 18 additions and 1 deletions
|
|
@ -13,7 +13,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { Block, type BlockProps, Streamdown, defaultUrlTransform, type UrlTransform } from "streamdown";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
|
|
@ -424,6 +424,22 @@ function useRafCoalescedText(text: string, isStreaming: boolean): string {
|
|||
return text;
|
||||
}
|
||||
|
||||
const safeImageUrl: UrlTransform = (url, _key, node) => {
|
||||
// Only images are restricted; links/other nodes use the default transform.
|
||||
if (node.tagName !== "img") return defaultUrlTransform(url, _key, node);
|
||||
|
||||
// Strip ASCII controls first: browsers drop them mid-parse, so a value like
|
||||
// "\t//attacker.com" would otherwise slip past the guards below.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const normalized = url.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
if (lower.startsWith("data:") || lower.startsWith("blob:")) return normalized;
|
||||
if (/^[/\\]{2}/.test(normalized)) return null; // protocol-relative: // \\ /\ \/
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(normalized)) return null; // scheme prefix (colon later in path is fine)
|
||||
return normalized; // relative -> same-origin
|
||||
};
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const { text, status } = useMessagePartText();
|
||||
const displayText = useRafCoalescedText(text, status.type === "running");
|
||||
|
|
@ -444,6 +460,7 @@ const MarkdownTextImpl = () => {
|
|||
isAnimating={status.type === "running"}
|
||||
plugins={{ code, math, mermaid }}
|
||||
components={STREAMDOWN_COMPONENTS}
|
||||
urlTransform={safeImageUrl}
|
||||
controls={{
|
||||
code: false,
|
||||
mermaid: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue