gumbo: do not use an end tag token whose name has been freed - #3672
gumbo: do not use an end tag token whose name has been freed#3672jeremy wants to merge 1 commit into
Conversation
|
Thanks! This LGTM. I reproduced the crash without the patch fixes it. I looked at the failing tests, I believe they're due to libiconv's server's instability. I imagine rebasing this PR on top of main would make those go green. If you want to fix the memory leak too, that'd be wonderful. |
The tree construction loop frees an unknown end tag name once it is done with the token, and sets it to NULL. When stop_on_first_error ends the loop on that same iteration, finish_parsing still sees that token as the current one. pop_current_node then passes tag == GUMBO_TAG_UNKNOWN with name == NULL to node_qualified_tagname_is. That breaks the function precondition that an unknown tag carries a name. With assertions enabled the process aborts. With NDEBUG the assertion is gone, and if the current node also has an unknown tag the comparison reaches gumbo_ascii_strcasecmp(element_name, NULL) and dereferences NULL. Such a token cannot name any node, so treat it as no match. This is a C API path. Nokogiri never sets stop_on_first_error, so no Ruby code reaches it.
d718e78 to
8b2ba4c
Compare
|
I rebased this branch on main. It now includes #3671, and all 167 checks are green. I also looked at the memory leak you mentioned. My first analysis of it was wrong, and I want to correct the record before anyone acts on it. What I measuredI built A parse with The leak is not in the tokenizerThe call chain points at the tokenizer, so I first assumed that the in-flight tag state leaks its attribute vector. That is incorrect. I instrumented
Where the leak actually isThe leaked vector belongs to a start tag. Its attributes were transferred to an element node, and the ownership assert in the tree construction loop passes. That node was then orphaned when } while (
(token.type != GUMBO_TOKEN_EOF || state->_reprocess_current_token)
&& !(options->stop_on_first_error && parser._output->document_error)
);
The fix, and why I am not pushing itThe tree depth limit a few lines above shows the safe pattern. It does not break out of the loop. It sets the token to EOF and lets if (unlikely(state->_open_elements.length > max_tree_depth)) {
parser._output->status = GUMBO_STATUS_TREE_TOO_DEEP;
token.type = GUMBO_TOKEN_EOF;
}If But it changes what I am happy to write that follow-up PR if you want those semantics. Tell me which you prefer and I will do it. If you would rather keep the abrupt stop, then the leak needs a different fix, and I do not have one I trust. ScopeThis is a C API path only. Nokogiri never sets |
|
Thanks for the fix, I'll review. |
gumbo_parse_with_options()crashes whenGumboOptions.stop_on_first_erroristrueand theparse ends on an unknown end tag inside a fragment context.
This is a C API path. It is not reachable from Ruby.
common_options()inext/nokogiri/gumbo.cassignsmax_attributes,max_errors,max_tree_depthandparse_noscript_content_as_text, and the fragment path addsfragment_context,fragment_namespace,fragment_encoding,quirks_modeandfragment_context_has_form_ancestor. It never assignsstop_on_first_error, andkGumboDefaultOptionssets itfalse.rg -n stop_on_first_error ext/ lib/returns no hits.So no HTML reaches this through
Nokogiri::HTML5. I am reporting and fixing it because theoption is part of the public C interface.
The defect
The tree construction loop frees an unknown end tag's name once it is done with the token, and
sets it to NULL (
parser.c:4862-4867):The loop exit condition can fire on that same iteration:
finish_parsing()then still sees that token asstate->_current_token.pop_current_node()passes
tag == GUMBO_TAG_UNKNOWNtogether withname == NULLintonode_qualified_tagname_is(), whose precondition is the opposite:A fragment context is required.
fragment_parser_initpushes anhtmlroot onto_open_elements. In a document parse_open_elementsis empty at that point,pop_current_nodereturns NULL, and this is never reached.
Two outcomes, depending on how the library was built.
With assertions enabled — which is how Nokogiri builds it — the process aborts on line 603.
With
NDEBUGthe assertion is gone. If the current node's own tag is alsoGUMBO_TAG_UNKNOWNand the namespace matches,
node_qualified_tagname_isdoes not return early at line 607. Itreaches line 611 and calls
gumbo_ascii_strcasecmp(element_name, NULL), which dereferences NULL:The fix
Such a token cannot name any node, so
pop_current_nodetreats it as no match rather than askingnode_qualified_tagname_isa question that violates its precondition. The node then getsGUMBO_INSERTION_IMPLICIT_END_TAG, which is what already happens for every other early exit.Measurements
Host: arm64-darwin, Apple clang. Built directly from
gumbo-parser/srcwithclang -O2, inboth the assertion and the
NDEBUGconfiguration.NDEBUG)</ta>template</ta>div<custom-el></ta>div<custom-el></ta>template<foo-bar><baz-qux></ta>div<custom-el></zz>custom-el</ta>Control: every row above is clean before and after with
stop_on_first_error = false.No behaviour change anywhere else. I built the parser before and after the change and dumped
the full tree for every node — type, tag, namespace, attribute count and
parse_flags, sinceparse_flagsis the only thing this change can affect. Corpus: the 1,792 html5libtree-construction cases (192 of them with a fragment context), each parsed with
stop_on_first_errorbothfalseandtrue.3,584 parses, byte-identical output, no crash on either side.
That corpus contains none of the triggering inputs, so on its own it could not tell a fix from a
no-op. Running the same differential over the cases in the table above shows the instrument does
fire:
<custom-el></ta>and the other three unknown-node rows go from exit 139 to exit 0.Tests
Two tests added to
gumbo-parser/test/parser.cc. Without the fix the suite aborts at the firstof them:
With the fix:
Ruby suite,
test/html5/*_test.rb: 2839 runs, 10321 assertions, 0 failures, 0 errors, 24 skips.One thing this PR does not fix
The same option leaks memory on a different input. With
stop_on_first_error = trueand a parsethat exits mid-tag, the tokenizer's in-flight tag state leaks its attribute vector
(
start_new_tag→gumbo_vector_initinvector.c:28→gumbo_allocinutil.c:25). Itreproduces on
<td><template></teand needs no fragment context. That is a separate cleanup pathand I have left it out to keep this change small. Happy to do it here or in a follow-up,
whichever you prefer.
The two early exits that are reachable from Ruby do not leak. Measured with ASan and LSan at
Nokogiri's defaults, across four fragment contexts:
<div>×5000 returnsGUMBO_STATUS_TREE_TOO_DEEPand 5,000 attributes returnsGUMBO_STATUS_TOO_MANY_ATTRIBUTES,both with no leak.
Provenance
Found by coverage-guided fuzzing of
gumbo_parse_with_options(libFuzzer, ASan and UBSan,1,991,470 executions), which reported no other crash. 160 artifacts reduced to this one signature,
and 0 of the 160 reproduce with
stop_on_first_error = false.