@@ -393,3 +393,51 @@ def test_possibly_invalid_url_without_whitelist(self):
393393 self .assertEqual (len (w ), 0 )
394394 self .assertNotIn ("google.com" , result )
395395 self .assertNotIn ("example.com" , result )
396+
397+ def test_unicode_escape_in_style (self ):
398+ # Test that CSS Unicode escapes are properly decoded before security checks
399+ # This prevents attackers from bypassing filters using escape sequences
400+ # CSS escape syntax: \HHHHHH where H is a hex digit (1-6 digits)
401+
402+ # Test inline style attributes (requires safe_attrs_only=False)
403+ cleaner = Cleaner (safe_attrs_only = False )
404+ inline_style_cases = [
405+ # \6a\61\76\61\73\63\72\69\70\74 = "javascript"
406+ ('<div style="background: url(\\ 6a\\ 61\\ 76\\ 61\\ 73\\ 63\\ 72\\ 69\\ 70\\ 74:alert(1))">test</div>' , '<div>test</div>' ),
407+ # \69 = 'i', so \69mport = "import"
408+ ('<div style="@\\ 69mport url(evil.css)">test</div>' , '<div>test</div>' ),
409+ # \69 with space after = 'i', space consumed as part of escape
410+ ('<div style="@\\ 69 mport url(evil.css)">test</div>' , '<div>test</div>' ),
411+ # \65\78\70\72\65\73\73\69\6f\6e = "expression"
412+ ('<div style="\\ 65\\ 78\\ 70\\ 72\\ 65\\ 73\\ 73\\ 69\\ 6f\\ 6e(alert(1))">test</div>' , '<div>test</div>' ),
413+ ]
414+
415+ for html , expected in inline_style_cases :
416+ with self .subTest (html = html ):
417+ cleaned = cleaner .clean_html (html )
418+ self .assertEqual (expected , cleaned )
419+
420+ # Test <style> tag content (uses default clean_html)
421+ style_tag_cases = [
422+ # Unicode-escaped "javascript:" in url()
423+ '<style>url(\\ 6a\\ 61\\ 76\\ 61\\ 73\\ 63\\ 72\\ 69\\ 70\\ 74:alert(1))</style>' ,
424+ # Unicode-escaped "javascript:" without url()
425+ '<style>\\ 6a\\ 61\\ 76\\ 61\\ 73\\ 63\\ 72\\ 69\\ 70\\ 74:alert(1)</style>' ,
426+ # Unicode-escaped "expression"
427+ '<style>\\ 65\\ 78\\ 70\\ 72\\ 65\\ 73\\ 73\\ 69\\ 6f\\ 6e(alert(1))</style>' ,
428+ # Unicode-escaped @import with 'i'
429+ '<style>@\\ 69mport url(evil.css)</style>' ,
430+ # Unicode-escaped "data:" scheme
431+ '<style>url(\\ 64\\ 61\\ 74\\ 61:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+)</style>' ,
432+ # Space after escape is consumed: \69 mport = "import"
433+ '<style>@\\ 69 mport url(evil.css)</style>' ,
434+ # 6-digit escape: \000069 = 'i'
435+ '<style>@\\ 000069mport url(evil.css)</style>' ,
436+ # 6-digit escape with space
437+ '<style>@\\ 000069 mport url(evil.css)</style>' ,
438+ ]
439+
440+ for html in style_tag_cases :
441+ with self .subTest (html = html ):
442+ cleaned = clean_html (html )
443+ self .assertEqual ('<div><style>/* deleted */</style></div>' , cleaned )
0 commit comments