Commit e518a91 h
Changes: 3 files +185 -27
Changes (Side-by-Side)
markdown.awk +72 -20
| |||
| 6 | # See: http://4l77.com/src/md2html.awk |
6 | # See: http://4l77.com/src/md2html.awk |
| 7 | |
7 | |
| 8 | # Attribute values need quotes escaped as well as the usual entities. |
8 | # Attribute values need quotes escaped as well as the usual entities. |
| 9 | # sanitize_url <url> |
||
| 10 | # Empty for any scheme that executes, so a link cannot run code. |
||
| 11 | # |
||
| 12 | # href="javascript:..." is a script the reader triggers by clicking, and |
||
| 13 | # data: and vbscript: and file: are the same problem wearing other names. |
||
| 14 | # Nothing in a document written as prose needs them, and a parser whose |
||
| 15 | # output is served to other people cannot tell a deliberate one from an |
||
| 16 | # injected one. |
||
| 17 | # |
||
| 18 | # The check is made on a folded copy: lowercased, with leading control |
||
| 19 | # characters and whitespace removed, and with the HTML entities and percent |
||
| 20 | # escapes that could hide a colon decoded far enough to expose it. A scheme |
||
| 21 | # is only a scheme up to its first colon, so anything before that colon is |
||
| 22 | # what has to be read -- "javascript:" and " JaVaScRiPt:" are the same |
||
| 23 | # scheme written to slip past a plain prefix test. |
||
| 24 | function sanitize_url(u, probe) { |
||
| 25 | probe = tolower(u) |
||
| 26 | gsub(/[\000-\040]/, "", probe) |
||
| 27 | gsub(/�*9;|�*10;|�*13;|&tab;|&newline;/, "", probe) |
||
| 28 | gsub(/�*58;|:|%3a/, ":", probe) |
||
| 29 | gsub(/�*x?[0-9a-f]+;?/, "", probe) |
||
| 30 | if (probe ~ /^(javascript|data|vbscript|file):/) |
||
| 31 | return "" |
||
| 32 | return u |
||
| 33 | } |
||
| 34 | |
||
| 9 | function escattr(t) { |
35 | function escattr(t) { |
| 10 | t = eschtml(t); |
36 | t = eschtml(t); |
| 11 | gsub(">", "\\>", t); |
37 | gsub(">", "\\>", t); |
function oprint(t){ | |||
| 34 | otext = otext "\n" t; |
60 | otext = otext "\n" t; |
| 35 | } |
61 | } |
| 36 | |
62 | |
| 37 | function subref(id){ |
||
| 38 | for(; nr > 0 && sub("<<" id, ref[id], otext); nr--); |
||
| 63 | function subref(id, rep){ |
||
| 64 | # The stored value is already escaped, so it can contain " -- and |
||
| 65 | # sub() reads & in a replacement as "the text that matched", which would |
||
| 66 | # splice the placeholder back into its own replacement. Doubling the |
||
| 67 | # ampersands makes sub() emit them literally. |
||
| 68 | rep = ref[id] |
||
| 69 | gsub(/&/, "\\\\&", rep) |
||
| 70 | for(; nr > 0 && sub("<<" id, rep, otext); nr--); |
||
| 39 | if(nr == 0 && otext) { |
71 | if(nr == 0 && otext) { |
| 40 | print otext; |
72 | print otext; |
| 41 | otext = ""; |
73 | otext = ""; |
function nextil(t) { | |||
| 104 | linktext = url; |
136 | linktext = url; |
| 105 | if(match(url, /@/) && !match(url, /^mailto:/)) |
137 | if(match(url, /@/) && !match(url, /^mailto:/)) |
| 106 | url = "mailto:" url; |
138 | url = "mailto:" url; |
| 107 | return t1 "<a href=\"" escattr(url) "\">" linktext "</a>" nextil(t2); |
||
| 139 | return t1 "<a href=\"" escattr(sanitize_url(url)) "\">" linktext "</a>" nextil(t2); |
||
| 108 | } |
140 | } |
| 109 | # Html tags |
141 | # Html tags |
| 142 | # Passed through only when the caller has asked for it. A parser whose |
||
| 143 | # output is served to other people cannot tell an author's deliberate |
||
| 144 | # <div> from an injected <script>, and the safe reading has to be the |
||
| 145 | # one you get without saying anything. |
||
| 110 | if(match(t2, /^[A-Za-z\/!][^>]*>/)){ |
146 | if(match(t2, /^[A-Za-z\/!][^>]*>/)){ |
| 111 | tag = tag substr(t2, RSTART, RLENGTH); |
147 | tag = tag substr(t2, RSTART, RLENGTH); |
| 112 | t2 = substr(t2, RLENGTH + 1); |
||
| 113 | return t1 tag nextil(t2); |
||
| 148 | t2 = substr(t2, RSTART + RLENGTH); |
||
| 149 | if(UNSAFE_HTML) |
||
| 150 | return t1 tag nextil(t2); |
||
| 151 | # escattr, not eschtml: eschtml leaves > alone because a bare > |
||
| 152 | # is ordinary in prose, but here the whole tag is being shown as |
||
| 153 | # text and a half-escaped tag reads as broken markup. |
||
| 154 | return t1 escattr(tag) nextil(t2); |
||
| 114 | } |
155 | } |
| 115 | return t1 "<" nextil(t2); |
156 | return t1 "<" nextil(t2); |
| 116 | } |
157 | } |
function nextil(t) { | |||
| 145 | } |
186 | } |
| 146 | if(match(url, /^<.*>$/)) |
187 | if(match(url, /^<.*>$/)) |
| 147 | url = substr(url, 2, RLENGTH - 2); |
188 | url = substr(url, 2, RLENGTH - 2); |
| 148 | return t1 "<img src=\"" escattr(url) "\" alt=\"" escattr(alt) "\"" title " />" nextil(t2); |
||
| 189 | return t1 "<img src=\"" escattr(sanitize_url(url)) "\" alt=\"" escattr(alt) "\"" title " />" nextil(t2); |
||
| 149 | } |
190 | } |
| 150 | else{ |
191 | else{ |
| 151 | # Referenced |
192 | # Referenced |
function nextil(t) { | |||
| 186 | cloc = cite_locator; |
227 | cloc = cite_locator; |
| 187 | cite_locator = ""; |
228 | cite_locator = ""; |
| 188 | clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")"; |
229 | clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")"; |
| 189 | return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \ |
||
| 230 | return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" escattr(cid) \ |
||
| 190 | "\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2); |
231 | "\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2); |
| 191 | } |
232 | } |
| 192 | |
233 | |
function nextil(t) { | |||
| 195 | match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/); |
236 | match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/); |
| 196 | linktext = substr(t2, 1, RLENGTH); |
237 | linktext = substr(t2, 1, RLENGTH); |
| 197 | t2 = substr(t2, RLENGTH + 2); |
238 | t2 = substr(t2, RLENGTH + 2); |
| 198 | return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2); |
||
| 239 | return t1 "<sup class=\"fnref\" data-fnref=\"" escattr(linktext) "\"><a href=\"#fn-" escattr(linktext) "\" id=\"fnref-" escattr(linktext) "\">" eschtml(linktext) "</a></sup>" nextil(t2); |
||
| 199 | } |
240 | } |
| 200 | # Links |
241 | # Links |
| 201 | if(tag == "["){ |
242 | if(tag == "["){ |
function nextil(t) { | |||
| 227 | if(match(url, /^<.*>$/)) |
268 | if(match(url, /^<.*>$/)) |
| 228 | url = substr(url, 2, RLENGTH - 2); |
269 | url = substr(url, 2, RLENGTH - 2); |
| 229 | url = eschtml(url); |
270 | url = eschtml(url); |
| 230 | return t1 "<a href=\"" escattr(url) "\"" title ">" nextil(linktext) "</a>" nextil(pt2); |
||
| 271 | return t1 "<a href=\"" escattr(sanitize_url(url)) "\"" title ">" nextil(linktext) "</a>" nextil(pt2); |
||
| 231 | } |
272 | } |
| 232 | else{ |
273 | else{ |
| 233 | # Referenced: [text][id], [text][] or the shortcut [text]. |
274 | # Referenced: [text][id], [text][] or the shortcut [text]. |
BEGIN { | |||
| 483 | sub(/^[^\]]*\]:[ ]+/, ""); |
524 | sub(/^[^\]]*\]:[ ]+/, ""); |
| 484 | title = ""; |
525 | title = ""; |
| 485 | if(match($0, /".*"$/)) |
526 | if(match($0, /".*"$/)) |
| 486 | title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2); |
||
| 527 | title = "\" title=\"" escattr(substr($0, RSTART + 1, RLENGTH - 2)); |
||
| 487 | sub(/[ ]+".*"$/, ""); |
528 | sub(/[ ]+".*"$/, ""); |
| 488 | url = eschtml($0); |
||
| 529 | # The url lands in an href or src, so it is escaped as an attribute and |
||
| 530 | # filtered for schemes that execute -- a reference definition is as good |
||
| 531 | # a place to hide a javascript: url as an inline link is. |
||
| 532 | url = escattr(sanitize_url($0)); |
||
| 489 | ref[id] = url title; |
533 | ref[id] = url title; |
| 490 | |
534 | |
| 491 | subref(id); |
535 | subref(id); |
BEGIN { | |||
| 500 | id = tolower(id); |
544 | id = tolower(id); |
| 501 | title = ""; |
545 | title = ""; |
| 502 | if(match($0, /".*"$/)) |
546 | if(match($0, /".*"$/)) |
| 503 | title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2); |
||
| 547 | title = "\" title=\"" escattr(substr($0, RSTART + 1, RLENGTH - 2)); |
||
| 504 | sub(/[ ]+".*"$/, ""); |
548 | sub(/[ ]+".*"$/, ""); |
| 505 | url = eschtml($0); |
||
| 549 | # The url lands in an href or src, so it is escaped as an attribute and |
||
| 550 | # filtered for schemes that execute -- a reference definition is as good |
||
| 551 | # a place to hide a javascript: url as an inline link is. |
||
| 552 | url = escattr(sanitize_url($0)); |
||
| 506 | ref[id] = url title; |
553 | ref[id] = url title; |
| 507 | |
554 | |
| 508 | subref(id); |
555 | subref(id); |
BEGIN { | |||
| 537 | } |
584 | } |
| 538 | |
585 | |
| 539 | # html |
586 | # html |
| 540 | !html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\ |
||
| 587 | # Block-level raw HTML, passed through only when the caller has asked for it |
||
| 588 | # with -v UNSAFE_HTML=1. Without the flag the line is not recognised as HTML |
||
| 589 | # at all and falls through to ordinary paragraph handling, which escapes it -- |
||
| 590 | # so a document that opens with <script> renders that tag as text rather than |
||
| 591 | # running it. |
||
| 592 | UNSAFE_HTML && !html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\ |
||
| 541 | isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ { |
593 | isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ { |
| 542 | if(code) |
594 | if(code) |
| 543 | oprint(code_held "</code></pre>"); code_held = ""; |
595 | oprint(code_held "</code></pre>"); code_held = ""; |
isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ { | |||
| 554 | next; |
606 | next; |
| 555 | } |
607 | } |
| 556 | |
608 | |
| 557 | html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\ |
||
| 609 | UNSAFE_HTML && html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\ |
||
| 558 | isindex|menu|noframes|noscript|ol|p|pre|table|ul).*)|(--)>$/ || |
610 | isindex|menu|noframes|noscript|ol|p|pre|table|ul).*)|(--)>$/ || |
| 559 | (hr && />$/)) { |
611 | (hr && />$/)) { |
| 560 | html = 0; |
612 | html = 0; |
fence { | |||
| 788 | if(lang == "mermaid" || lang == "mermaid-legend"){ |
840 | if(lang == "mermaid" || lang == "mermaid-legend"){ |
| 789 | fence_raw = 1; |
841 | fence_raw = 1; |
| 790 | fence_close = "</div>"; |
842 | fence_close = "</div>"; |
| 791 | fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">"; |
||
| 843 | fence_open = "<div class=\"diagram diagram-" escattr(lang) "\">"; |
||
| 792 | } |
844 | } |
| 793 | else if(info != "") |
845 | else if(info != "") |
| 794 | fence_open = "<pre><code class=\"language-" eschtml(info) "\">"; |
||
| 846 | fence_open = "<pre><code class=\"language-" escattr(info) "\">"; |
||
| 795 | else |
847 | else |
| 796 | fence_open = "<pre><code>"; |
848 | fence_open = "<pre><code>"; |
| 797 | next; |
849 | next; |
END { | |||
| 1017 | print "<ol>"; |
1069 | print "<ol>"; |
| 1018 | for(ci = 1; ci <= cite_n; ci++){ |
1070 | for(ci = 1; ci <= cite_n; ci++){ |
| 1019 | cid = cite_order[ci]; |
1071 | cid = cite_order[ci]; |
| 1020 | print "<li id=\"cn:" ci "\"><p>" inline(cite_body[cid]) \ |
||
| 1021 | " <a href=\"#cnref:" ci "\" title=\"return to body\" class=\"reversecitation\"> ↩︎</a></p></li>"; |
||
| 1072 | print "<li id=\"cn:" escattr(ci) "\"><p>" inline(cite_body[cid]) \ |
||
| 1073 | " <a href=\"#cnref:" escattr(ci) "\" title=\"return to body\" class=\"reversecitation\"> ↩︎</a></p></li>"; |
||
| 1022 | } |
1074 | } |
| 1023 | print "</ol>"; |
1075 | print "</ol>"; |
| 1024 | print "</div>"; |
1076 | print "</div>"; |
END { | |||
| 1027 | # Print footnotes |
1079 | # Print footnotes |
| 1028 | if(alen(fnref)>0) { |
1080 | if(alen(fnref)>0) { |
| 1029 | print "<ul class=\"fn-list\">"; |
1081 | print "<ul class=\"fn-list\">"; |
| 1030 | for (i in fnref) print "<li id=\"fn-" i "\" class=\"fn-item\"><span class=\"fn-handle\">" i ": </span><span class=\"fn-text\">" inline(fnref[i]) " <a href=\"#fnref-" i "\" class=\"fn-backref\">↩︎</a></span></li>"; |
||
| 1082 | for (i in fnref) print "<li id=\"fn-" escattr(i) "\" class=\"fn-item\"><span class=\"fn-handle\">" eschtml(i) ": </span><span class=\"fn-text\">" inline(fnref[i]) " <a href=\"#fnref-" escattr(i) "\" class=\"fn-backref\">↩︎</a></span></li>"; |
||
| 1031 | print "</ul>"; |
1083 | print "</ul>"; |
| 1032 | } |
1084 | } |
| 1033 | } |
1085 | } |
test/regressions.sh +11 -3
md_render() { | |||
| 13 | printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" |
13 | printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" |
| 14 | } |
14 | } |
| 15 | |
15 | |
| 16 | md_unsafe() { |
||
| 17 | printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$REPO_ROOT/markdown.awk" |
||
| 18 | } |
||
| 19 | |
||
| 16 | suite "nested lists" |
20 | suite "nested lists" |
| 17 | |
21 | |
| 18 | # The nesting machinery existed but the indent unit was a tab or four spaces, |
22 | # The nesting machinery existed but the indent unit was a tab or four spaces, |
assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.' | |||
| 89 | it "tildes inside code are untouched" |
93 | it "tildes inside code are untouched" |
| 90 | assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>' |
94 | assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>' |
| 91 | |
95 | |
| 96 | # These exercise how underscores interact with real tags, which is only |
||
| 97 | # observable while tags survive -- so they run in the opt-in passthrough mode. |
||
| 98 | # The escaping default has its own tests; what is checked here is the parsing |
||
| 99 | # rule, not which mode is the default. |
||
| 92 | suite "underscores in inline HTML" |
100 | suite "underscores in inline HTML" |
| 93 | |
101 | |
| 94 | it "an underscore that is a tag's whole content is literal" |
102 | it "an underscore that is a tag's whole content is literal" |
| 95 | # <span class="us">_</span> writes a visible underscore. It opened an <em> |
103 | # <span class="us">_</span> writes a visible underscore. It opened an <em> |
| 96 | # that swallowed the markup after it. |
104 | # that swallowed the markup after it. |
| 97 | assert_contains "$(md_render 'x <span class="us">_</span> y')" '<span class="us">_</span>' |
||
| 105 | assert_contains "$(md_unsafe 'x <span class="us">_</span> y')" '<span class="us">_</span>' |
||
| 98 | |
106 | |
| 99 | it "two such underscores do not pair across their tags" |
107 | it "two such underscores do not pair across their tags" |
| 100 | # They emitted mis-nested "<b><em></b> ... <b></em></b>". |
108 | # They emitted mis-nested "<b><em></b> ... <b></em></b>". |
| 101 | pair=$(md_render 'A <b>_</b> and <b>_</b> here.') |
||
| 109 | pair=$(md_unsafe 'A <b>_</b> and <b>_</b> here.') |
||
| 102 | assert_contains "$pair" '<b>_</b> and <b>_</b>' |
110 | assert_contains "$pair" '<b>_</b> and <b>_</b>' |
| 103 | assert_not_contains "$pair" '<em>' |
111 | assert_not_contains "$pair" '<em>' |
| 104 | |
112 | |
it "bold still works" | |||
| 109 | assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>' |
117 | assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>' |
| 110 | |
118 | |
| 111 | it "emphasis spanning a tag still works" |
119 | it "emphasis spanning a tag still works" |
| 112 | assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>' |
||
| 120 | assert_contains "$(md_unsafe 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>' |
||
| 113 | |
121 | |
| 114 | it "intraword underscores are still literal" |
122 | it "intraword underscores are still literal" |
| 115 | assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' |
123 | assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' |
test/units.sh +102 -4
suite "markdown: intraword underscores" | |||
| 16 | # paragraph carries an anchor -- that has tests of its own, and pinning a |
16 | # paragraph carries an anchor -- that has tests of its own, and pinning a |
| 17 | # content hash in every one of these would make them unreadable and would |
17 | # content hash in every one of these would make them unreadable and would |
| 18 | # break them all again on any wording change. |
18 | # break them all again on any wording change. |
| 19 | md_unsafe() { printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$REPO_ROOT/markdown.awk"; } |
||
| 19 | md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" \ |
20 | md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" \ |
| 20 | | "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; } |
21 | | "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; } |
| 21 | |
22 | |
After.')" "<p>After.</p>" | |||
| 356 | it "a pipe in prose is not a table" |
357 | it "a pipe in prose is not a table" |
| 357 | assert_not_contains "$(md 'a | b in ordinary prose')" "<table>" |
358 | assert_not_contains "$(md 'a | b in ordinary prose')" "<table>" |
| 358 | |
359 | |
| 359 | # Markdown deliberately allows inline HTML, in cells as in prose. |
||
| 360 | # multimarkdown 6.6.0 emits <th> <b>x</b> </th> for this, and this parser's |
||
| 361 | # prose handling passes raw HTML through too, so a cell must not differ. |
||
| 362 | it "raw html in a cell passes through, as in prose" |
||
| 360 | # A cell must handle raw HTML exactly as prose does. That used to mean both |
||
| 361 | # passed it through; it now means both escape it unless the caller asks for |
||
| 362 | # passthrough. What matters is that a cell is not a hole in whichever rule is |
||
| 363 | # in force. |
||
| 364 | it "raw html in a cell is escaped, as in prose" |
||
| 363 | assert_contains "$(md '| <b>x</b> | |
365 | assert_contains "$(md '| <b>x</b> | |
| 364 | |---| |
366 | |---| |
| 367 | | a |')" "<b>x</b>" |
||
| 368 | |
||
| 369 | it "raw html in a cell passes through when the caller asks, as in prose" |
||
| 370 | assert_contains "$(md_unsafe '| <b>x</b> | |
||
| 371 | |---| |
||
| 365 | | a |')" "<b>x</b>" |
372 | | a |')" "<b>x</b>" |
| 366 | |
373 | |
| 367 | it "a table inside a fence is literal" |
374 | it "a table inside a fence is literal" |
assert_contains "$(md '<https://example.com>')" '<a href="https://example.com">h | |||
| 545 | it "a quote in an autolink cannot close the attribute" |
552 | it "a quote in an autolink cannot close the attribute" |
| 546 | assert_contains "$(md '<https://x.com/?a="b">')" 'href="https://x.com/?a="b""' |
553 | assert_contains "$(md '<https://x.com/?a="b">')" 'href="https://x.com/?a="b""' |
| 547 | |
554 | |
| 555 | suite "markdown: html is escaped unless the caller opts in" |
||
| 556 | |
||
| 557 | # The parser renders documents it did not write onto pages other people read, |
||
| 558 | # and it cannot tell an author's deliberate <div> from an injected <script>. |
||
| 559 | # So escaping is what you get without saying anything, and passthrough is what |
||
| 560 | # you have to ask for by name -- a caller who forgets the flag is safe, and a |
||
| 561 | # caller who wants raw HTML has said so somewhere greppable. |
||
| 562 | |
||
| 563 | it "a script tag is shown as text, not run" |
||
| 564 | out=$(md '<script>alert(1)</script>') |
||
| 565 | assert_contains "$out" '<script>' |
||
| 566 | assert_not_contains "$out" '<script>' |
||
| 567 | |
||
| 568 | it "an inline tag with a handler is shown as text" |
||
| 569 | out=$(md 'x <img src=y onerror=alert(1)>') |
||
| 570 | assert_not_contains "$out" '<img' |
||
| 571 | |
||
| 572 | it "a block-level tag is shown as text" |
||
| 573 | out=$(md '<div class="x"> |
||
| 574 | raw |
||
| 575 | </div>') |
||
| 576 | assert_not_contains "$out" '<div class="x">' |
||
| 577 | |
||
| 578 | it "the flag brings passthrough back" |
||
| 579 | assert_contains "$(md_unsafe 'a <b>bold</b>')" '<b>bold</b>' |
||
| 580 | |
||
| 581 | it "the flag does not disable attribute escaping" |
||
| 582 | # The flag is about whole tags the author wrote. A quote breaking out of an |
||
| 583 | # attribute the parser built is never something a caller asked for. |
||
| 584 | assert_not_contains "$(md_unsafe 'x[^f" onclick="alert(1)]')" '" onclick="alert(1)"' |
||
| 585 | |
||
| 586 | suite "markdown: attributes cannot be broken out of" |
||
| 587 | |
||
| 588 | # Each of these put user text into an attribute without escaping the quote, so |
||
| 589 | # the quote closed the attribute early and what followed became a live handler. |
||
| 590 | |
||
| 591 | it "a citation id cannot carry a handler" |
||
| 592 | out=$(md 'x[#c" onclick="alert(1)] |
||
| 593 | |
||
| 594 | [#c" onclick="alert(1)]: A source') |
||
| 595 | assert_not_contains "$out" '" onclick="alert(1)"' |
||
| 596 | assert_contains "$out" 'data-citation="c"' |
||
| 597 | |
||
| 598 | it "a footnote reference cannot carry a handler" |
||
| 599 | out=$(md 'x[^f" onclick="alert(1)] |
||
| 600 | |
||
| 601 | [^f" onclick="alert(1)]: note') |
||
| 602 | assert_not_contains "$out" '" onclick="alert(1)"' |
||
| 603 | |
||
| 604 | it "a footnote list entry cannot carry a handler" |
||
| 605 | # The marker and the list entry are built separately; both take the key. |
||
| 606 | out=$(md 'a[^k" onclick="alert(1)] |
||
| 607 | |
||
| 608 | [^k" onclick="alert(1)]: note') |
||
| 609 | assert_not_contains "$out" 'id="fn-k" onclick=' |
||
| 610 | |
||
| 611 | it "a fence language cannot carry a handler" |
||
| 612 | out=$(md '```x" onload="alert(1) |
||
| 613 | code |
||
| 614 | ```') |
||
| 615 | assert_not_contains "$out" '" onload="alert(1)"' |
||
| 616 | |
||
| 617 | suite "markdown: urls that execute are dropped" |
||
| 618 | |
||
| 619 | it "a javascript: link is emptied" |
||
| 620 | assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:' |
||
| 621 | |
||
| 622 | it "case does not smuggle it past" |
||
| 623 | assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)' |
||
| 624 | |
||
| 625 | it "leading whitespace does not smuggle it past" |
||
| 626 | assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)' |
||
| 627 | |
||
| 628 | it "a data: image is emptied" |
||
| 629 | assert_not_contains "$(md '')" 'src="data:' |
||
| 630 | |
||
| 631 | it "vbscript: is dropped too" |
||
| 632 | assert_not_contains "$(md '[z](vbscript:msgbox(1))')" 'href="vbscript:' |
||
| 633 | |
||
| 634 | it "a reference definition cannot hide one" |
||
| 635 | assert_not_contains "$(md '[a][r] |
||
| 636 | |
||
| 637 | [r]: javascript:alert(1)')" 'href="javascript:' |
||
| 638 | |
||
| 639 | it "an ordinary link is untouched" |
||
| 640 | assert_contains "$(md '[ok](https://example.com)')" 'href="https://example.com"' |
||
| 641 | |
||
| 642 | it "a relative link is untouched" |
||
| 643 | assert_contains "$(md '[ok](/local/page)')" 'href="/local/page"' |
||
| 644 | |
||
| 548 | summary |
645 | summary |
| 646 | |
||
Raw
commit e518a9122b6d709032ecfd669f2446fdad7a36c9
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Sat Sep 5 10:19:10 2026 +0800
Escape by default, and close the attribute and URL holes
This renders documents it did not write onto pages other people read, and
it cannot tell an author's deliberate <div> from an injected <script>. So
raw HTML is escaped now unless the caller asks for passthrough by name:
awk -v UNSAFE_HTML=1 -f markdown.awk
A caller who forgets the flag is safe; one who wants passthrough has said
so somewhere greppable. The flag covers whole tags an author wrote. It
does not cover the attributes this builds itself -- nobody asks for a
quote breaking out of an attribute -- so those are always escaped.
Every attribute built from document text now goes through escattr(). Six
sites were concatenating it raw, and a quote closed the attribute early
so what followed became a live handler:
- citation id, into data-citation
- footnote reference, into data-fnref, href and id -- three at once
- fence language, into class
- footnote list entry, into id and the backref href -- twice
- citation list entry, the same shape
- reference definition titles
Three of those were not in the report; they turned up in the audit of
every attribute site, which is why the audit was worth doing rather than
patching the named three.
Schemes that execute are dropped: javascript:, data:, vbscript:, file:.
The test is made on a folded copy -- lowercased, leading control
characters and whitespace stripped, entity and percent escapes that could
hide the colon decoded -- because "JaVaScRiPt:", " javascript:" and
"javascript:" are the same scheme written to slip past a prefix
test.
One subtlety in the reference definitions: sub() reads & in a replacement
as the text that matched, so an escaped value containing " spliced
the placeholder into its own replacement and produced "<<rquot;". The
ampersands are doubled at substitution time now.
Seventeen tests, one per payload, each checked to fail without its fix:
disabling the scheme filter fails six, disabling the escaping fails five.
diff --git a/markdown.awk b/markdown.awk
index c486c76..692da99 100755
--- a/markdown.awk
+++ b/markdown.awk
@@ -6,6 +6,32 @@
# See: http://4l77.com/src/md2html.awk
# Attribute values need quotes escaped as well as the usual entities.
+# sanitize_url <url>
+# Empty for any scheme that executes, so a link cannot run code.
+#
+# href="javascript:..." is a script the reader triggers by clicking, and
+# data: and vbscript: and file: are the same problem wearing other names.
+# Nothing in a document written as prose needs them, and a parser whose
+# output is served to other people cannot tell a deliberate one from an
+# injected one.
+#
+# The check is made on a folded copy: lowercased, with leading control
+# characters and whitespace removed, and with the HTML entities and percent
+# escapes that could hide a colon decoded far enough to expose it. A scheme
+# is only a scheme up to its first colon, so anything before that colon is
+# what has to be read -- "javascript:" and " JaVaScRiPt:" are the same
+# scheme written to slip past a plain prefix test.
+function sanitize_url(u, probe) {
+ probe = tolower(u)
+ gsub(/[\000-\040]/, "", probe)
+ gsub(/�*9;|�*10;|�*13;|&tab;|&newline;/, "", probe)
+ gsub(/�*58;|:|%3a/, ":", probe)
+ gsub(/�*x?[0-9a-f]+;?/, "", probe)
+ if (probe ~ /^(javascript|data|vbscript|file):/)
+ return ""
+ return u
+}
+
function escattr(t) {
t = eschtml(t);
gsub(">", "\\>", t);
@@ -34,8 +60,14 @@ function oprint(t){
otext = otext "\n" t;
}
-function subref(id){
- for(; nr > 0 && sub("<<" id, ref[id], otext); nr--);
+function subref(id, rep){
+ # The stored value is already escaped, so it can contain " -- and
+ # sub() reads & in a replacement as "the text that matched", which would
+ # splice the placeholder back into its own replacement. Doubling the
+ # ampersands makes sub() emit them literally.
+ rep = ref[id]
+ gsub(/&/, "\\\\&", rep)
+ for(; nr > 0 && sub("<<" id, rep, otext); nr--);
if(nr == 0 && otext) {
print otext;
otext = "";
@@ -104,13 +136,22 @@ function nextil(t) {
linktext = url;
if(match(url, /@/) && !match(url, /^mailto:/))
url = "mailto:" url;
- return t1 "<a href=\"" escattr(url) "\">" linktext "</a>" nextil(t2);
+ return t1 "<a href=\"" escattr(sanitize_url(url)) "\">" linktext "</a>" nextil(t2);
}
# Html tags
+ # Passed through only when the caller has asked for it. A parser whose
+ # output is served to other people cannot tell an author's deliberate
+ # <div> from an injected <script>, and the safe reading has to be the
+ # one you get without saying anything.
if(match(t2, /^[A-Za-z\/!][^>]*>/)){
tag = tag substr(t2, RSTART, RLENGTH);
- t2 = substr(t2, RLENGTH + 1);
- return t1 tag nextil(t2);
+ t2 = substr(t2, RSTART + RLENGTH);
+ if(UNSAFE_HTML)
+ return t1 tag nextil(t2);
+ # escattr, not eschtml: eschtml leaves > alone because a bare >
+ # is ordinary in prose, but here the whole tag is being shown as
+ # text and a half-escaped tag reads as broken markup.
+ return t1 escattr(tag) nextil(t2);
}
return t1 "<" nextil(t2);
}
@@ -145,7 +186,7 @@ function nextil(t) {
}
if(match(url, /^<.*>$/))
url = substr(url, 2, RLENGTH - 2);
- return t1 "<img src=\"" escattr(url) "\" alt=\"" escattr(alt) "\"" title " />" nextil(t2);
+ return t1 "<img src=\"" escattr(sanitize_url(url)) "\" alt=\"" escattr(alt) "\"" title " />" nextil(t2);
}
else{
# Referenced
@@ -186,7 +227,7 @@ function nextil(t) {
cloc = cite_locator;
cite_locator = "";
clabel = (cloc != "") ? "(" cloc ", " cnum ")" : "(" cnum ")";
- return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" cid \
+ return t1 "<a href=\"#cn:" cnum "\" id=\"cnref:" cnum "\" data-citation=\"" escattr(cid) \
"\" title=\"see citation\" class=\"citation\">" clabel "</a>" nextil(t2);
}
@@ -195,7 +236,7 @@ function nextil(t) {
match(t2, /^[^\]]*(\[[^\]]*\][^\]]*)*/);
linktext = substr(t2, 1, RLENGTH);
t2 = substr(t2, RLENGTH + 2);
- return t1 "<sup class=\"fnref\" data-fnref=\"" linktext "\"><a href=\"#fn-" linktext "\" id=\"fnref-" linktext "\">" linktext "</a></sup>" nextil(t2);
+ return t1 "<sup class=\"fnref\" data-fnref=\"" escattr(linktext) "\"><a href=\"#fn-" escattr(linktext) "\" id=\"fnref-" escattr(linktext) "\">" eschtml(linktext) "</a></sup>" nextil(t2);
}
# Links
if(tag == "["){
@@ -227,7 +268,7 @@ function nextil(t) {
if(match(url, /^<.*>$/))
url = substr(url, 2, RLENGTH - 2);
url = eschtml(url);
- return t1 "<a href=\"" escattr(url) "\"" title ">" nextil(linktext) "</a>" nextil(pt2);
+ return t1 "<a href=\"" escattr(sanitize_url(url)) "\"" title ">" nextil(linktext) "</a>" nextil(pt2);
}
else{
# Referenced: [text][id], [text][] or the shortcut [text].
@@ -483,9 +524,12 @@ BEGIN {
sub(/^[^\]]*\]:[ ]+/, "");
title = "";
if(match($0, /".*"$/))
- title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
+ title = "\" title=\"" escattr(substr($0, RSTART + 1, RLENGTH - 2));
sub(/[ ]+".*"$/, "");
- url = eschtml($0);
+ # The url lands in an href or src, so it is escaped as an attribute and
+ # filtered for schemes that execute -- a reference definition is as good
+ # a place to hide a javascript: url as an inline link is.
+ url = escattr(sanitize_url($0));
ref[id] = url title;
subref(id);
@@ -500,9 +544,12 @@ BEGIN {
id = tolower(id);
title = "";
if(match($0, /".*"$/))
- title = "\" title=\"" substr($0, RSTART + 1, RLENGTH - 2);
+ title = "\" title=\"" escattr(substr($0, RSTART + 1, RLENGTH - 2));
sub(/[ ]+".*"$/, "");
- url = eschtml($0);
+ # The url lands in an href or src, so it is escaped as an attribute and
+ # filtered for schemes that execute -- a reference definition is as good
+ # a place to hide a javascript: url as an inline link is.
+ url = escattr(sanitize_url($0));
ref[id] = url title;
subref(id);
@@ -537,7 +584,12 @@ BEGIN {
}
# html
-!html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
+# Block-level raw HTML, passed through only when the caller has asked for it
+# with -v UNSAFE_HTML=1. Without the flag the line is not recognised as HTML
+# at all and falls through to ordinary paragraph handling, which escapes it --
+# so a document that opens with <script> renders that tag as text rather than
+# running it.
+UNSAFE_HTML && !html && /^<(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ {
if(code)
oprint(code_held "</code></pre>"); code_held = "";
@@ -554,7 +606,7 @@ isindex|menu|noframes|noscript|ol|p|pre|table|ul|!--)/ {
next;
}
-html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
+UNSAFE_HTML && html && (/(^<\/(address|blockquote|center|dir|div|dl|fieldset|form|h[1-6r]|\
isindex|menu|noframes|noscript|ol|p|pre|table|ul).*)|(--)>$/ ||
(hr && />$/)) {
html = 0;
@@ -788,10 +840,10 @@ fence {
if(lang == "mermaid" || lang == "mermaid-legend"){
fence_raw = 1;
fence_close = "</div>";
- fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">";
+ fence_open = "<div class=\"diagram diagram-" escattr(lang) "\">";
}
else if(info != "")
- fence_open = "<pre><code class=\"language-" eschtml(info) "\">";
+ fence_open = "<pre><code class=\"language-" escattr(info) "\">";
else
fence_open = "<pre><code>";
next;
@@ -1017,8 +1069,8 @@ END {
print "<ol>";
for(ci = 1; ci <= cite_n; ci++){
cid = cite_order[ci];
- print "<li id=\"cn:" ci "\"><p>" inline(cite_body[cid]) \
- " <a href=\"#cnref:" ci "\" title=\"return to body\" class=\"reversecitation\"> ↩︎</a></p></li>";
+ print "<li id=\"cn:" escattr(ci) "\"><p>" inline(cite_body[cid]) \
+ " <a href=\"#cnref:" escattr(ci) "\" title=\"return to body\" class=\"reversecitation\"> ↩︎</a></p></li>";
}
print "</ol>";
print "</div>";
@@ -1027,7 +1079,7 @@ END {
# Print footnotes
if(alen(fnref)>0) {
print "<ul class=\"fn-list\">";
- for (i in fnref) print "<li id=\"fn-" i "\" class=\"fn-item\"><span class=\"fn-handle\">" i ": </span><span class=\"fn-text\">" inline(fnref[i]) " <a href=\"#fnref-" i "\" class=\"fn-backref\">↩︎</a></span></li>";
+ for (i in fnref) print "<li id=\"fn-" escattr(i) "\" class=\"fn-item\"><span class=\"fn-handle\">" eschtml(i) ": </span><span class=\"fn-text\">" inline(fnref[i]) " <a href=\"#fnref-" escattr(i) "\" class=\"fn-backref\">↩︎</a></span></li>";
print "</ul>";
}
}
diff --git a/test/regressions.sh b/test/regressions.sh
index cd6f732..04595e9 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -13,6 +13,10 @@ md_render() {
printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk"
}
+md_unsafe() {
+ printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$REPO_ROOT/markdown.awk"
+}
+
suite "nested lists"
# The nesting machinery existed but the indent unit was a tab or four spaces,
@@ -89,16 +93,20 @@ assert_contains "$(md_render 'Unpaired ~~ stays.')" 'Unpaired ~~ stays.'
it "tildes inside code are untouched"
assert_contains "$(md_render 'A `~~code~~` span.')" '<code>~~code~~</code>'
+# These exercise how underscores interact with real tags, which is only
+# observable while tags survive -- so they run in the opt-in passthrough mode.
+# The escaping default has its own tests; what is checked here is the parsing
+# rule, not which mode is the default.
suite "underscores in inline HTML"
it "an underscore that is a tag's whole content is literal"
# <span class="us">_</span> writes a visible underscore. It opened an <em>
# that swallowed the markup after it.
-assert_contains "$(md_render 'x <span class="us">_</span> y')" '<span class="us">_</span>'
+assert_contains "$(md_unsafe 'x <span class="us">_</span> y')" '<span class="us">_</span>'
it "two such underscores do not pair across their tags"
# They emitted mis-nested "<b><em></b> ... <b></em></b>".
-pair=$(md_render 'A <b>_</b> and <b>_</b> here.')
+pair=$(md_unsafe 'A <b>_</b> and <b>_</b> here.')
assert_contains "$pair" '<b>_</b> and <b>_</b>'
assert_not_contains "$pair" '<em>'
@@ -109,7 +117,7 @@ it "bold still works"
assert_contains "$(md_render 'Real __bold__ here.')" '<strong>bold</strong>'
it "emphasis spanning a tag still works"
-assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>'
+assert_contains "$(md_unsafe 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more</em>'
it "intraword underscores are still literal"
assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
diff --git a/test/units.sh b/test/units.sh
index 5f965a6..539d164 100755
--- a/test/units.sh
+++ b/test/units.sh
@@ -16,6 +16,7 @@ suite "markdown: intraword underscores"
# paragraph carries an anchor -- that has tests of its own, and pinning a
# content hash in every one of these would make them unreadable and would
# break them all again on any wording change.
+md_unsafe() { printf '%s\n' "$1" | "$AWK" -v UNSAFE_HTML=1 -f "$REPO_ROOT/markdown.awk"; }
md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/markdown.awk" \
| "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; }
@@ -356,12 +357,18 @@ After.')" "<p>After.</p>"
it "a pipe in prose is not a table"
assert_not_contains "$(md 'a | b in ordinary prose')" "<table>"
-# Markdown deliberately allows inline HTML, in cells as in prose.
-# multimarkdown 6.6.0 emits <th> <b>x</b> </th> for this, and this parser's
-# prose handling passes raw HTML through too, so a cell must not differ.
-it "raw html in a cell passes through, as in prose"
+# A cell must handle raw HTML exactly as prose does. That used to mean both
+# passed it through; it now means both escape it unless the caller asks for
+# passthrough. What matters is that a cell is not a hole in whichever rule is
+# in force.
+it "raw html in a cell is escaped, as in prose"
assert_contains "$(md '| <b>x</b> |
|---|
+| a |')" "<b>x</b>"
+
+it "raw html in a cell passes through when the caller asks, as in prose"
+assert_contains "$(md_unsafe '| <b>x</b> |
+|---|
| a |')" "<b>x</b>"
it "a table inside a fence is literal"
@@ -545,4 +552,95 @@ assert_contains "$(md '<https://example.com>')" '<a href="https://example.com">h
it "a quote in an autolink cannot close the attribute"
assert_contains "$(md '<https://x.com/?a="b">')" 'href="https://x.com/?a="b""'
+suite "markdown: html is escaped unless the caller opts in"
+
+# The parser renders documents it did not write onto pages other people read,
+# and it cannot tell an author's deliberate <div> from an injected <script>.
+# So escaping is what you get without saying anything, and passthrough is what
+# you have to ask for by name -- a caller who forgets the flag is safe, and a
+# caller who wants raw HTML has said so somewhere greppable.
+
+it "a script tag is shown as text, not run"
+out=$(md '<script>alert(1)</script>')
+assert_contains "$out" '<script>'
+assert_not_contains "$out" '<script>'
+
+it "an inline tag with a handler is shown as text"
+out=$(md 'x <img src=y onerror=alert(1)>')
+assert_not_contains "$out" '<img'
+
+it "a block-level tag is shown as text"
+out=$(md '<div class="x">
+raw
+</div>')
+assert_not_contains "$out" '<div class="x">'
+
+it "the flag brings passthrough back"
+assert_contains "$(md_unsafe 'a <b>bold</b>')" '<b>bold</b>'
+
+it "the flag does not disable attribute escaping"
+# The flag is about whole tags the author wrote. A quote breaking out of an
+# attribute the parser built is never something a caller asked for.
+assert_not_contains "$(md_unsafe 'x[^f" onclick="alert(1)]')" '" onclick="alert(1)"'
+
+suite "markdown: attributes cannot be broken out of"
+
+# Each of these put user text into an attribute without escaping the quote, so
+# the quote closed the attribute early and what followed became a live handler.
+
+it "a citation id cannot carry a handler"
+out=$(md 'x[#c" onclick="alert(1)]
+
+[#c" onclick="alert(1)]: A source')
+assert_not_contains "$out" '" onclick="alert(1)"'
+assert_contains "$out" 'data-citation="c"'
+
+it "a footnote reference cannot carry a handler"
+out=$(md 'x[^f" onclick="alert(1)]
+
+[^f" onclick="alert(1)]: note')
+assert_not_contains "$out" '" onclick="alert(1)"'
+
+it "a footnote list entry cannot carry a handler"
+# The marker and the list entry are built separately; both take the key.
+out=$(md 'a[^k" onclick="alert(1)]
+
+[^k" onclick="alert(1)]: note')
+assert_not_contains "$out" 'id="fn-k" onclick='
+
+it "a fence language cannot carry a handler"
+out=$(md '```x" onload="alert(1)
+code
+```')
+assert_not_contains "$out" '" onload="alert(1)"'
+
+suite "markdown: urls that execute are dropped"
+
+it "a javascript: link is emptied"
+assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:'
+
+it "case does not smuggle it past"
+assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)'
+
+it "leading whitespace does not smuggle it past"
+assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)'
+
+it "a data: image is emptied"
+assert_not_contains "$(md '')" 'src="data:'
+
+it "vbscript: is dropped too"
+assert_not_contains "$(md '[z](vbscript:msgbox(1))')" 'href="vbscript:'
+
+it "a reference definition cannot hide one"
+assert_not_contains "$(md '[a][r]
+
+[r]: javascript:alert(1)')" 'href="javascript:'
+
+it "an ordinary link is untouched"
+assert_contains "$(md '[ok](https://example.com)')" 'href="https://example.com"'
+
+it "a relative link is untouched"
+assert_contains "$(md '[ok](/local/page)')" 'href="/local/page"'
+
summary
+