Commit 9700190 h
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(/�*9;|�*10;|�*13;|&tab;|&newline;/, "", probe) |
48 | gsub(/�*9;|�*10;|�*13;|&tab;|&newline;/, "", probe) |
| 28 | gsub(/�*58;|:|%3a/, ":", probe) |
||
| 49 | gsub(/�*58;|:/, ":", probe) |
||
| 29 | gsub(/�*x?[0-9a-f]+;?/, "", probe) |
50 | gsub(/�*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(">", "\\>", t); |
81 | gsub(">", "\\>", 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 '')" '%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 '')" '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 -- "javascript:" 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(/�*9;|�*10;|�*13;|&tab;|&newline;/, "", probe)
- gsub(/�*58;|:|%3a/, ":", probe)
+ gsub(/�*58;|:/, ":", probe)
gsub(/�*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(">", "\\>", 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 '')" '%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 '')" '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