Commit ba20446 h
Changes: 2 files +57 -6
Changes (Side-by-Side)
markdown.awk +19 -6
| |||
| 27 | # Only the copy is decoded. A legitimate %20 in a path must survive into the |
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 |
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. |
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) { |
||
| 30 | function pct_decode(t, out, lo, hi, HEX) { |
||
| 31 | # One pass of %xx -> the byte it names, without strtonum: that is a gawk |
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 |
32 | # extension and this has to run under any awk. index() into a table of |
| 33 | # the sixteen digits gives the nibble values. |
||
| 33 | # the sixteen digits gives the nibble values, and index() is |
||
| 34 | # case-sensitive, so the digits are folded before the lookup rather than |
||
| 35 | # the table being widened. |
||
| 36 | # |
||
| 37 | # The pair is matched in either case even though the caller folds the |
||
| 38 | # whole string first: a decoder that only reads %6a and not %6A is wrong |
||
| 39 | # on its own terms, whatever its callers happen to do. |
||
| 34 | HEX = "0123456789abcdef" |
40 | HEX = "0123456789abcdef" |
| 35 | out = "" |
41 | 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 |
||
| 42 | while (match(t, /%[0-9a-fA-F][0-9a-fA-F]/)) { |
||
| 43 | hi = index(HEX, tolower(substr(t, RSTART + 1, 1))) - 1 |
||
| 44 | lo = index(HEX, tolower(substr(t, RSTART + 2, 1))) - 1 |
||
| 39 | out = out substr(t, 1, RSTART - 1) sprintf("%c", hi * 16 + lo) |
45 | out = out substr(t, 1, RSTART - 1) sprintf("%c", hi * 16 + lo) |
| 40 | t = substr(t, RSTART + 3) |
46 | t = substr(t, RSTART + 3) |
| 41 | } |
47 | } |
function sanitize_url(u, probe, prev) { | |||
| 50 | gsub(/�*x?[0-9a-f]+;?/, "", probe) |
56 | gsub(/�*x?[0-9a-f]+;?/, "", probe) |
| 51 | # Until it stops changing, so "%256a" -- a percent sign that is itself |
57 | # 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. |
58 | # encoded -- is decoded the second time round rather than left half done. |
| 59 | # |
||
| 60 | # Folded again after each pass, not only at the start. Decoding reveals |
||
| 61 | # bytes that were not in the text to fold the first time: "%4a" is |
||
| 62 | # lowercase already and decodes to "J", so "%4Aavascript:" became |
||
| 63 | # "Javascript:" -- past a check written in lowercase, and a scheme a |
||
| 64 | # browser reads case-insensitively. The fold has to follow the decode |
||
| 65 | # that produced the letter. |
||
| 53 | do { |
66 | do { |
| 54 | prev = probe |
67 | prev = probe |
| 55 | probe = pct_decode(probe) |
||
| 68 | probe = tolower(pct_decode(probe)) |
||
| 56 | gsub(/[\000-\040]/, "", probe) |
69 | gsub(/[\000-\040]/, "", probe) |
| 57 | } while (probe != prev) |
70 | } while (probe != prev) |
| 58 | if (probe ~ /^(javascript|data|vbscript|file):/) |
71 | if (probe ~ /^(javascript|data|vbscript|file):/) |
test/units.sh +38 -0
assert_not_contains "$(md '[x](javascript:alert(1))')" 'href="javascript:' | |||
| 681 | assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)' |
681 | assert_not_contains "$(md '[x](JaVaScRiPt:alert(1))')" 'alert(1)' |
| 682 | assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)' |
682 | assert_not_contains "$(md '[x]( javascript:alert(1))')" 'alert(1)' |
| 683 | |
683 | |
| 684 | suite "markdown: decoding reveals letters that must be folded" |
||
| 685 | |
||
| 686 | # The probe was lowercased once, before decoding. Decoding then produced |
||
| 687 | # letters that had never been folded -- "%4a" is lowercase already and decodes |
||
| 688 | # to "J" -- so "%4Aavascript:" arrived at the check as "Javascript:" and did |
||
| 689 | # not match a pattern written in lowercase. A browser reads a scheme |
||
| 690 | # case-insensitively, so it ran. The fold has to follow the decode that |
||
| 691 | # produced the letter, on every pass. |
||
| 692 | |
||
| 693 | it "an uppercase escape at the start is caught" |
||
| 694 | assert_not_contains "$(md '[x](%4Aavascript:alert(1))')" '%4Aavascript' |
||
| 695 | |
||
| 696 | it "a mixed-case pair of escapes is caught" |
||
| 697 | assert_not_contains "$(md '[x](%4a%61vascript:alert(1))')" 'alert(1)' |
||
| 698 | |
||
| 699 | it "an uppercase escape in the middle is caught" |
||
| 700 | assert_not_contains "$(md '[x](jav%41script:alert(1))')" 'jav%41script' |
||
| 701 | |
||
| 702 | it "an uppercase escape in another scheme is caught" |
||
| 703 | assert_not_contains "$(md '[x](%56%42script:alert(1))')" 'alert(1)' |
||
| 704 | |
||
| 705 | it "uppercase hex in a real path is untouched" |
||
| 706 | # The decoder reads either case now, but it reads a throwaway copy. What is |
||
| 707 | # emitted is what the author wrote, so an uppercase escape in a genuine path |
||
| 708 | # survives byte for byte. |
||
| 709 | assert_contains "$(md '[ok](https://example.com/A%2FB)')" 'href="https://example.com/A%2FB"' |
||
| 710 | |
||
| 711 | it "a utf-8 sequence in a path is untouched" |
||
| 712 | assert_contains "$(md '[ok](https://example.com/%E2%9C%93)')" 'href="https://example.com/%E2%9C%93"' |
||
| 713 | |
||
| 714 | it "the decoder reads an uppercase pair on its own terms" |
||
| 715 | # sanitize_url folds before decoding, so the decoder is never handed |
||
| 716 | # uppercase hex in practice and narrowing this class back passes every other |
||
| 717 | # test here. It is still wrong: a decoder that reads %6a and not %6A is |
||
| 718 | # incorrect independently of what its one caller happens to do, and the next |
||
| 719 | # caller may not fold first. Exercised directly for that reason. |
||
| 720 | out=$(printf '%%4Aavascript\n' | "$AWK" -f "$REPO_ROOT/markdown.awk" -e 'BEGIN{ print pct_decode("%4Aavascript") }' 2>/dev/null </dev/null) |
||
| 721 | if [ "$out" = "Javascript" ]; then pass; else fail "decoder gave [$out], wanted [Javascript]"; fi |
||
| 684 | summary |
722 | summary |
| 685 | |
723 | |
Raw
commit ba20446165a74a95e64950d20bd3edb2a1e804d3
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Sat Sep 5 10:46:04 2026 +0800
Fold the probe after each decode, not only before the first
"%4Aavascript:alert(1)" survived and ran on click. Not because the
decoder could not read uppercase hex -- the probe is lowercased before
decoding, so it arrives as "%4a" and decodes fine. The problem is what it
decodes to: "J". A letter that was not in the text when it was folded,
now sitting in front of a check written in lowercase, in a scheme a
browser reads case-insensitively.
The fold follows the decode now, on every pass, so a letter revealed by
decoding is folded before it is read. That is the fix: removing it fails
four tests.
The hex class is widened to accept A-F as well, which changes nothing
today -- the caller folds first, so narrowing it back passes every test
that goes through sanitize_url. It is still wrong for a decoder to read
%6a and not %6A, independently of what its one caller happens to do, so
there is a test that calls it directly rather than leaving the line
unexercised.
Unchanged, and checked: only the throwaway probe is decoded. An uppercase
escape in a real path -- %2F, %3D, a UTF-8 sequence -- reaches the output
byte for byte.
diff --git a/markdown.awk b/markdown.awk
index 1be3cf5..f001ec1 100755
--- a/markdown.awk
+++ b/markdown.awk
@@ -27,15 +27,21 @@
# 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) {
+function pct_decode(t, out, 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.
+ # the sixteen digits gives the nibble values, and index() is
+ # case-sensitive, so the digits are folded before the lookup rather than
+ # the table being widened.
+ #
+ # The pair is matched in either case even though the caller folds the
+ # whole string first: a decoder that only reads %6a and not %6A is wrong
+ # on its own terms, whatever its callers happen to do.
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
+ while (match(t, /%[0-9a-fA-F][0-9a-fA-F]/)) {
+ hi = index(HEX, tolower(substr(t, RSTART + 1, 1))) - 1
+ lo = index(HEX, tolower(substr(t, RSTART + 2, 1))) - 1
out = out substr(t, 1, RSTART - 1) sprintf("%c", hi * 16 + lo)
t = substr(t, RSTART + 3)
}
@@ -50,9 +56,16 @@ function sanitize_url(u, probe, prev) {
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.
+ #
+ # Folded again after each pass, not only at the start. Decoding reveals
+ # bytes that were not in the text to fold the first time: "%4a" is
+ # lowercase already and decodes to "J", so "%4Aavascript:" became
+ # "Javascript:" -- past a check written in lowercase, and a scheme a
+ # browser reads case-insensitively. The fold has to follow the decode
+ # that produced the letter.
do {
prev = probe
- probe = pct_decode(probe)
+ probe = tolower(pct_decode(probe))
gsub(/[\000-\040]/, "", probe)
} while (probe != prev)
if (probe ~ /^(javascript|data|vbscript|file):/)
diff --git a/test/units.sh b/test/units.sh
index 188013b..bbdd580 100755
--- a/test/units.sh
+++ b/test/units.sh
@@ -681,5 +681,43 @@ 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)'
+suite "markdown: decoding reveals letters that must be folded"
+
+# The probe was lowercased once, before decoding. Decoding then produced
+# letters that had never been folded -- "%4a" is lowercase already and decodes
+# to "J" -- so "%4Aavascript:" arrived at the check as "Javascript:" and did
+# not match a pattern written in lowercase. A browser reads a scheme
+# case-insensitively, so it ran. The fold has to follow the decode that
+# produced the letter, on every pass.
+
+it "an uppercase escape at the start is caught"
+assert_not_contains "$(md '[x](%4Aavascript:alert(1))')" '%4Aavascript'
+
+it "a mixed-case pair of escapes is caught"
+assert_not_contains "$(md '[x](%4a%61vascript:alert(1))')" 'alert(1)'
+
+it "an uppercase escape in the middle is caught"
+assert_not_contains "$(md '[x](jav%41script:alert(1))')" 'jav%41script'
+
+it "an uppercase escape in another scheme is caught"
+assert_not_contains "$(md '[x](%56%42script:alert(1))')" 'alert(1)'
+
+it "uppercase hex in a real path is untouched"
+# The decoder reads either case now, but it reads a throwaway copy. What is
+# emitted is what the author wrote, so an uppercase escape in a genuine path
+# survives byte for byte.
+assert_contains "$(md '[ok](https://example.com/A%2FB)')" 'href="https://example.com/A%2FB"'
+
+it "a utf-8 sequence in a path is untouched"
+assert_contains "$(md '[ok](https://example.com/%E2%9C%93)')" 'href="https://example.com/%E2%9C%93"'
+
+it "the decoder reads an uppercase pair on its own terms"
+# sanitize_url folds before decoding, so the decoder is never handed
+# uppercase hex in practice and narrowing this class back passes every other
+# test here. It is still wrong: a decoder that reads %6a and not %6A is
+# incorrect independently of what its one caller happens to do, and the next
+# caller may not fold first. Exercised directly for that reason.
+out=$(printf '%%4Aavascript\n' | "$AWK" -f "$REPO_ROOT/markdown.awk" -e 'BEGIN{ print pct_decode("%4Aavascript") }' 2>/dev/null </dev/null)
+if [ "$out" = "Javascript" ]; then pass; else fail "decoder gave [$out], wanted [Javascript]"; fi
summary