Skip to content

Commit 05f7dc1

Browse files
fzipiclaude
andauthored
fix: decode ES2015+ \u{...} extended unicode escapes in jsDecode (#1657)
* fix: decode ES2015+ \u{...} extended unicode escapes in jsDecode doJsDecode only recognized \uHHHH (exactly 4 hex digits). The \u{H...H} extended code point escape (1-6 hex digits in braces), supported by every modern JS engine since ES2015, fell through to the generic escape branch: the backslash was dropped and the literal "u" kept, leaving the rest ("{H...H}") uncorrected in the output -- so a keyword spelled with \u{...} escapes (e.g. \u{61}\u{6c}\u{65}\u{72}\u{74} for "alert") never got decoded at all. See #1653 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: fold full-width ASCII in \u{...} regardless of leading zeros The full-width-ASCII fold only applied when the escape had exactly 4 hex digits, so a leading-zero encoding of the same value -- \u{0ff01} or \u{00ff01}, both numerically U+FF01 -- skipped the fold entirely and decoded to the raw low byte instead of '!'. Trivially defeats the fold's purpose (normalizing fullwidth-character evasion). Now computes the fully resolved code point (same approach as cssDecode's fix in #1658) and checks the fold range against that value directly, independent of digit count or leading zeros. Found by CodeRabbit review on this PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8639d3e commit 05f7dc1

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

internal/transformations/js_decode.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,41 @@ func doJsDecode(input string, pos int) (string, bool) {
3333
if input[i] == '\\' {
3434
/* Character is an escape. */
3535

36+
/* Measured once here rather than in the case guard below, which
37+
* would otherwise have to repeat the scan to recover the length. */
38+
extLen := 0
39+
if (i+2 < inputLen) && (input[i+1] == 'u') && (input[i+2] == '{') {
40+
extLen = jsExtendedUnicodeEscapeLen(input, i+3)
41+
}
42+
3643
switch {
3744

45+
case extLen > 0:
46+
/* \u{H...H} - ES2015+ extended Unicode code point escape
47+
* (1-6 hex digits). Handled the same way as \uHHHH below:
48+
* lower byte of the value, with the same full-width-ASCII
49+
* fold -- checked against the fully resolved value, not a
50+
* fixed digit count, so a leading-zero encoding (e.g.
51+
* \u{0ff01}) folds the same as \u{ff01}. */
52+
j := extLen
53+
54+
var code rune
55+
for k := 0; k < j; k++ {
56+
code = code<<4 | rune(xsingle2c(input[i+3+k]))
57+
}
58+
59+
/* Use only the lower byte. */
60+
d[c] = byte(code)
61+
changed = true
62+
63+
/* Full width ASCII (ff01 - ff5e) needs 0x20 added */
64+
if (code >= 0xff01) && (code <= 0xff5e) {
65+
d[c] += 0x20
66+
}
67+
68+
c++
69+
i += j + 4 // '\', 'u', '{', j hex digits, '}'
70+
3871
case (i+5 < inputLen) && (input[i+1] == 'u') && (utils.ValidHex(input[i+2])) && (utils.ValidHex(input[i+3])) && (utils.ValidHex(input[i+4])) && (utils.ValidHex(input[i+5])):
3972
/* \uHHHH */
4073

@@ -131,3 +164,18 @@ func doJsDecode(input string, pos int) (string, bool) {
131164
func isodigit(x byte) bool {
132165
return (x >= '0') && (x <= '7')
133166
}
167+
168+
// jsExtendedUnicodeEscapeLen returns the number of hex digits (1-6) in a
169+
// \u{H...H} escape whose first hex digit is at pos, or 0 if the escape is
170+
// malformed (no digits, more than 6 digits, or missing the closing '}').
171+
func jsExtendedUnicodeEscapeLen(input string, pos int) int {
172+
inputLen := len(input)
173+
j := 0
174+
for (j < 6) && (pos+j < inputLen) && utils.ValidHex(input[pos+j]) {
175+
j++
176+
}
177+
if (j == 0) || (pos+j >= inputLen) || (input[pos+j] != '}') {
178+
return 0
179+
}
180+
return j
181+
}

internal/transformations/js_decode_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,90 @@ func TestCJSDecode(t *testing.T) {
2626
input: "\\",
2727
want: "\\",
2828
},
29+
// \u{H...H} is the ES2015+ extended Unicode code point escape (1-6
30+
// hex digits in braces), used by every modern JS engine. Before
31+
// recognizing it, doJsDecode fell through to the generic \C branch,
32+
// dropping the backslash and keeping a literal "u" while copying the
33+
// rest through unchanged -- silently failing to decode the escape at
34+
// all. See https://github.com/corazawaf/coraza/issues/1653.
35+
{
36+
input: "\\u{61}\\u{6c}\\u{65}\\u{72}\\u{74}",
37+
want: "alert",
38+
},
39+
{
40+
input: "\\u{ff01}",
41+
want: "!",
42+
},
43+
{
44+
input: "\\u{1}",
45+
want: "\x01",
46+
},
47+
{
48+
input: "\\u{41}",
49+
want: "A",
50+
},
51+
{
52+
input: "\\u{",
53+
want: "u{",
54+
},
55+
{
56+
input: "\\u{41",
57+
want: "u{41",
58+
},
59+
{
60+
input: "\\u{zz}",
61+
want: "u{zz}",
62+
},
63+
// The full-width-ASCII fold must key off the fully resolved value,
64+
// not a fixed 4-digit count -- a leading-zero encoding of the same
65+
// value (5 or 6 digits) must fold identically to the 4-digit form.
66+
{
67+
input: "\\u{0ff01}",
68+
want: "!",
69+
},
70+
{
71+
input: "\\u{00ff01}",
72+
want: "!",
73+
},
74+
{
75+
input: "\\u{0ff5e}",
76+
want: "~",
77+
},
78+
{
79+
input: "\\u{000061}",
80+
want: "a",
81+
},
82+
{
83+
// 7 hex digits exceeds the 6-digit maximum: malformed, falls
84+
// through to the generic escape handling unchanged.
85+
input: "\\u{1234567}",
86+
want: "u{1234567}",
87+
},
88+
{
89+
// Hex digits are case-insensitive, including in a leading-zero
90+
// form that still has to fold.
91+
input: "\\u{0FF5e}",
92+
want: "~",
93+
},
94+
{
95+
// Empty braces: no hex digits, so not a valid extended escape.
96+
// Falls through to generic escape handling, dropping the
97+
// backslash and leaving the braces literal.
98+
input: "\\u{}",
99+
want: "u{}",
100+
},
101+
{
102+
// A well-formed escape for U+0000 decodes to a NUL byte rather
103+
// than being treated as malformed.
104+
input: "\\u{0}",
105+
want: "\x00",
106+
},
107+
{
108+
// Extended and classic 4-digit escapes must decode in the same
109+
// pass without the extended form consuming the one after it.
110+
input: "\\u{41}\\u0042",
111+
want: "AB",
112+
},
29113
}
30114

31115
for _, tc := range tests {
@@ -50,6 +134,7 @@ func BenchmarkJSDecode(b *testing.B) {
50134
"",
51135
"hello world",
52136
"\\a\\b\\f\\n\\r\\t\\v\\u0000\\?\\'\\\"\\0\\12\\123\\x00\\xff",
137+
"\\u{61}\\u{6c}\\u{65}\\u{72}\\u{74}",
53138
}
54139

55140
for _, tc := range tests {

0 commit comments

Comments
 (0)