All findings · Lucene finding · LFV-289 · htmlstrip-hex-charref-max-five-digits

HTMLStripCharFilter leaves six-hex-digit character references such as é undecoded: its length check counts the 'x'

HTMLStripCharFilter decodes é to é but leaves é (six hex digits) as literal text, so the word around it is indexed in pieces. Its length check counts the leading x, so only five hex digits pass instead of the six its own comment allows.

Summary

Priority
Low, rank 777HTMLStripCharFilter leaves only six-hex-digit character references undecoded, and nothing throws while the index stays well formed.
Impact
minor
Since
3.6.0 (LUCENE-3690, the 2012 JFlex rewrite)introduced 2012-01-22 · over 14 years
Affected
3.6.0 to 10.6.0 and main
Status
documented
Found by
code reading while building a grammar fuzzer

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Numeric character references in HTML

  1. HTML can write any character as a numeric reference: é (decimal) and é (hex) both mean é. Leading zeros are allowed, so é and é mean é too.
  2. HTMLStripCharFilter decodes these references before the tokenizer runs, so bad reaches the index as the word bad.
  3. The largest code point, U+10FFFF, needs six hex digits or seven decimal digits. The scanner is meant to cap references at exactly these lengths; longer ones are left as text on purpose.
Summary card of the bug. Panel 1, what the user sees (executed): a CustomAnalyzer with the htmlStrip char filter, the standard tokenizer and lowercase. Doc 0, '<b>Caf&#xE9;</b>', becomes 'Café' and the token café; doc 1, '<b>Caf&#x0000E9;</b>', becomes the literal 'Caf&#x0000E9;' and the tokens caf and x0000e9. searcher.count of a TermQuery for body:café gives 1, expected 2: doc 1 is not found; the tags are stripped in both, nothing throws, CheckIndex is clean. Panel 2, why: the characters of &#x0000E9; are numbered 1 to 7 from the x, so yylength() is 7 and '7 <= 6' is false, and the else branch (lines 522-525) passes the reference through as text. A table: &#x000E9; (5 hex digits) decoded to é; &#x0000E9; and &#x10FFFD; (6 hex digits) stay text; &#0000233; and &#1114109; (7 decimal digits) decoded, because the decimal rule's matchLength <= 7 (line 531) counts digits only. A green strip shows the fix, if (matchLength <= 7), then regenerate the scanner; executed with the fix, six-digit references decode, the search finds 2 of 2 documents and the minimal repro passes, while &#x110000; and seven-digit references such as &#x00000E9; stay text. Footnote: executed on main 645d4fe14a20, assertions on and off; affected: every release from 3.6.0 to 10.6.0, and main.
A search for café finds <b>Caf&#xE9;</b> but not <b>Caf&#x0000E9;</b>: HTMLStripCharFilter leaves the six-hex-digit reference as text, which is tokenized as caf and x0000e9, because its length check counts the x (yylength() = 7 fails <= 6). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Strip HTML with HTMLStripCharFilter (or the htmlStrip char filter) in front of StandardTokenizer, and index b&#x000061;d: the word "bad" with its a written as a zero-padded six-hex-digit reference, which is valid HTML.
  2. You expect the token bad, as you get for b&#x61;d (2 hex digits), b&#x00061;d (5 hex digits) and b&#0000097;d (7 decimal digits). The scanner's comment allows hex references of "max 6 hex chars".
  3. Instead the reference stays literal and is indexed as three tokens: b, x000061, d. A TermQuery for bad finds the 2-digit, 5-digit and decimal versions (3 hits) but not this one.
  4. Why: the hex rule matches the x plus the digits, and the check matchLength <= 6 counts the x. Six digits make 7, so the reference is passed through unchanged. The decimal rule beside it counts only digits and is right.
  5. Only references with exactly six hex digits are hit: zero-padded ones such as &#x0000E9; and plane-16 characters (U+100000 to U+10FFFD). Offsets stay correct and nothing throws.

Who is affected

Applications that strip HTML with HTMLStripCharFilter or the htmlStrip factory, at default settings. Only hex references with exactly six digits are hit: zero-padded ones (&#x0000E9;) and plane-16 private-use characters (U+100000 to U+10FFFD). A word containing one is indexed in pieces and not found by its decoded form. Rare; no error, no index damage.

Mechanism, terms and numbers405 words

HTMLStripCharFilter turns numeric character references back into the characters they name before the text is tokenized. Its hexadecimal rule is meant to accept up to six hex digits, enough for the largest code point 10FFFF, as the comment next to the check says ("10FFFF: max 6 hex chars"). But the length it checks is the length of the whole match, x included, so only five digits get through.

A reference with exactly six hex digits, such as the zero-padded &#x0000E9; (é) or the plane-16 &#x100000; and &#x10FFFF;, is therefore emitted unchanged, as literal text, while &#x000E9;, &#0000233; and &#1114111; are decoded. With StandardTokenizer, b&#x000061;d is indexed as b, x000061, d, and a TermQuery for bad misses the document. It has been this way since the JFlex scanner shipped in 3.6.0. The fix is one character in the grammar plus regenerating the scanner.

The scanner rule

After &# the scanner enters the state NUMERIC_CHARACTER (HTMLStripCharFilter.jflex:351). Its hex rule (:494-527):

[xX] [0-9A-Fa-f]+ {                       // the match is the x plus every digit
  int matchLength = yylength();             // :495, counts the x
  ...
  if (matchLength <= 6) { // 10FFFF: max 6 hex chars        :497
    String hexCharRef
        = new String(zzBuffer, zzStartRead + 1, matchLength - 1);   // skips the x
    ...
    if (codePoint <= 0x10FFFF) { ... decode ... }            :506
    else { pass through as text }                            :517-521
  } else { pass through as text }                            :522-526

Six hex digits make matchLength 7, so the reference takes the last branch and its original characters are emitted unchanged. The decimal rule (:528-531, [0-9]+) matches digits only and checks matchLength <= 7 // 0x10FFFF = 1114111: max 7 decimal chars, which is right. The generated scanner has the same two checks (HTMLStripCharFilter.java:6557 and :6327).

The boundary on main (executed)

reference      digits  output
&#x61;         2 hex   a              decoded
&#x00061;      5 hex   a              decoded
&#x000061;     6 hex   &#x000061;     NOT decoded  <- the bug
&#x0000061;    7 hex   &#x0000061;    literal by design, also after the fix
&#0000097;     7 dec   a              decoded
&#00000097;    8 dec   &#00000097;    literal by design

End to end (StandardTokenizer + LowerCaseFilter): b&#x000061;d gives the tokens b (offsets 0-1), x000061 (3-10) and d (11-12); TermQuery(body:bad) finds the 2-digit, 5-digit and 7-decimal-digit documents (3 hits) and misses the six-digit one; CheckIndex is clean. a &#x10FFFF; z gives a, x10ffff, z.

Exposure and likelihood148 words
  • Trigger. A hex reference with exactly six hex digits whose value is at most 0x10FFFF: a plane-16 character (U+100000 to U+10FFFD, Supplementary Private Use Area-B; U+10FFFE and U+10FFFF are noncharacters) or a smaller code point zero-padded to six digits (HTML allows leading zeros). Reached through new HTMLStripCharFilter(reader) or the factory (SPI name htmlStrip), with no option to set.
  • Effect. The reference is indexed as text, for example the token x000061 or x10ffff, so a word that contains it is not found by its decoded form. The text around it is tokenized normally, offsets point at the original text, nothing throws, CheckIndex is clean.
  • Not affected. Every code point below U+100000 in its shortest hex form (at most five digits), decimal references of up to seven digits, and named entities.
  • Likelihood. Rare: the recorded searches found no public report of it since it shipped in 2012.

Minimal repro

One JUnit class, one test method, one input (&#x0000E9;), public API only, analysis-common only. It reads the filter's output and expects é.

TestHTMLStripHexCharRefSixDigits.java (one test class)34 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.analysis.charfilter;

import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestHTMLStripHexCharRefSixDigits extends LuceneTestCase {

  public void testSixHexDigitReference() throws Exception {
    StringWriter out = new StringWriter();
    try (Reader r = new HTMLStripCharFilter(new StringReader("&#x0000E9;"))) {
      r.transferTo(out);
    }
    // expected the six-hex-digit reference decoded to "é"; actual: it stays literal "&#x0000E9;"
    assertEquals("é", out.toString());
  }
}
Command
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests TestHTMLStripHexCharRefSixDigits -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
&#x0000E9; strips to é, like &#x000E9; and &#0000233;.
Observed
expected:<[é]> but was:<[&#x0000E9;]>: the reference comes back unchanged; assertions on and off.

All files of this repro: repro/LFV-289/

More files, environment, full output191 words

new HTMLStripCharFilter(new StringReader("&#x0000E9;")) is read to the end with Reader.transferTo and the output is compared with é. Deterministic, single thread; LuceneTestCase is only the base class. &#x0000E9; is an ordinary BMP character, so the expected value needs no surrogate pair; plane-16 references such as &#x100000; take the same branch.

Regression test: this same class (or its one method added to TestHTMLStripCharFilter); it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 10.6.0: fails; 3.6.0 to 10.5.2: the same literal output with the history probe (this test needs 9.0 or later); with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform run; plain Java string handling)
Command (environment)
cp $REPRO/TestHTMLStripHexCharRefSixDigits.java $LUCENE/lucene/analysis/common/src/test/org/apache/lucene/analysis/charfilter/
cd $LUCENE && ./gradlew :lucene:analysis:common:test --tests TestHTMLStripHexCharRefSixDigits -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
   >     org.junit.ComparisonFailure: expected:<[é]> but was:<[&#x0000E9;]>
   >         at __randomizedtesting.SeedInfo.seed([DEADBEEF:54CBF2DB236B7CEA]:0)
   >         at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
   >         at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
   >         at app//org.apache.lucene.analysis.charfilter.TestHTMLStripHexCharRefSixDigits.testSixHexDigitReference(TestHTMLStripHexCharRefSixDigits.java:32)

:lucene:analysis:common:test (FAILURE): 1 test, 1  failure

# standalone runner, seed DEADBEEF: main 645d4fe14a20 (-ea and -da) and 10.6.0 (-da) fail with the same message

Why it looks unintended

  • The check's comment says "10FFFF: max 6 hex chars", and the next line skips the x to parse the digits; the check counts it.
  • The decimal rule beside it counts only digits: &#0000233; decodes, &#x0000E9; does not.
  • With five digits the hex rule's own out-of-range branch is dead code; no checked document states a five-digit limit.
The full argument190 words
  • The stated limit. HTMLStripCharFilter.jflex:497: "10FFFF: max 6 hex chars": six hex digits, the length of the largest code point. The implementation admits five.
  • The code knows the x is in the match. :498-499 parses new String(zzBuffer, zzStartRead + 1, matchLength - 1).
  • The sibling is right. :531 admits seven decimal digits for 1114111; &#1114111; and &#0000233; decode, &#x10FFFF; and &#x0000E9; do not (executed).
  • Dead code. With at most five digits the largest value is 0xFFFFF, so the codePoint <= 0x10FFFF else-branch (:517-521) is unreachable.
  • No checked document states five digits. Not the class javadoc (:31), the factory or the package docs, no CHANGES or MIGRATE entry, and not the LUCENE-3690 discussion (#4764). Upstream tests assert that numeric references decode (TestHTMLStripCharFilter.java:115-119 testEntities, :601-634 testUTF16Surrogates) but never use more than four hex digits.
  • Partly a regression, in lineage only. The Solr class that LUCENE-3690 replaced read up to 10 hex digits and decoded &#x0000e9; (executed on the Solr 3.5.0 jar). It is not a regression between Lucene releases: Lucene 3.5.0 had no Lucene HTMLStripCharFilter. Neither version ever decoded six-digit supplementary references correctly.
The devil's-advocate check102 words

The strongest case for "intended": the class only "attempts to strip out HTML constructs" (HTMLStripCharFilter.jflex:31); it does not follow HTML5's numeric-reference rules anyway (values above 0x10FFFF and long zero padding stay literal), so a digit cap is an implementation choice; and six-digit references are uncommon. A cross-model reviewer rated this "moderate".

Why it fails: the cap's intended value is written next to the check, and the code misses its own stated limit by one. That is an implementation slip, not a choice about HTML5 conformance, and "uncommon" is about impact, not intent. Every reviewer and the adjudication rejected the argument.

Suggested fix

Recommended Allow seven characters: the x plus six digits (validated)

Count the x: change matchLength <= 6 to <= 7 in HTMLStripCharFilter.jflex and regenerate the scanner (one generated line changes). The repro, the 64 charfilter tests and the whole analysis-common module pass. Six-digit references then decode like their shorter spellings (old documents keep their tokens until reindexed); seven-digit hex and out-of-range values stay literal.

   [xX] [0-9A-Fa-f]+ {
     int matchLength = yylength();
     inputSegment.write(zzBuffer, zzStartRead, matchLength);
-    if (matchLength <= 6) { // 10FFFF: max 6 hex chars
+    if (matchLength <= 7) { // "x" + 10FFFF: max 6 hex chars
       String hexCharRef
           = new String(zzBuffer, zzStartRead + 1, matchLength - 1);
All options and trade-offs3 options · 491 words

One fix was written and validated in a scratch worktree of 645d4fe14a20; the alternatives are described but not run.

Allow seven characters: the x plus six digits (validated) (recommended)

Change the bound in the grammar and run ./gradlew :lucene:analysis:common:regenerateHTMLStripCharFilter, which rewrites HTMLStripCharFilter.java:6557 the same way (the only change in the generated file) and updates src/generated/checksums/regenerateHTMLStripCharFilter.json: 3 files, +4/-4. The regenerated scanner is byte-identical to the finder's hand-edited copy.

   [xX] [0-9A-Fa-f]+ {
     int matchLength = yylength();
     inputSegment.write(zzBuffer, zzStartRead, matchLength);
-    if (matchLength <= 6) { // 10FFFF: max 6 hex chars
+    if (matchLength <= 7) { // "x" + 10FFFF: max 6 hex chars
       String hexCharRef
           = new String(zzBuffer, zzStartRead + 1, matchLength - 1);

Trade-offs

  • A visible analysis change for every hex reference with exactly six digits, not only the headline examples. Each now decodes the way its shorter spelling already does (executed): &#x000000; becomes U+0000 like &#x0;; the zero-padded surrogate &#x00D800; becomes U+FFFD like &#xD800; (HTMLStripCharFilter.jflex:509-511; the paired-surrogate rules at :355-489 match only adjacent unpadded pairs); an unterminated &#x000061 at the end of the input becomes a like &#x00061.
  • The tokens of such text change, so documents indexed before the fix keep the old tokens until they are reindexed. Worth a CHANGES entry under Bug Fixes.
  • Unchanged: seven or more hex digits and eight or more decimal digits stay literal (the stated limits); values above 0x10FFFF such as &#x110000; stay literal through the range check.

What the checks validated

Gradle --no-daemon in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3 (2026-10-31). Stock: the repro fails with assertions on and off. With the fix:

  • the repro passes with assertions on and off;
  • org.apache.lucene.analysis.charfilter.*: 64 tests (TestHTMLStripCharFilter 36, TestHTMLStripCharFilterFactory 7, TestMappingCharFilter 18, its factory 2, the repro 1), 0 failures, seeds DEADBEEF and CAFEBABE;
  • the whole :lucene:analysis:common:test, seed DEADBEEF: 340 test classes, 1,910 tests, 18 skipped, 0 failures (counted from the test-result XML; an independent fact check's own run got the same totals);
  • regenerateHTMLStripCharFilterChecksumCheck passes;
  • end to end (the same change on the earlier main): b&#x000061;d becomes the token bad and TermQuery(body:bad) finds it (4 hits instead of 3); an independent 7-test check also passes, including &#x110000; staying literal.

Name the digit count (equivalent, not run)

int hexDigits = matchLength - 1; if (hexDigits <= 6), and use hexDigits in the substring.

Trade-offs

Same behaviour as the recommended fix and reads more clearly; a slightly larger diff.

Accept any zero padding (larger, not recommended, not run)

Skip leading zeros, then cap at six significant hex digits (and seven decimal), as HTML5 decoders do.

Trade-offs

Also decodes &#x0000061;, but changes the stated limits for seven or more digits: a behaviour change beyond this bug. Dropping the cap alone is not an option: Integer.parseInt throws for values of 0x80000000 and above, and its catch block only asserts (HTMLStripCharFilter.jflex:501-505): with assertions off codePoint stays 0 and the reference would become U+0000.

Downstream note

The Lucene.NET port carries the same matchLength <= 6 check (read, not run), so its users would see the same undecoded references.

How it was found

Found by reading the hex branch of the scanner's grammar while building a fuzzer for HTMLStripCharFilter; the fuzzer's offset and chunking checks cannot see an undecoded reference. A public-API test then confirmed it on main and on releases back to 3.6.0.

The method in full129 words

Method: non-formal (code reading, then an executed test).

  • Finder. The hunt unit for analysis char filters and stemmers was building a grammar fuzzer for HTMLStripCharFilter whose fragment list includes &#x10FFFF; and &#1114111;. Reading the hex rule showed the check counts the x. The fuzzer's oracles (offset range and order, final offset, chunking independence) reported 0 violations over 2,000,000 documents, because an undecoded reference breaks none of them.
  • Confirmation. A public-API JUnit (3 six-digit cases fail, the five-digit and decimal controls pass) on main with assertions on and off and on 9.12.3, and a main() probe on 9 releases back to 4.0.0.
  • Hunt. Independent reproduction, cross-model reviews, an adjudication, the version-history runs, a prior-art search, a security screen and a one-test minimal repro followed (see the checks below).

Prior reports, history and checks

Prior reports

No prior report: 116 queries and reads on 2026-10-26 over GitHub (Lucene and downstream), the mailing lists, JIRA and the web, and a re-check on 2026-10-31. Upstream main still has the check, and no open PR touches the file.

Searches3 searches

Verdict new: in the recorded searches, no issue, PR, JIRA entry, list thread, commit or downstream report says that HTMLStripCharFilter leaves six-hex-digit references undecoded or that its check counts the x. Closest, none of them this bug: LUCENE-3690 / #4764 (introduced the check; its discussion covers only unpaired surrogates), LUCENE-5763 / #6825 (open, HTML5 named entities), SOLR-2819 and SOLR-882 (the removed pre-JFlex code), #15754 / #15756 and #13157 (attribute parsing; #13157, open since 2024, changes only TestHTMLStripCharFilter.java). The Lucene.NET port has the same check, unreported. Limits: GitHub's search rate limit was reached, and list search matches poorly on fragments such as &#x.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-26a scripted prior-report search (18 issue/PR, 3 commit, 2 downstream queries), 10 direct searches, 13 item reads, the changed paths of all 411 open apache/lucene PRsbackground only: #4764, #6825, #6796, #15754/#15756, #13157, #11556, #4986
Mailing lists, JIRA and the web2026-10-2624 list queries (dev@, java-user@, issues@, commits@), 25 JIRA queries and reads (LUCENE and SOLR), 8 web queriesSOLR-2819, SOLR-882, LUCENE-3690: other defects or the introducing change
Upstream source and open PRs2026-10-31the .jflex on upstream main (2f424fd428da), branch_11x and branch_10_6; open-PR and issue searches for HTMLStripCharFilterthe check is unchanged; no open PR touches HTMLStripCharFilter.jflex or HTMLStripCharFilter.java (open PR #13157 of 2024 changes only TestHTMLStripCharFilter.java, a test about the earlier fix #11724)

History

Introduced by LUCENE-3690 (f3a363708ff, sarowe, 2012-01-22), the JFlex rewrite of HTMLStripCharFilter, first released in 3.6.0 via its branch_3x backport; the check has not changed since. Every release run from 3.6.0 to 10.6.0 fails; 3.5.0 has no such class.

Versions tested6 rows

Introduced by f3a363708ff "LUCENE-3690: Re-implemented HTMLStripCharFilter as a JFlex-generated scanner" (Steven Rowe, GitHub sarowe, trunk, 2012-01-22; a JIRA patch, no PR; the issue is now #4764) and its branch_3x backport d387ae60fcf (2012-01-24). The rule, the check and its comment were written together: git blame of HTMLStripCharFilter.jflex:494-499 gives f3a363708ff, and git log -L 494,527 lists only that commit and 059410d4248 (2012-01-23, the surrogate follow-up, which did not touch the check).

The line is in all 127 release tags that contain the .jflex, 3.6.0 through 10.6.0 (the 3.6.x contrib path included). 4.0.0-alpha and 4.0.0-beta contain it but were not run.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal repro fails (main with assertions on and off)
10.0.0, 10.5.2, 9.0.0, 9.12.3, 8.11.4 affected4 of 4 six-digit cases stay literal; 5 of 5 controls OK
4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3 affectedthe same
3.6.0, 3.6.2 (first release: the branch_3x backport) affectedthe same
3.5.0 no classno Lucene HTMLStripCharFilter (the repro does not compile); the Solr class it replaced decoded &#x0000e9;
main 645d4fe14a20 with the fix not affectedrepro passes; the whole analysis-common module passes

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication each re-ran it and confirmed it; a cross-model works-as-designed review judged it real. The one dissent, a read-only refute review, said "not real" only because it could not run Java, and agreed on the root cause. This doc's gradle runs validated the fix.

Checks and verdicts309 words

Every verdict below re-ran a repro on stock main jars or release jars, except the reading-only cross-model reviews, for which separate runs re-ran the repro (fails 3 of 4).

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidatePublic-API JUnit: 3 of 4 fail on main with assertions on and off and on 9.12.3; a main() probe fails on 9 releases back to 4.0.0; the fix sketch passes 43 upstream tests.
Cross-model refute review not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only; it had no executed repro of its own. It agreed on the root cause and the legal inputs and rated the intended argument weak.
Independent reproducer realOwn 7-test JUnit with other inputs (&#x100000;, &#X10FFFD;, &#x0000e9;): 3 of 7 fail on main with assertions on and off, 10.5.2 and 9.12.3; own main() fails on 8.11.4; with <= 7 all 7 pass.
Works-as-designed review (the cross-model review, OpenAI GPT-5.6) real (medium)Reading only; rated the intended argument moderate, outweighed by the comment, the decimal sibling and the upstream tests.
Intended-behaviour devil's advocate realNo documented five-digit limit anywhere; the replaced filter read up to 10 hex digits; re-runs fail on main (assertions on and off) and 10.5.2.
Adjudication confirmed (high), minorRe-ran the independent reproduction's JUnit with a new seed: 3 of 7 fail with assertions on and off.
Minimal repro and main refresh verifiedThe one-test class fails on 645d4fe14a20 with assertions on and off and passes with the fix; on main a9779ef3c193 all 4 recorded replays failed as before.
This doc real; fix validatedGradle in a scratch worktree: stock fails with assertions on and off; the fix passes the repro, 64 charfilter tests (2 seeds) and the whole analysis-common module; 10.6.0 fails.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,370 words

Finding record

LFV-289, snapshot of 2026-11-06

Registry title
HTMLStripCharFilter leaves hexadecimal character references with six hex digits (&#x10FFFD; &#x100000; the zero-padded &#x0000E9) undecoded, because its length check counts the leading 'x' and so lets only five hex digits through
Area
analysis / HTMLStripCharFilter (JFlex grammar, numeric character references; also the 'htmlStrip' char filter SPI)
Priority
Low, rank 777: HTMLStripCharFilter leaves only six-hex-digit character references undecoded, and nothing throws while the index stays well formed.
Impact class
minor
Impact note
Applications that strip HTML with HTMLStripCharFilter (directly or through the 'htmlStrip' SPI factory), at its default settings and any index size: a hexadecimal character reference with exactly six hex digits, that is a plane-16 private-use code point (U+100000 to U+10FFFD) or a zero-padded smaller one such as &#x0000E9; is passed through as literal text with its original offsets and indexed as a token such as 'x0000e9', so a word that contains it is not found by its decoded form; hex references of up to five digits and decimal references of up to seven digits are decoded, nothing throws, and the index stays well formed (CheckIndex clean).
Summary
You run HTML through HTMLStripCharFilter and expect every numeric character reference up to U+10FFFF to be decoded, as the scanner's own comment says ('10FFFF: max 6 hex chars') and as its decimal branch does for &#1114111; and the zero-padded &#0000233;. Instead a hex reference with six hex digits, such as &#x10FFFD; &#x100000; or &#x0000E9; stays in the output as literal text: the length that is checked includes the leading 'x', so only five hex digits pass. Through StandardTokenizer, 'b&#x000061;d' is indexed as the tokens b, x000061 and d, and a TermQuery for 'bad' does not find the document.
Introduced
f3a363708ff95f28b5f29fe2cc27c1cd9fd0fca9 (trunk, svn r1234452; LUCENE-3690 / GitHub #4764 'JFlex-based HTMLStripCharFilter replacement', by sarowe; pre-GitHub, a JIRA patch, no PR) and its branch_3x backport d387ae60fcf7ecb65596b756a0f8fc79923d0370 (svn r1235308), which shipped in 3.6.0; the commit wrote the hex rule, the 'matchLength <= 6' check and its comment together [read: git blame of HTMLStripCharFilter.jflex:494-499 on main gives f3a363708ff; git log -L 494,527 (the whole hex branch) lists only f3a363708ff and 059410d4248 (2012-01-23, surrogate handling, length check untouched); git tag --contains d387ae60fcf gives 3.6.0, 3.6.1, 3.6.2; exec: 3.5.0 has no such Lucene class (compile error) and 3.6.0 fails] (2012-01-22 (trunk commit f3a363708ff); 2012-01-24 (branch_3x backport d387ae60fcf))
First affected version
3.6.0 [exec] (via the branch_3x backport; 3.5.0, the last release before, has no Lucene HTMLStripCharFilter [exec: compile error]); on the 4.x line 4.0.0-alpha [read], first GA release 4.0.0 [exec]
Affected versions
every release from 3.6.0 to 10.5.2 (the last release before 10.6.0) and main ea57360912ed: 3.6.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea and -da [exec: the follow-up's public-API history probe, 4 of 4 six-digit cases literal and 5 of 5 controls OK on each]; main, 10.5.2 and 9.12.3 also [exec: the finder's, the independent reproduction's and the intended-behaviour check's JUnits], and 8.11.4 [exec: the independent reproduction's main()]; the buggy line is present at all 126 release tags that contain HTMLStripCharFilter.jflex [read]; 4.0.0-alpha and 4.0.0-beta were not executed (the harness takes x.y.z versions only) but contain the line [read]; not fixed upstream: apache/lucene main, branch_10x and branch_10_5 still have the check (read 2026-10-26; the last commit to the .jflex, 02014a6245d of 2026-03-03, does not touch it) and no open PR touches HTMLStripCharFilter.java or .jflex. Context: the replaced Solr class (org.apache.solr.analysis.HTMLStripCharFilter, Solr 3.5.0) decoded the zero-padded &#x0000e9; [exec], so for zero-padded BMP references the 2012 rewrite was a regression; six-digit supplementary references were never decoded correctly by either implementation (the Solr class returned them from read() as one int above 0xFFFF)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-26, read-only; the prior-report record: 116 recorded queries and reads, namely the scripted prior-report search (18 GitHub issue/PR, 3 commit, 2 downstream-repo, 24 mailing-list over dev@, java-user@, issues@ and commits@, 9 JIRA and 6 web queries), 10 direct GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and apache/lucenenet, 4 open-PR searches plus a scan of the changed paths of all 411 open apache/lucene PRs, 16 direct JIRA queries and reads over LUCENE and SOLR, 13 GitHub item reads, the upstream .jflex on main, branch_10x and branch_10_5, the local history and CHANGES.txt; the finder's, the independent reproduction's and the intended-behaviour check's own searches found nothing either). Not fixed upstream: the check is unchanged on main, branch_10x and branch_10_5, and no open PR touches HTMLStripCharFilter.java or .jflex. Nearest items, none a report of this bug: LUCENE-3690 / #4764 (introduced the scanner and the check; its 41 comments discuss only the unpaired-surrogate follow-up), LUCENE-5763 / #6825 (open: HTML5 named entities only), SOLR-2819 and SOLR-882 (the removed pre-JFlex implementation: isHex accepting G-Z, and hex-entity offset padding), #15754 / #15756 (an attribute value ending in '='; the last change to the .jflex), #13157 (open PR, a test for attribute values). The Lucene.NET port has the same check, unreported. Gaps: GitHub's search rate limit was reached, and the mailing-list full-text search matches poorly on fragments such as '&#x'
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the one-line fix (scanner regenerated by gradle) it passes, as do the 64 charfilter tests (seeds DEADBEEF and CAFEBABE) and the whole analysis-common test module (1,910 tests, seed DEADBEEF). Release 10.6.0 fails the same way; 12 releases from 3.6.0 to 10.5.2, run by the version-history runs, all fail.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.jflex:31 (at ea57360912ed) (class javadoc: 'attempts to strip out HTML constructs'), :493-526 (NUMERIC_CHARACTER hex rule '[xX] [0-9A-Fa-f]+': 'int matchLength = yylength();' includes the 'x'; 'if (matchLength <= 6) { // 10FFFF: max 6 hex chars'; the substring below skips the 'x' with zzStartRead + 1, matchLength - 1; then 'if (codePoint <= 0x10FFFF)'; else-branches pass the reference through as literal text), :528-560 (decimal rule '[0-9]+': 'if (matchLength <= 7) { // 0x10FFFF = 1114111: max 7 decimal chars', digits only)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.java:6557 (at ea57360912ed) (generated copy of the hex check), :6327 (generated decimal check)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilterFactory.java:42 (at ea57360912ed) (SPI NAME = "htmlStrip")
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/charfilter/TestHTMLStripCharFilter.java (at ea57360912ed) testEntities and testUTF16Surrogates (decimal and hex references of at most 4 hex digits are asserted to decode; no test uses 5 or 6 hex digits)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.jflex:351, :493-560
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.java:6327, :6557
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilterFactory.java:42 (NAME = "htmlStrip")
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.jflex:31, :494-531
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/test/org/apache/lucene/analysis/charfilter/TestHTMLStripCharFilter.java:115-119, :601-634
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.jflex:501-505, :509-511
Prior reports
  • GitHub (read-only, 2026-10-31): apache/lucene contents of HTMLStripCharFilter.jflex at main, branch_11x, branch_10_6; commits?path= (last: 02014a6245d3, #15756); gh search prs/issues
  • GitHub (read-only, 2026-10-31): gh pr view 13157 (OPEN, 2024-03-05, 'Test case that exposes bug in PR #11724', one file: TestHTMLStripCharFilter.java +10); gh issue view 11724 ('LUCENE-10520 / #11556 HTMLStripCharFilter bugfix', merged)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/charfilter/HTMLStripCharFilter.jflex:327, :351, :494-499, :522-526, :531

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and a probe executed for the diagram on main, with assertions on and off and with the fix shadowed in: the two café documents through CustomAnalyzer (htmlStrip, standard, lowercase), their tokens (caf [3,6) and x0000e9 [8,15) for doc 1), count(body:café) 1 instead of 2, CheckIndex clean, and the five-, six- and seven-digit rows of the table. The affected range in the footnote also rests on the 10.6.0 runs of the minimal repro.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.