Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion static/js/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,20 @@ function linkHtml(text, url) {
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
}

// Strict inline-image data URI: raster formats only (no SVG, which can carry
// script), base64 payload only — no quotes, angle brackets, or nested URLs —
// so the value is inert as an <img src>. Omni backends (e.g. Lemonade) return
// generated images embedded in the assistant text as
// ![generated image](data:image/png;base64,...). Same shape chatRenderer.js
// accepts for tool screenshots and display images.
const SAFE_DATA_IMAGE_RE = /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i;

function imageHtml(alt, url, title) {
const safeUrl = safeLinkUrl(url);
let safeUrl = safeLinkUrl(url);
if (!safeUrl) {
const raw = String(url || '').trim();
if (SAFE_DATA_IMAGE_RE.test(raw)) safeUrl = raw;
}
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
const safeAlt = escapeHtml(alt || '');
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
Expand Down Expand Up @@ -124,6 +136,14 @@ function _cleanAllowedHtmlOnce(htmlString) {
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
// Exception: <img src> may carry the strict base64 raster data URI
// that imageHtml emits for inline generated images (omni models).
// The anchored regex admits no SVG, no quotes/brackets, base64
// payload chars only, so the value stays inert across re-parses.
if (name === 'src' && el.tagName.toUpperCase() === 'IMG'
&& SAFE_DATA_IMAGE_RE.test(attr.value)) {
continue;
}
if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
el.removeAttribute(attr.name);
}
Expand Down
15 changes: 15 additions & 0 deletions tests/test_markdown_dom_xss_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,18 @@ def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
assert "sanitizeAllowedHtml," in markdown_src
assert "markdownModule.sanitizeAllowedHtml(t)" in email_body_helper
assert "return t;" not in email_body_helper


def test_markdown_raw_html_sanitizer_img_data_uri_exception_is_strict():
"""The <img src> exception for inline generated images (issue #5436) must
stay anchored to the base64 raster form and must not weaken the general
data:/javascript: strip for every other URL-bearing attribute."""
src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")

# Strict pattern: raster formats only (no SVG), base64 payload chars only.
assert r"/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i" in src
# Exception is scoped to src on IMG elements and the anchored pattern.
assert "name === 'src' && el.tagName.toUpperCase() === 'IMG'" in src
assert "SAFE_DATA_IMAGE_RE.test(attr.value)" in src
# The general scrub for all other URL-bearing attributes is unchanged.
assert "name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)" in src
53 changes: 53 additions & 0 deletions tests/test_markdown_rendering_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,56 @@ def test_dotted_python_import_paths_are_not_autolinked(node_available):
assert 'href="https://imblearn.com' not in html
assert 'href="https://sklearn.me' not in html
assert 'href="https://example.com/docs"' in html


# 1x1 transparent PNG — the smallest valid base64 raster payload.
_TINY_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
"z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)


def test_inline_data_uri_image_renders_as_img(node_available):
# Omni backends (e.g. Lemonade Server) embed generated images in the
# assistant text as ![generated image](data:image/png;base64,...). The
# image pass used to reject every data: URL, so the user saw only the
# literal alt text "generated image" (issue #5436).
html = _run_markdown_case(
f"Here you go:\n\n![generated image](data:image/png;base64,{_TINY_PNG_B64})"
)

assert f'<img src="data:image/png;base64,{_TINY_PNG_B64}"' in html
assert 'alt="generated image"' in html
assert ">generated image<" not in html


def test_svg_data_uri_image_stays_alt_text(node_available):
# SVG can carry script — never allowed inline, even as an image.
html = _run_markdown_case(
"![generated image](data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=)"
)

assert "<img" not in html
assert "generated image" in html


def test_non_image_data_uri_stays_alt_text(node_available):
html = _run_markdown_case("![payload](data:text/html;base64,PHNjcmlwdD4=)")

assert "<img" not in html
assert "payload" in html


def test_javascript_url_image_stays_alt_text(node_available):
html = _run_markdown_case("![click](javascript:alert(1))")

assert "<img" not in html
assert "javascript:" not in html
assert "click" in html


def test_http_image_still_renders(node_available):
html = _run_markdown_case("![photo](https://example.com/a.png)")

assert '<img src="https://example.com/a.png"' in html
assert 'alt="photo"' in html
Loading