ssh git@grit.deadkey.org man

Commit 9700190 h

Pat Ambrosio · 26-09-05 10:29

Decode percent escapes before deciding what scheme a URL holds

Changes: 2 files +95 -9

Changes (Side-by-Side)

markdown.awk +56 -9

15 # output is served to other people cannot tell a deliberate one from an 15 # output is served to other people cannot tell a deliberate one from an
16 # injected one. 16 # injected one.
17 # 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) {
18 # The check is made on a throwaway copy, never on the URL that is emitted.
19 # The copy is lowercased, stripped of leading control characters and
20 # whitespace, has the HTML entities that could hide a colon decoded, and is
21 # percent-decoded until no escape remains. A browser decodes an href before it
22 # decides what scheme it holds, so the check has to read what the browser will
23 # read: "%6a%61%76%61%73%63%72%69%70%74:", "%6aavascript:" and the
24 # double-encoded "%256a..." are all javascript: written to slip past a test
25 # that reads the raw text.
26 #
27 # Only the copy is decoded. A legitimate %20 in a path must survive into the
28 # output untouched, and it does, because the URL that is returned is the one
29 # that came in -- once a scheme is rejected the whole URL is dropped anyway.
30 function pct_decode(t, out, hex, lo, hi, HEX) {
31 # One pass of %xx -> the byte it names, without strtonum: that is a gawk
32 # extension and this has to run under any awk. index() into a table of
33 # the sixteen digits gives the nibble values.
34 HEX = "0123456789abcdef"
35 out = ""
36 while (match(t, /%[0-9a-f][0-9a-f]/)) {
37 hi = index(HEX, substr(t, RSTART + 1, 1)) - 1
38 lo = index(HEX, substr(t, RSTART + 2, 1)) - 1
39 out = out substr(t, 1, RSTART - 1) sprintf("%c", hi * 16 + lo)
40 t = substr(t, RSTART + 3)
41 }
42 return out t
43 }
44
45 function sanitize_url(u, probe, prev) {
25 probe = tolower(u) 46 probe = tolower(u)
26 gsub(/[\000-\040]/, "", probe) 47 gsub(/[\000-\040]/, "", probe)
27 gsub(/&#0*9;|&#0*10;|&#0*13;|&tab;|&newline;/, "", probe) 48 gsub(/&#0*9;|&#0*10;|&#0*13;|&tab;|&newline;/, "", probe)
28 gsub(/&#0*58;|:|%3a/, ":", probe)
49 gsub(/&#0*58;|:/, ":", probe)
29 gsub(/&#0*x?[0-9a-f]+;?/, "", probe) 50 gsub(/&#0*x?[0-9a-f]+;?/, "", probe)
51 # Until it stops changing, so "%256a" -- a percent sign that is itself
52 # encoded -- is decoded the second time round rather than left half done.
53 do {
54 prev = probe
55 probe = pct_decode(probe)
56 gsub(/[\000-\040]/, "", probe)
57 } while (probe != prev)
30 if (probe ~ /^(javascript|data|vbscript|file):/) 58 if (probe ~ /^(javascript|data|vbscript|file):/)
31 return "" 59 return ""
32 return u 60 return u
33 } 61 }
34 62
63 # close_quotes
64 # Emits the closing tag for any blockquote still open, and forgets it.
65 #
66 # The rules that build a fence or a table finish with next, which returns
67 # before the loop further down that closes open blocks -- so a fence or a
68 # table written after a blockquote was swallowed into it: the paragraph
69 # closed, the quote did not, and the block rendered inside the quote it should
70 # have followed. Those rules close it themselves, here, rather than leaving a
71 # level set for a loop they never reach.
72 function close_quotes( i) {
73 for(i = nl; i > 0 && block[i] == "blockquote"; i--)
74 oprint("</blockquote>");
75 if(i < nl)
76 nl = i;
77 }
78
35 function escattr(t) { 79 function escattr(t) {
36 t = eschtml(t); 80 t = eschtml(t);
37 gsub(">", "\\&gt;", t); 81 gsub(">", "\\&gt;", t);
tbl_pending != "" {
721 tbl_align[ai] = table_align(align_spec[ai]); 765 tbl_align[ai] = table_align(align_spec[ai]);
722 if(text) 766 if(text)
723 printp(par ? par : "p"); 767 printp(par ? par : "p");
768 close_quotes();
724 oprint("<div class=\"table-scroll\">"); 769 oprint("<div class=\"table-scroll\">");
725 oprint("<table>"); 770 oprint("<table>");
726 oprint("<thead>"); 771 oprint("<thead>");
fence {
811 sub(/^[ \t]+/, "", info); 856 sub(/^[ \t]+/, "", info);
812 sub(/[ \t].*$/, "", info); 857 sub(/[ \t].*$/, "", info);
813 858
814 # Close anything currently open so the block is not nested inside a <p>.
859 # Close anything currently open so the block is not nested inside a <p>,
860 # or inside a blockquote it merely follows.
815 if(text) 861 if(text)
816 printp(par ? par : "p"); 862 printp(par ? par : "p");
817 if(code){ 863 if(code){
818 oprint(code_held "</code></pre>"); code_held = ""; 864 oprint(code_held "</code></pre>"); code_held = "";
819 code = 0; 865 code = 0;
820 } 866 }
867 close_quotes();
821 868
822 fence = 1; 869 fence = 1;
823 fence_first = 1; 870 fence_first = 1;

test/units.sh +39 -0

assert_contains "$(md '[ok](https://example.com)')" 'href="https://example.com"'
642 it "a relative link is untouched" 642 it "a relative link is untouched"
643 assert_contains "$(md '[ok](/local/page)')" 'href="/local/page"' 643 assert_contains "$(md '[ok](/local/page)')" 'href="/local/page"'
644 644
645 suite "markdown: percent-encoded schemes are decoded before the check"
646
647 # A browser percent-decodes an href before it decides what scheme the URL
648 # holds, so a check that reads the raw text reads something the browser never
649 # sees. Every one of these was emitted verbatim and ran on click.
650
651 it "a fully encoded scheme is caught"
652 assert_not_contains "$(md '[x](%6a%61%76%61%73%63%72%69%70%74:alert(1))')" '%6a%61%76%61'
653
654 it "a partly encoded scheme is caught"
655 assert_not_contains "$(md '[x](%6aavascript:alert(1))')" '%6aavascript'
656
657 it "a double-encoded scheme is caught"
658 # %256a is a percent sign that is itself encoded: one pass leaves %6a, so the
659 # decode has to run until the string stops changing.
660 assert_not_contains "$(md '[x](%256a%61%76%61%73%63%72%69%70%74:alert(1))')" 'alert(1)'
661
662 it "an encoded data: image is caught"
663 assert_not_contains "$(md '![y](%64%61%74%61:text/html,x)')" '%64%61%74%61'
664
665 it "a reference definition cannot hide an encoded scheme"
666 assert_not_contains "$(md '[a][r]
667
668 [r]: %6a%61%76%61%73%63%72%69%70%74:alert(1)')" 'alert(1)'
669
670 it "a legitimate percent escape survives into the output"
671 # The decode is done on a throwaway copy read only by the scheme test. The URL
672 # that is emitted is the one the author wrote, so a space encoded in a path is
673 # still encoded when it gets to the browser.
674 assert_contains "$(md '[ok](https://example.com/a%20b)')" 'href="https://example.com/a%20b"'
675
676 it "a percent escape in an image path survives too"
677 assert_contains "$(md '![i](/i/a%20b.png)')" 'src="/i/a%20b.png"'
678
679 it "the earlier vectors are still blocked"
680 assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:'
681 assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)'
682 assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)'
683
645 summary 684 summary
646 685

Raw

commit 970019084b963d45fed337993aa819830f649602
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Sat Sep 5 10:29:28 2026 +0800

    Decode percent escapes before deciding what scheme a URL holds
    
    A browser percent-decodes an href before it works out which scheme the URL
    names, so a check that reads the raw text is reading something the browser
    never sees. Every one of these was emitted verbatim and ran on click:
    
        [x](%6a%61%76%61%73%63%72%69%70%74:alert(1))
        [x](%6aavascript:alert(1))
        [x](%256a%61%76%61%73%63%72%69%70%74:alert(1))
    
    The probe is decoded until it stops changing, so a percent sign that is
    itself encoded is decoded on the second pass rather than left half done.
    
    Only the probe. The URL that is emitted is the one the author wrote, so a
    space encoded in a path is still encoded when it reaches the browser --
    %20 in a real link must survive, and it is the throwaway copy that gets
    decoded, not the output.
    
    The decode is written with an index() lookup rather than strtonum(): that
    is a gawk extension, and jenny falls back to whatever awk is on the
    machine.
    
    Also fixes a table or a fenced block written after a blockquote being
    swallowed into it. Those rules end with next, which returns before the
    loop that closes open blocks, so the paragraph closed and the quote did
    not. They close it themselves now. A fence inside a quote is still not
    supported -- that is older than this and unchanged.

diff --git a/markdown.awk b/markdown.awk
index 692da99..1be3cf5 100755
--- a/markdown.awk
+++ b/markdown.awk
@@ -15,23 +15,67 @@
 # 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) {
+# The check is made on a throwaway copy, never on the URL that is emitted.
+# The copy is lowercased, stripped of leading control characters and
+# whitespace, has the HTML entities that could hide a colon decoded, and is
+# percent-decoded until no escape remains. A browser decodes an href before it
+# decides what scheme it holds, so the check has to read what the browser will
+# read: "%6a%61%76%61%73%63%72%69%70%74:", "%6aavascript:" and the
+# double-encoded "%256a..." are all javascript: written to slip past a test
+# that reads the raw text.
+#
+# Only the copy is decoded. A legitimate %20 in a path must survive into the
+# output untouched, and it does, because the URL that is returned is the one
+# that came in -- once a scheme is rejected the whole URL is dropped anyway.
+function pct_decode(t,   out, hex, lo, hi, HEX) {
+	# One pass of %xx -> the byte it names, without strtonum: that is a gawk
+	# extension and this has to run under any awk. index() into a table of
+	# the sixteen digits gives the nibble values.
+	HEX = "0123456789abcdef"
+	out = ""
+	while (match(t, /%[0-9a-f][0-9a-f]/)) {
+		hi = index(HEX, substr(t, RSTART + 1, 1)) - 1
+		lo = index(HEX, substr(t, RSTART + 2, 1)) - 1
+		out = out substr(t, 1, RSTART - 1) sprintf("%c", hi * 16 + lo)
+		t = substr(t, RSTART + 3)
+	}
+	return out t
+}
+
+function sanitize_url(u,   probe, prev) {
 	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*58;|&colon;/, ":", probe)
 	gsub(/&#0*x?[0-9a-f]+;?/, "", probe)
+	# Until it stops changing, so "%256a" -- a percent sign that is itself
+	# encoded -- is decoded the second time round rather than left half done.
+	do {
+		prev = probe
+		probe = pct_decode(probe)
+		gsub(/[\000-\040]/, "", probe)
+	} while (probe != prev)
 	if (probe ~ /^(javascript|data|vbscript|file):/)
 		return ""
 	return u
 }
 
+# close_quotes
+# Emits the closing tag for any blockquote still open, and forgets it.
+#
+# The rules that build a fence or a table finish with next, which returns
+# before the loop further down that closes open blocks -- so a fence or a
+# table written after a blockquote was swallowed into it: the paragraph
+# closed, the quote did not, and the block rendered inside the quote it should
+# have followed. Those rules close it themselves, here, rather than leaving a
+# level set for a loop they never reach.
+function close_quotes(   i) {
+	for(i = nl; i > 0 && block[i] == "blockquote"; i--)
+		oprint("</blockquote>");
+	if(i < nl)
+		nl = i;
+}
+
 function escattr(t) {
 	t = eschtml(t);
 	gsub(">", "\\&gt;", t);
@@ -721,6 +765,7 @@ tbl_pending != "" {
 			tbl_align[ai] = table_align(align_spec[ai]);
 		if(text)
 			printp(par ? par : "p");
+		close_quotes();
 		oprint("<div class=\"table-scroll\">");
 		oprint("<table>");
 		oprint("<thead>");
@@ -811,13 +856,15 @@ fence {
 	sub(/^[ \t]+/, "", info);
 	sub(/[ \t].*$/, "", info);
 
-	# Close anything currently open so the block is not nested inside a <p>.
+	# Close anything currently open so the block is not nested inside a <p>,
+	# or inside a blockquote it merely follows.
 	if(text)
 		printp(par ? par : "p");
 	if(code){
 		oprint(code_held "</code></pre>"); code_held = "";
 		code = 0;
 	}
+	close_quotes();
 
 	fence = 1;
 	fence_first = 1;
diff --git a/test/units.sh b/test/units.sh
index 539d164..188013b 100755
--- a/test/units.sh
+++ b/test/units.sh
@@ -642,5 +642,44 @@ 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"'
 
+suite "markdown: percent-encoded schemes are decoded before the check"
+
+# A browser percent-decodes an href before it decides what scheme the URL
+# holds, so a check that reads the raw text reads something the browser never
+# sees. Every one of these was emitted verbatim and ran on click.
+
+it "a fully encoded scheme is caught"
+assert_not_contains "$(md '[x](%6a%61%76%61%73%63%72%69%70%74:alert(1))')" '%6a%61%76%61'
+
+it "a partly encoded scheme is caught"
+assert_not_contains "$(md '[x](%6aavascript:alert(1))')" '%6aavascript'
+
+it "a double-encoded scheme is caught"
+# %256a is a percent sign that is itself encoded: one pass leaves %6a, so the
+# decode has to run until the string stops changing.
+assert_not_contains "$(md '[x](%256a%61%76%61%73%63%72%69%70%74:alert(1))')" 'alert(1)'
+
+it "an encoded data: image is caught"
+assert_not_contains "$(md '![y](%64%61%74%61:text/html,x)')" '%64%61%74%61'
+
+it "a reference definition cannot hide an encoded scheme"
+assert_not_contains "$(md '[a][r]
+
+[r]: %6a%61%76%61%73%63%72%69%70%74:alert(1)')" 'alert(1)'
+
+it "a legitimate percent escape survives into the output"
+# The decode is done on a throwaway copy read only by the scheme test. The URL
+# that is emitted is the one the author wrote, so a space encoded in a path is
+# still encoded when it gets to the browser.
+assert_contains "$(md '[ok](https://example.com/a%20b)')" 'href="https://example.com/a%20b"'
+
+it "a percent escape in an image path survives too"
+assert_contains "$(md '![i](/i/a%20b.png)')" 'src="/i/a%20b.png"'
+
+it "the earlier vectors are still blocked"
+assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:'
+assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)'
+assert_not_contains "$(md '[x](  javascript:alert(1))')" 'alert(1)'
+
 summary