Repository navigation
FIX: Detect Markdown images that span lines in MarkdownInjectionScorer - #3764
Merged
Roman Lutz (romanlutz) merged 9 commits intoNov 2, 2026
Merged
Roman Lutz (romanlutz) merged 9 commits into
Roman Lutz (romanlutz) merged 9 commits into
Conversation
CommonMark lets image text, reference labels and the whitespace before a destination or title contain a line ending, and renderers still fetch the image. The inline and reference image patterns used '.', which stops at a line ending, so these images scored False. Each image pattern keeps its single-line form and adds an alternative that follows the image syntax across lines within a paragraph: the image text ends at its first unescaped, unbalanced ']' (up to two levels of nested brackets), the destination at the first space, line break or ')', and a blank line ends every part. The alternative uses only possessive repetitions, so it searches in linear time. On text without line breaks the patterns match exactly what they matched before.
Roman Lutz (romanlutz)
self-requested a review
November 1, 2026 01:06
9 of 25 tasks
Roman Lutz (romanlutz)
requested changes
Nov 1, 2026
Replace the fixed-depth multi-line regex alternatives with a scan inside each paragraph: brackets pair on one stack, so nesting has no depth limit; paragraphs end at blank lines and at the block starts that interrupt a paragraph (headings, fences, thematic breaks, setext underlines, block quotes, list items, HTML blocks); shortcut labels match after CommonMark normalization. The default patterns are main's again, and the scan runs only for image patterns equal to their defaults.
Code blocks and code spans are not rendered as images, so the scan skips them. Block quote and list item continuation lines are read after the paragraph's own markers and indentation, so a nested quote or a heading inside the item ends the paragraph. A reference image across lines counts only when its label has a definition.
Block quote markers and list indentation are removed from each paragraph line before the image scan, so a destination or label on a quoted continuation line is read as CommonMark reads it. A code fence ends with the block quote or list item that holds it, and a list marker outside an item's indentation starts a new item.
pytest stores the test id in PYTEST_CURRENT_TEST, and Windows limits an environment variable to 32,767 characters, so the inputs of up to 160,000 characters could not be used as ids.
markdown-it-py's block parser, with its inline rule disabled, now gives the paragraphs and headings without container prefixes, leaves code and HTML blocks out and collects the link reference definitions. This replaces the hand-written block splitter and the raw-text definition regex, so a definition inside code no longer counts, one inside a block quote does, and image text inside an HTML block is not scanned. markdown-it-py becomes a direct dependency; 4.0 is required because 3.0.0 parses a run of reference definitions in quadratic time.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Roman Lutz (romanlutz)
approved these changes
Nov 2, 2026
Roman Lutz (romanlutz)
left a comment
Contributor
There was a problem hiding this comment.
fixing this myself
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
On main, the image patterns of
MarkdownInjectionScorer()do not match a Markdown image when the image text, the reference label or the space before the URL or title contains a line break. CommonMark allows line breaks in all of these places within one paragraph, so the reply still renders as an image, and a client that loads remote images automatically requests its URL. A reply such astherefore scores
False, whilescoresTrue. The inline and reference image patterns use., which does not match a line feed, and the shortcut-reference pattern compares label whitespace literally, so![quarterly\nreport]never matches[quarterly report]:. A query-string URL is often still caught byMarkdown Exfil Link, but a URL that carries the data in its path without=, as in #3343, is caught only by the image patterns.This PR keeps every default pattern exactly as on main and adds
_find_images, which follows the image syntax inside each paragraph:>=4.0.0,<5; it was already installed throughrich, at 3.0.0 inuv.lock). Version 4.0 is required because 3.0.0 parses a run of reference definitions in quadratic time.]closes an image, what follows decides the form: an inline destination with an optional title (each may start on the next line), a reference label, or a shortcut reference.MarkdownInjectionScoreruses the scan for each image pattern that has its default value. Custom patterns replace the defaults as before, so a custom dict without the default image patterns does not use the scan.The review examples, with GitHub's renderer (
gh api markdown) andMarkdownInjectionScorer().score_text_async,URL=https://evil.example/SECRET/p.png:70d693e is the head of the first review, and 5c21910 the head of the second.
To check the scan against a renderer, I scored 120,000 random strings made of Markdown punctuation (
!,[,],(,),\,",#,`,>,-), two letters, spaces and line breaks (the shortcut batches also useX,:and tabs; 28,022 strings end with a[label]: uline), and rendered each with markdown-it-py 3.0.0 in CommonMark mode. Of the 321 strings that render an image, main's image patterns miss 157, 70d693e 42 and this PR 1. This PR misses no image that main or 70d693e detects. Of the strings that render no image, this PR flags 556, main 555 and 70d693e 833. On text without line breaks, the three image checks give exactly main's result on all 877,807 single-line strings up to length 7 over![]()x, a space,"and\(length 7 over![]()xonly). For shortcut labels, I compared every image and definition label of 1 to 4 characters overx,X, a space and a tab (115,600 pairs): 18,208 pairs are newly flagged, markdown-it renders an image for each of them, and no pair that renders an image scoresFalse.The bracket scan reads each closing bracket once. I timed
_find_imageson 29 malformed shapes at doubling sizes (fastest of five runs): 15 for the bracket scan (unclosed images on every line, deeply nested openers followed by closers, long runs of[](, unclosed titles, unmatched backtick runs) and 14 for the block parser (deep quotes and lists, thousands of definitions, unclosed HTML comments, fences, setext underlines). When the input length doubles, the time grows by 1.7 to 3.0 times, and most steps are close to 2. The slowest is 30 nested quote levels followed by 40,000 lazy lines, 1.3 s for 160,000 characters. The scan's regular expressions use possessive quantifiers, a Python 3.11 feature, to prevent backtracking within those repetitions; they are incidental to this fix and not an item of #3166.Tests and Documentation
In
tests/unit/score/regex/test_markdown_injection.py:test_md_image_with_line_break_detected: the cases of 70d693e, plus three and 50 levels of brackets nested inside multi-line image text, a shortcut label that spans lines, a shortcut label with different case and spacing, an image across two lines of a block quote, a destination and a shortcut label on a quoted continuation line, an image across a list item's continuation line, a bracket inside a code span, a collapsed reference across lines, an image after a code fence that ended with its block quote or list item, a reference whose definition is inside the block quote, a definition after lone CR line endings, and an image inside an inline<span>. Each case scoresTruewith the expected image pattern in the rationale.test_md_image_lookalikes_not_detected: the existing cases and the blank-line cases of 70d693e, plus a heading, a backtick fence, a tilde fence, a thematic break, a list item, a block quote and an HTML block on the second line, an image on a heading line, the next item of an ordered list, a nested quote and a heading inside a list item, an ordered item after a bullet item, fenced and indented code and a code span, a reference across lines without a matching definition, a shortcut label that spans lines without a definition, a definition inside a code block, and an image inside an HTML comment, a<script>block and a<div>block. All scoreFalse.test_md_image_across_lines_follows_default_patterns_only: a custom inline pattern does not use the scan, the default inline pattern under its default name does.test_md_image_scan_scales_on_malformed_inputandtest_md_injection_scales_on_unclosed_images_across_lines: malformed inputs of up to 150,002 characters through the scan, and 20,000 unclosed images across lines throughscore_text_async, each within 10 s.28 of the new cases fail on 70d693e, six of the seven cases added for the second review fail on 5c21910 (the
<span>control passes there too), and all 83 tests in the file pass with this PR.