classification
Title: HTMLParser: comma in attribute values with/without space
Type: behavior Stage: resolved
Components: Library (Lib) Versions: Python 3.8
process
Status: closed Resolution: fixed
Dependencies: Superseder:
Assigned To: ezio.melotti Nosy List: ezio.melotti, karlcow, miss-islington, nowasky.jr, vstinner
Priority: normal Keywords: patch

Created on 2020-09-08 21:59 by nowasky.jr, last changed 2021-02-01 21:27 by ezio.melotti. This issue is now closed.

Pull Requests
URL Status Linked Edit
PR 24072 merged karlcow, 2021-01-03 08:52
PR 24415 merged miss-islington, 2021-02-01 20:33
PR 24416 merged miss-islington, 2021-02-01 20:33
Messages (15)
msg376607 - (view) Author: Ademar Nowasky Junior (nowasky.jr) Date: 2020-09-08 21:59
HTML tags that have a attribute name starting with a comma character aren't parsed and break future calls to feed(). 

The problem occurs when such attribute is the second one or later in the HTML tag. Doesn't seems to affect when it's the first attribute.

#POC:

from html.parser import HTMLParser

class MyHTMLParser(HTMLParser):
    def handle_starttag(self, tag, attrs):
        print("Encountered a start tag:", tag)

parser = MyHTMLParser()

#This is ok
parser.feed('<yyy id="poc" a,="">')

#This breaks
parser.feed('<zzz id="poc" ,a="">')

#Future calls to feed() will not work
parser.feed('<img id="poc" src=x>')
msg376635 - (view) Author: STINNER Victor (vstinner) * (Python committer) Date: 2020-09-09 13:57
HTML 5.2 specification says
https://www.w3.org/TR/html52/syntax.html#elements-attributes

"Attribute names must consist of one or more characters other than the space characters, U+0000 NULL, U+0022 QUOTATION MARK ("), U+0027 APOSTROPHE ('), U+003E GREATER-THAN SIGN (>), U+002F SOLIDUS (/), and U+003D EQUALS SIGN (=) characters, the control characters, and any characters that are not defined by Unicode."

It is confirmed in the "12.2 Parsing HTML documents" section of the "HTML Living Standard":
"""
12.2.5.33 Attribute name state

Consume the next input character:

U+0009 CHARACTER TABULATION (tab)
U+000A LINE FEED (LF)
U+000C FORM FEED (FF)
U+0020 SPACE
U+002F SOLIDUS (/)
U+003E GREATER-THAN SIGN (>)
EOF
    Reconsume in the after attribute name state.
U+003D EQUALS SIGN (=)
    Switch to the before attribute value state.
ASCII upper alpha
    Append the lowercase version of the current input character (add 0x0020 to the character's code point) to the current attribute's name.
U+0000 NULL
    This is an unexpected-null-character parse error. Append a U+FFFD REPLACEMENT CHARACTER character to the current attribute's name.
U+0022 QUOTATION MARK (")
U+0027 APOSTROPHE (')
U+003C LESS-THAN SIGN (<)
    This is an unexpected-character-in-attribute-name parse error. Treat it as per the "anything else" entry below.
Anything else
    Append the current input character to the current attribute's name.
"""
https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state

I understand that "," *is* a legit character in a HTML attribute name. So "a," and ",a" *are* valid HTML attribute names. Do I understand correctly?
msg376638 - (view) Author: Ademar Nowasky Junior (nowasky.jr) Date: 2020-09-09 14:11
Yes, I understand that in the same way. Both are valid attr names. 

Maybe it's worth noting that Javascript has no problem handling this.
msg376639 - (view) Author: STINNER Victor (vstinner) * (Python committer) Date: 2020-09-09 14:12
HTMLParser.check_for_whole_start_tag() uses locatestarttagend_tolerant regular expression to find the end of the start tag. This regex cuts the string at the first comma (","), but not if the comma is the first character of an attribute name

* '<div id="test" , color="blue">' => '<div id="test" , color="blue"': OK!
* '<div id="test" ,color="blue">' => '<div id="test" ,' => BUG

The regex is quite complex:

locatestarttagend_tolerant = re.compile(r"""
  <[a-zA-Z][^\t\n\r\f />\x00]*       # tag name
  (?:[\s/]*                          # optional whitespace before attribute name
    (?:(?<=['"\s/])[^\s/>][^\s/=>]*  # attribute name
      (?:\s*=+\s*                    # value indicator
        (?:'[^']*'                   # LITA-enclosed value
          |"[^"]*"                   # LIT-enclosed value
          |(?!['"])[^>\s]*           # bare value
         )
         (?:\s*,)*                   # possibly followed by a comma
       )?(?:\s|/(?!>))*
     )*
   )?
  \s*                                # trailing whitespace
""", re.VERBOSE)
endendtag = re.compile('>')

The problem is that this part of the regex:

#(?:\s*,)*                   # possibly followed by a comma

The comma is not seen as part of the attribute name.
msg376641 - (view) Author: STINNER Victor (vstinner) * (Python committer) Date: 2020-09-09 14:42
Also, there is no warning about security in the html.parser documentation? Is this module mature and maintained enough to be considered as reliable? Or should we warn users about possible issues on corner cases, and point to BeautilfulSoup for a more mature HTML parser?

https://docs.python.org/dev/library/html.parser.html
msg376642 - (view) Author: Ezio Melotti (ezio.melotti) * (Python committer) Date: 2020-09-09 15:20
The html.parser follows the HTML 5 specs as closely as possible.  There are a few corner cases where it behaves slightly differently but it's only while dealing with invalid markup, and the differences should be trivial and generally not worth the extra complexity to deal with them.

In this case, if I recall correctly, the way the comma is handled is just a left-over from the previous version of the parser, that predates the HTML 5 specs.  In tags like <tag foo=bar,baz=asd> there was an ambiguous situation and parsing it <tag foo="bar" baz="asd"> was deemed a reasonable interpretation, so the comma was treated as an attribute separator (and there should be test cases for this).

This likely caused the issue reported by the OP, and I think it should be fixed, even if technically it's a change in behavior and will break some of the tests.

If I'm reading the specs[0] correctly:
* <tag foo=bar,baz=asd> should be parsed as <tag foo="bar,baz=asd">, and
* <tag foo=bar ,baz=asd> should be parsed as <tag foo="bar" ,baz="asd">, where ',baz' is the attribute name


> Also, there is no warning about security in the html.parser documentation?

I'm not aware of any specific security issues, since html.parser just implements the parser described by the HTML 5 specs.  If there are any security issues caused by divergences from the specs, they should be fixed.  I'm not sure why a warning would be needed.

> Is this module mature and maintained enough to be considered as reliable?

Even though it hasn't been updated to the latest version of the specs (5.2 at the time of writing), it has been updated to implement the parsing rules described by the HTML 5 specs.  I don't know if the parsing rules changed between 5.0 and 5.2.

> Or should we warn users about possible issues on corner cases, and point to BeautilfulSoup for a more mature HTML parser?

BeautifulSoup is built on top of html.parser (and can also use other parses, like lxml).  BS uses the underlying parsers to parse the HTML, then builds the tree and provides, among other things, functions to search and edit it.
When I upgraded html.parser to HTML 5 I worked with the author of BeautifulSoup (Leonard Richardson), to make sure that my changes were compatible with BS. We also discussed about some corner cases he found and other feature requests and issues he had with the old version of the parser.  That said, a link to BS would be a nice addition, since it's a great library.


[0] starting from https://www.w3.org/TR/html52/syntax.html#tokenizer-before-attribute-name-state
msg384256 - (view) Author: karl (karlcow) * Date: 2021-01-03 08:22
Ezio,

TL,DR: Testing in browsers and adding two tests for this issue. 
       Should I create a PR just for the tests?

https://github.com/python/cpython/blame/63298930fb531ba2bb4f23bc3b915dbf1e17e9e1/Lib/test/test_htmlparser.py#L479-L485


A: comma without spaces
-----------------------


Tests for browsers:
data:text/html,<!doctype html><div class=bar,baz=asd>text</div>

Serializations:
* Firefox, Gecko (86.0a1 (2020-12-28) (64-bit)) 
* Edge, Blink (Version 89.0.752.0 (Version officielle) Canary (64 bits))
* Safari, WebKit (Release 117 (Safari 14.1, WebKit 16611.1.7.2))

Same serialization in these 3 rendering engines
<div class="bar,baz=asd">text</div>


Adding:

    def test_comma_between_unquoted_attributes(self):
        # bpo 41748
        self._run_check('<div class=bar,baz=asd>',
                        [('starttag', 'div', [('class', 'bar,baz=asd')])])


❯ ./python.exe -m test -v test_htmlparser

…
test_comma_between_unquoted_attributes (test.test_htmlparser.HTMLParserTestCase) ... ok
…

Ran 47 tests in 0.168s

OK

== Tests result: SUCCESS ==

1 test OK.

Total duration: 369 ms
Tests result: SUCCESS


So this is working as expected for the first test.


B: comma with spaces
--------------------

Tests for browsers:
data:text/html,<!doctype html><div class=bar, baz=asd>text</div>

Serializations:
* Firefox, Gecko (86.0a1 (2020-12-28) (64-bit)) 
* Edge, Blink (Version 89.0.752.0 (Version officielle) Canary (64 bits))
* Safari, WebKit (Release 117 (Safari 14.1, WebKit 16611.1.7.2))

Same serialization in these 3 rendering engines
<div class="bar" ,baz="asd">text</div>


Adding
    def test_comma_with_space_between_unquoted_attributes(self):
        # bpo 41748
        self._run_check('<div class=bar ,baz=asd>',
                        [('starttag', 'div', [
                            ('class', 'bar'),
                            (',baz', 'asd')])])


❯ ./python.exe -m test -v test_htmlparser


This is failing.

======================================================================
FAIL: test_comma_with_space_between_unquoted_attributes (test.test_htmlparser.HTMLParserTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/karl/code/cpython/Lib/test/test_htmlparser.py", line 493, in test_comma_with_space_between_unquoted_attributes
    self._run_check('<div class=bar ,baz=asd>',
  File "/Users/karl/code/cpython/Lib/test/test_htmlparser.py", line 95, in _run_check
    self.fail("received events did not match expected events" +
AssertionError: received events did not match expected events
Source:
'<div class=bar ,baz=asd>'
Expected:
[('starttag', 'div', [('class', 'bar'), (',baz', 'asd')])]
Received:
[('data', '<div class=bar ,baz=asd>')]

----------------------------------------------------------------------


I started to look into the code of parser.py which I'm not familiar (yet) with.

https://github.com/python/cpython/blob/63298930fb531ba2bb4f23bc3b915dbf1e17e9e1/Lib/html/parser.py#L42-L52

Do you have a suggestion to fix it?
msg384258 - (view) Author: karl (karlcow) * Date: 2021-01-03 08:49
Ah!

This is fixing it

diff --git a/Lib/html/parser.py b/Lib/html/parser.py
index 6083077981..ffff790666 100644
--- a/Lib/html/parser.py
+++ b/Lib/html/parser.py
@@ -44,7 +44,7 @@
       (?:\s*=+\s*                    # value indicator
         (?:'[^']*'                   # LITA-enclosed value
           |"[^"]*"                   # LIT-enclosed value
-          |(?!['"])[^>\s]*           # bare value
+          |(?!['"])[^>]*           # bare value
          )
          (?:\s*,)*                   # possibly followed by a comma
        )?(?:\s|/(?!>))*




Ran 48 tests in 0.175s

OK

== Tests result: SUCCESS ==
msg384259 - (view) Author: Ezio Melotti (ezio.melotti) * (Python committer) Date: 2021-01-03 08:49
Writing tests that verify the expected behavior is a great first step. The expected output in the tests should match the behavior described by the HTML 5 specs (which should also correspond to the browsers' behavior), and should initially fail. You can start creating a PR with only the tests, clarifying that it's a work in progress, or wait until you have the fix too.

The next step would be tweaking the regex and the code until both the new tests and all the other ones work (excluding the one with the commas you are fixing).  You can then commit the fix in the same branch and push it -- GitHub will automatically update the PR.


> Do you have a suggestion to fix it?

If you are familiar enough with regexes, you could try to figure out whether it matches the invalid attributes or not, and if not why (I took a quick look and I didn't see anything immediately wrong in the regexes).

Since the output of the failing test is [('data', '<div class=bar ,baz=asd>')], it's likely that the parser doesn't know how to handle it and passes it to one of the handle_data() in the goahead() method.  You can figure out which one is being called and see which are the if-conditions that are leading the interpreter down this path rather than the usual path where the attributes are parsed correctly.

If you have other questions let me know :)
msg384763 - (view) Author: karl (karlcow) * Date: 2021-01-10 14:04
Status: The PR should be ready and completed
https://github.com/python/cpython/pull/24072
and eventually be merged at a point. 
Thanks to ezio.melotti for the wonderful guidance.
msg386104 - (view) Author: Ezio Melotti (ezio.melotti) * (Python committer) Date: 2021-02-01 20:32
New changeset 9eb11a139fac5514d8456626806a68b3e3b7eafb by Karl Dubost in branch 'master':
bpo-41748: Handles unquoted attributes with commas (#24072)
https://github.com/python/cpython/commit/9eb11a139fac5514d8456626806a68b3e3b7eafb
msg386109 - (view) Author: STINNER Victor (vstinner) * (Python committer) Date: 2021-02-01 20:52
Oh, thank you Karl Dubost for the fix!
msg386110 - (view) Author: miss-islington (miss-islington) Date: 2021-02-01 20:53
New changeset 0869a713f21f4b2fe021d802cf18f1b1af53695f by Miss Islington (bot) in branch '3.8':
bpo-41748: Handles unquoted attributes with commas (GH-24072)
https://github.com/python/cpython/commit/0869a713f21f4b2fe021d802cf18f1b1af53695f
msg386111 - (view) Author: miss-islington (miss-islington) Date: 2021-02-01 20:54
New changeset 0874491bcc392f7bd9c394ec2fdab183e3f320dd by Miss Islington (bot) in branch '3.9':
bpo-41748: Handles unquoted attributes with commas (GH-24072)
https://github.com/python/cpython/commit/0874491bcc392f7bd9c394ec2fdab183e3f320dd
msg386115 - (view) Author: Ezio Melotti (ezio.melotti) * (Python committer) Date: 2021-02-01 21:27
Merged! Thanks for the report and the PR!
History
Date User Action Args
2021-02-01 21:27:04ezio.melottisetstatus: open -> closed
messages: + msg386115

assignee: ezio.melotti
resolution: fixed
stage: patch review -> resolved
2021-02-01 20:54:46miss-islingtonsetmessages: + msg386111
2021-02-01 20:53:23miss-islingtonsetmessages: + msg386110
2021-02-01 20:52:17vstinnersetmessages: + msg386109
2021-02-01 20:33:24miss-islingtonsetpull_requests: + pull_request23231
2021-02-01 20:33:11miss-islingtonsetnosy: + miss-islington
pull_requests: + pull_request23230
2021-02-01 20:32:55ezio.melottisetmessages: + msg386104
2021-01-10 14:04:58karlcowsetmessages: + msg384763
2021-01-03 08:52:13karlcowsetkeywords: + patch
stage: test needed -> patch review
pull_requests: + pull_request22904
2021-01-03 08:49:49ezio.melottisettype: crash -> behavior
messages: + msg384259
2021-01-03 08:49:01karlcowsetmessages: + msg384258
2021-01-03 08:26:58karlcowsettitle: HTMLParser: parsing error -> HTMLParser: comma in attribute values with/without space
2021-01-03 08:22:14karlcowsetnosy: + karlcow
messages: + msg384256
2020-09-09 15:20:27ezio.melottisetnosy: + ezio.melotti

messages: + msg376642
stage: test needed
2020-09-09 14:42:31vstinnersetmessages: + msg376641
2020-09-09 14:12:10vstinnersetmessages: + msg376639
2020-09-09 14:11:50nowasky.jrsetmessages: + msg376638
2020-09-09 13:57:46vstinnersetnosy: + vstinner
messages: + msg376635
2020-09-09 11:38:33nowasky.jrsettype: security -> crash
2020-09-08 23:04:05nowasky.jrsettype: crash -> security
2020-09-08 21:59:30nowasky.jrcreate