#3692: Markdown export escapes link destinations instead of deleting characters

Finding 53: link_target removed `<`, `>` and newlines from a destination after
is_safe_href had accepted it, so an accepted relative target such as
`java<script:alert(1)` was written as `<javascript:alert(1)>` (or `/</host` as
`//host`). The exporter now backslash-escapes `<`, `>`, a backslash that a reader would
treat as an escape, and `&` that starts an entity reference, so a CommonMark reader gets
back exactly the checked target; control characters, which the check ignores, are still
left out. The importer now ends a `<...>` destination at the first unescaped `>`, so these
round-trip. Ordinary destinations are written as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-25 20:59:47 -04:00
committed by Dylan M. Taylor
co-authored by Claude Opus 5.5
parent f6398e29a9
commit e9fe3d29b6
+70 -5
View File
@@ -799,10 +799,18 @@ fn link_dest(s: &str, at: usize) -> Option<(String, usize)> {
i += ws;
let r = s.get(i..)?;
let (dest, used) = if let Some(inner) = r.strip_prefix('<') {
let end = inner.find(['>', '\n'])?;
if inner.get(end..)?.starts_with('\n') {
return None;
}
// The first `>` not escaped by a backslash ends it; a line break first means no link.
let b = inner.as_bytes();
let mut k = 0;
let end = loop {
match b.get(k)? {
b'\\' => k += 1,
b'>' => break k,
b'\n' => return None,
_ => {}
}
k += 1;
};
(inner.get(..end)?.to_string(), end + 2)
} else {
let mut depth = 0i32;
@@ -1436,8 +1444,35 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
out
}
/// `l` as a Markdown link destination that reads back as exactly `l`, the target
/// `is_safe_href` passed. What Markdown would decode (a backslash escape, an entity reference)
/// or end the destination on (`<`, `>`) is backslash-escaped, never deleted: deleting `<` or `>`
/// could turn an accepted relative target into `javascript:…` or `//host`. Control characters
/// can't be written in a destination and are left out, as the safety check ignores them.
fn link_target(l: &str) -> String {
if l.contains([' ', '(', ')', '<', '>']) { format!("<{}>", l.replace(['<', '>', '\n'], "")) } else { l.to_string() }
let l: String = l.chars().filter(|c| !c.is_ascii_control()).collect();
let mut out = String::with_capacity(l.len() + 2);
for (i, c) in l.char_indices() {
let rest = l.get(i + c.len_utf8()..).unwrap_or("");
match c {
'<' | '>' => out.push('\\'),
// A backslash is literal unless punctuation (or the closing `)`/`>`) follows it.
'\\' if rest.chars().next().is_none_or(|n| n.is_ascii_punctuation()) => out.push('\\'),
'&' if entity_like(rest) => out.push('\\'),
_ => {}
}
out.push(c);
}
if l.contains([' ', '(', ')', '<', '>']) { format!("<{out}>") } else { out }
}
/// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`,
/// `#x6A;`), which a Markdown reader would decode.
fn entity_like(rest: &str) -> bool {
let Some(end) = within(rest, 34).find(';') else { return false };
let body = rest.get(..end).unwrap_or("");
let body = body.strip_prefix('#').map_or(body, |n| n.strip_prefix(['x', 'X']).unwrap_or(n));
!body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric())
}
fn marker(m: Mark) -> &'static str {
@@ -2167,4 +2202,34 @@ mod tests {
let s = inl("[a](http://x.org) [b*](http://y.org)");
assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "b*" && f.link.as_deref() == Some("http://y.org"))), "{s:?}");
}
#[test]
fn exported_link_destinations_read_back_unchanged() {
// Removing `<` and `>` from a destination after the safety check could make a relative
// target a script or network-path one (`java<script:…` → `javascript:…`). They are
// escaped instead, as are backslash escapes and entity references a reader would decode.
let md = |l: &str| inlines_md(&[Inline::Text("x".into(), Fmt { link: Some(l.into()), ..Default::default() })]);
assert_eq!(md("java<script:alert(1)"), "[x](<java\\<script:alert(1)>)");
for l in [
"java<script:alert(1)",
"/</evil.example/(x)",
"a>b c",
"&#106;avascript:alert(1)",
"&#x6A;avascript:x",
"&amp;",
"javascript\\:x",
"dir\\",
"dir\\(1)\\",
"q?a=1&b=2",
] {
let out = md(l);
let back = inl(&out);
let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.clone() } else { None }).collect();
assert_eq!(got, [l.to_string()], "{l:?} → {out:?}");
}
// Ordinary destinations are written as before.
assert_eq!(md("https://example.com/a?b=1&c=2"), "[x](https://example.com/a?b=1&c=2)");
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
}