ssh git@grit.deadkey.org man

Commit e518a91 h

Pat Ambrosio · 26-09-05 10:19

Escape by default, and close the attribute and URL holes

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 -- "java&#115;cript:" 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(/&#0*9;|&#0*10;|&#0*13;|&tab;|&newline;/, "", probe)
28 gsub(/&#0*58;|&colon;|%3a/, ":", probe)
29 gsub(/&#0*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(">", "\\&gt;", t); 37 gsub(">", "\\&gt;", 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 &quot; -- 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 "&lt;" nextil(t2); 156 return t1 "&lt;" 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\">&#160;&#8617;&#xfe0e;</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\">&#160;&#8617;&#xfe0e;</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\">&#x21A9;&#xFE0E;</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\">&#x21A9;&#xFE0E;</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 |')" "&lt;b&gt;x&lt;/b&gt;"
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=&quot;b&quot;"' 553 assert_contains "$(md '<https://x.com/?a="b">')" 'href="https://x.com/?a=&quot;b&quot;"'
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" '&lt;script&gt;'
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&quot;'
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 '![y](data:text/html,xx)')" '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
    "java&#115;cript:" 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 &quot; 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 -- "java&#115;cript:" 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(/&#0*9;|&#0*10;|&#0*13;|&tab;|&newline;/, "", probe)
+	gsub(/&#0*58;|&colon;|%3a/, ":", probe)
+	gsub(/&#0*x?[0-9a-f]+;?/, "", probe)
+	if (probe ~ /^(javascript|data|vbscript|file):/)
+		return ""
+	return u
+}
+
 function escattr(t) {
 	t = eschtml(t);
 	gsub(">", "\\&gt;", 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 &quot; -- 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 "&lt;" 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\">&#160;&#8617;&#xfe0e;</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\">&#160;&#8617;&#xfe0e;</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\">&#x21A9;&#xFE0E;</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\">&#x21A9;&#xFE0E;</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 |')" "&lt;b&gt;x&lt;/b&gt;"
+
+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=&quot;b&quot;"'
 
+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" '&lt;script&gt;'
+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&quot;'
+
+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 '![y](data:text/html,xx)')" '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
+