Render data:image URIs as inline images (raster + base64 SVG) instead of raw base64 text, route file:// images through the media pipeline, with a strict allowlist + 2MB cap and img-only data: sanitizer. Thanks @ai-ag2026. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
140 lines
5.4 KiB
Python
140 lines
5.4 KiB
Python
"""Regression coverage for PR #6209's markdown-image safety contract.
|
|
|
|
The settled renderer in ``ui.js`` and the streaming renderer in ``messages.js``
|
|
must resolve data-image policy through the same ui-owned predicate. These tests
|
|
execute the real small renderer bodies under Node rather than comparing regex
|
|
source text, and pin the observable image markup/safety outcomes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _node_contract_cases() -> dict[str, object]:
|
|
script = r"""
|
|
const fs = require('fs');
|
|
const ui = fs.readFileSync('static/ui.js', 'utf8');
|
|
const messages = fs.readFileSync('static/messages.js', 'utf8');
|
|
function fn(source, name) {
|
|
const marker = 'function ' + name + '(';
|
|
const start = source.indexOf(marker);
|
|
if (start < 0) throw new Error('missing ' + name);
|
|
const brace = source.indexOf('{', start);
|
|
let depth = 1, pos = brace + 1;
|
|
while (depth && pos < source.length) {
|
|
if (source[pos] === '{') depth++;
|
|
else if (source[pos] === '}') depth--;
|
|
pos++;
|
|
}
|
|
return source.slice(start, pos);
|
|
}
|
|
function constLine(source, name) {
|
|
const start = source.indexOf('const ' + name + '=');
|
|
if (start < 0) throw new Error('missing ' + name);
|
|
const end = source.indexOf('\n', start);
|
|
return source.slice(start, end < 0 ? source.length : end);
|
|
}
|
|
function esc(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
}
|
|
const _IMAGE_EXTS = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
|
const _SVG_EXTS = /\.svg$/i;
|
|
const _PDF_EXTS = /\.pdf$/i;
|
|
const _HTML_EXTS = /\.html?$/i;
|
|
const _CSV_EXTS = /\.csv$/i;
|
|
const _EXCALIDRAW_EXTS = /\.excalidraw$/i;
|
|
function _mediaKindForName(ref) { return /\.(?:png|jpe?g|gif|webp|bmp|ico|avif)$/i.test(ref) ? 'image' : ''; }
|
|
function _mediaPlayerHtml() { return ''; }
|
|
const S = {session: {session_id: 's1'}};
|
|
const t = undefined;
|
|
eval(constLine(ui, '_DATA_IMAGE_RE').replace(/^const /, 'var '));
|
|
eval(constLine(ui, '_DATA_IMAGE_SVG_RE').replace(/^const /, 'var '));
|
|
eval(constLine(ui, '_DATA_IMAGE_MAX_LEN').replace(/^const /, 'var '));
|
|
eval(fn(ui, '_isSafeDataImageUri'));
|
|
eval(fn(ui, '_dataImageHtml'));
|
|
eval(fn(ui, '_inlineMediaHtmlForRef'));
|
|
eval(fn(ui, '_mdImageHtml'));
|
|
eval(constLine(messages, '_SMD_SAFE_URL_RE').replace(/^const /, 'var '));
|
|
eval(constLine(messages, '_SMD_SAFE_IMG_URL_RE').replace(/^const /, 'var '));
|
|
eval(fn(messages, '_smdImgSrcAllowed'));
|
|
function _smdLinkHref(value) { return String(value || ''); }
|
|
function _streamFadeBindCleanup() {}
|
|
globalThis.window = {
|
|
smd: {
|
|
HREF: 'href', SRC: 'src',
|
|
default_renderer: () => ({
|
|
add_text() {},
|
|
set_attr(data, attr, value) { data.baseCalls.push({attr, value}); },
|
|
}),
|
|
},
|
|
};
|
|
eval(fn(messages, '_streamFadeRenderer'));
|
|
eval(fn(messages, '_safeSmdRenderer'));
|
|
function invoke(factory, value) {
|
|
const node = {
|
|
attributes: {},
|
|
setAttribute(name, attrValue) { this.attributes[name] = String(attrValue); },
|
|
classList: { add() {} },
|
|
};
|
|
const data = { nodes: [node], index: 0, baseCalls: [] };
|
|
factory({}).set_attr(data, window.smd.SRC, value);
|
|
return {baseCalls: data.baseCalls, blocked: node.attributes['data-blocked-scheme'] === '1'};
|
|
}
|
|
const encodedSvg = 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2Fsvg%22%3E%3C%2Fsvg%3E';
|
|
const html = 'data:text/html;base64,PGgxPkJsb2NrZWQ8L2gxPg==';
|
|
const raster = 'data:image/png;base64,iVBORw0KGgo=';
|
|
const fileHtml = _mdImageHtml('annotated chart', 'file:///tmp/chart.png');
|
|
console.log(JSON.stringify({
|
|
fileHtml,
|
|
settledEncodedSvg: _dataImageHtml(encodedSvg, 'svg') !== null,
|
|
settledRaster: _dataImageHtml(raster, 'chart') !== null,
|
|
fade: { raster: invoke(_streamFadeRenderer, raster), encodedSvg: invoke(_streamFadeRenderer, encodedSvg), html: invoke(_streamFadeRenderer, html) },
|
|
safe: { raster: invoke(_safeSmdRenderer, raster), encodedSvg: invoke(_safeSmdRenderer, encodedSvg), html: invoke(_safeSmdRenderer, html) },
|
|
}));
|
|
"""
|
|
completed = subprocess.run(
|
|
[NODE, "-e", script],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_file_markdown_image_preserves_author_alt_text() -> None:
|
|
cases = _node_contract_cases()
|
|
assert 'alt="annotated chart"' in cases["fileHtml"]
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_url_encoded_svg_and_html_are_blocked_in_actual_stream_hooks() -> None:
|
|
cases = _node_contract_cases()
|
|
assert cases["settledEncodedSvg"] is False
|
|
for mode in ("fade", "safe"):
|
|
for blocked in ("encodedSvg", "html"):
|
|
outcome = cases[mode][blocked]
|
|
assert outcome["baseCalls"] == []
|
|
assert outcome["blocked"] is True
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_base64_raster_reaches_base_set_attr_in_actual_stream_hooks() -> None:
|
|
cases = _node_contract_cases()
|
|
assert cases["settledRaster"] is True
|
|
for mode in ("fade", "safe"):
|
|
outcome = cases[mode]["raster"]
|
|
assert outcome["baseCalls"] == [{"attr": "src", "value": "data:image/png;base64,iVBORw0KGgo="}]
|
|
assert outcome["blocked"] is False
|