diff --git a/static/ui.js b/static/ui.js index cf22b68..5275551 100644 --- a/static/ui.js +++ b/static/ui.js @@ -465,7 +465,7 @@ function renderMd(raw){ t=t.replace(/`([^`\n]+)`/g,(_,x)=>`${esc(x)}`); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); // Escape any plain text that isn't already wrapped in a tag we produced @@ -501,10 +501,10 @@ function renderMd(raw){ } return html+''; }); - // Stash existing tags before link pass so autolink never re-links already-linked URLs + // Stash existing tags so the autolink pass below does not re-link their href= URLs const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Tables: | col | col | header row followed by | --- | --- | separator then data rows s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ diff --git a/tests/test_issue470.py b/tests/test_issue470.py index ecb18fb..30c075b 100644 --- a/tests/test_issue470.py +++ b/tests/test_issue470.py @@ -236,3 +236,27 @@ def test_link_not_broken_by_prior_autolink(): assert f'href="{url1}"' in result assert f'href="{url2}"' in result assert '#461' in result + +def test_href_quote_sanitized(): + """A URL containing a double-quote must have it percent-encoded in href to prevent attribute breakout.""" + # This would break out of href="..." and inject an event handler without the fix + url = 'https://evil.com" onmouseover="alert(1)' + # The [label](url) regex captures up to the closing ), so we test via the render helper + # by constructing a URL that contains a literal quote character + safe_url = 'https://example.com/path"with"quotes' + result = render_links_only(f'[click]({safe_url})') + # The href must not contain a raw unencoded double-quote + href_start = result.find('href="') + 6 + href_end = result.find('"', href_start) + href_val = result[href_start:href_end] + assert '"' not in href_val, ( + f"href value must not contain unencoded double-quote. Got href: {href_val}" + ) + + +def test_js_source_sanitizes_quotes_in_href(): + """JS source must apply quote percent-encoding to URLs before placing in href.""" + # Both the inlineMd stash and outer link pass must sanitize quotes + assert "%22" in UI_JS, ( + "URL placed in href should have double-quotes percent-encoded via .replace to %22" + )