fix: block external resource loading in Vega chart rendering to prevent client-side SSRF (#26806)

* fix: block external resource loading in Vega chart rendering to prevent client-side SSRF

renderVegaVisualization renders vega/vega-lite chart specs that appear in untrusted chat content (shared chats, channel messages, assistant/RAG/tool output) by constructing a Vega View with no restricted loader, so a crafted spec could make a viewer's browser issue arbitrary outbound requests. There are two paths: data.url (and topojson/geo data) is fetched via loader.load at view construction, and image-mark urls are resolved via loader.sanitize and emitted as <image href> into the output SVG, fetched by the browser when the SVG is displayed. Both are client-side SSRF, and against same-origin or CORS-permissive targets allow reading the response back into the page. Pass a loader that rejects external resource loads on both paths, load throws and sanitize rejects http(s)/protocol-relative URIs, so rendered charts can only use inline data. Inline data.values charts are unaffected.

Co-authored-by: Zureno <Zureno@users.noreply.github.com>

* fix: resolve Vega image urls with the URL parser before blocking external loads

The previous scheme regex could be bypassed with encodings the browser URL parser
normalizes away, such as a leading tab or newline before the scheme and backslash
variants of protocol-relative urls like /\evil.com, which would still be emitted
into the rendered SVG and fetched externally on display. Resolve the uri against
document.baseURI with the browser's own URL parser and only allow data: uris and
same-origin results, so the check cannot diverge from what the browser would
actually fetch. Also shortens the explanatory comments.

---------

Co-authored-by: Zureno <Zureno@users.noreply.github.com>
This commit is contained in:
Classic298
2026-07-27 09:03:30 +02:00
committed by GitHub
parent 4c2d864b3f
commit 5278eb906e
+16 -1
View File
@@ -2049,7 +2049,22 @@ export const renderVegaVisualization = async (spec: string, lang: string = '', i
const vegaLite = await import('vega-lite');
vegaSpec = vegaLite.compile(parsedSpec).spec;
}
const view = new vega.View(vega.parse(vegaSpec), { renderer: 'none' });
// Specs come from untrusted chat content: block external loads via data.url (loader.load)
// and image mark hrefs emitted into the SVG (loader.sanitize).
const loader = vega.loader();
loader.load = async () => {
throw new Error('External resource loading is disabled for rendered visualizations');
};
const sanitize = loader.sanitize.bind(loader);
loader.sanitize = async (uri: string, options: any) => {
// Resolve with the browser's URL parser so encoding tricks match what it would fetch
const resolved = new URL(uri, document.baseURI);
if (resolved.protocol !== 'data:' && resolved.origin !== location.origin) {
throw new Error('External resource loading is disabled for rendered visualizations');
}
return sanitize(uri, options);
};
const view = new vega.View(vega.parse(vegaSpec), { loader, renderer: 'none' });
const svg = await view.toSVG();
return svg;
};