rzo1 opened a new issue, #2092:
URL: https://github.com/apache/stormcrawler/issues/2092

   
   ## What happens
   `getCharsetFromMeta` looks for `<meta charset="` inside the first 
`detect.charset.maxlength` bytes. If it finds the opening but no closing quote 
before the cutoff, it calls itself with `maxlength + 10`, decoding the whole 
window into a new String each time. For a document whose body starts with 
`<meta charset="` and never contains another double quote, the recursion depth 
is `(content.length - 10000) / 10`, and each frame holds a String of the 
growing window. A few hundred KB of body is enough to exhaust the stack. 
`JSoupParserBolt` calls charset detection at lines 274 to 280, before the `try` 
at line 299, so nothing in the bolt catches it and the worker dies.
   
   ## Where
   
`core/src/main/java/org/apache/stormcrawler/util/CharsetIdentification.java:189-196`,
 called from 
`core/src/main/java/org/apache/stormcrawler/bolt/JSoupParserBolt.java:274-280`. 
Config keys: `detect.charset.maxlength` (10000 in `crawler-default.yaml:251`), 
`detect.charset.fast` (false by default, so the full `getCharset` path runs), 
`http.content.limit` (`-1` in `crawler-default.yaml:125`, `65536` in the 
archetype).
   
   ```java
               int end = html.indexOf('"', start + 15);
               // https://github.com/apache/stormcrawler/issues/870
               // try on a slightly larger section of text if it is trimmed
               if (end == -1 && ((maxlength + 10) < buffer.length)) {
                   return getCharsetFromMeta(buffer, maxlength + 10);
               }
   ```
   
   ```java
               charset =
                       CharsetIdentification.getCharset(metadata, content, 
maxLengthCharsetDetection);
   ```
   
   ## Why it matters
   One fetched page ends the worker JVM rather than the parse of that one URL, 
which is what the surrounding `catch (Throwable)` and `handleException` in 
`JSoupParserBolt` are there to guarantee. The URL stays scheduled, so the 
replayed tuple takes the restarted worker down again. This needs a content 
limit that lets a few hundred KB through, which is the library default of `-1`; 
with the archetype's `http.content.limit: 65536` the depth is about 5.5k 
frames, which is CPU churn and repeated copying rather than a crash. Depending 
on heap and stack settings the worker may die of memory exhaustion before the 
stack overflows, since every frame retains its own copy of the window.
   
   ## Reproduction
   
   Save as 
`core/src/test/java/org/apache/stormcrawler/util/CharsetIdentificationUnterminatedMetaTest.java`:
   
   ```java
   /*
    * 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.stormcrawler.util;
   
   import java.nio.charset.StandardCharsets;
   import java.util.Arrays;
   import java.util.concurrent.atomic.AtomicReference;
   import org.apache.stormcrawler.Metadata;
   import org.junit.jupiter.api.Assertions;
   import org.junit.jupiter.api.Test;
   
   /**
    * A document with an unterminated meta charset declaration must not take 
the JVM down: charset
    * detection is expected to give up and let the caller fall back to a 
default.
    */
   class CharsetIdentificationUnterminatedMetaTest {
   
       /** detect.charset.maxlength as set in crawler-default.yaml. */
       private static final int MAXLENGTH = 10000;
   
       private static byte[] unterminatedMetaCharset(int size) {
           byte[] content = new byte[size];
           Arrays.fill(content, (byte) 'A');
           byte[] prefix = "<meta charset=\"".getBytes(StandardCharsets.UTF_8);
           System.arraycopy(prefix, 0, content, 0, prefix.length);
           return content;
       }
   
       private static Throwable runOnStack(byte[] content, long stackSize) 
throws InterruptedException {
           AtomicReference<Throwable> thrown = new AtomicReference<>();
           AtomicReference<String> charset = new AtomicReference<>();
           Runnable body =
                   () -> {
                       try {
                           charset.set(
                                   CharsetIdentification.getCharset(new 
Metadata(), content, MAXLENGTH));
                       } catch (Throwable t) {
                           thrown.set(t);
                       }
                   };
           Thread t = new Thread(null, body, "charset-detection", stackSize);
           t.start();
           t.join();
           return thrown.get();
       }
   
       @Test
       void smallDocumentWithUnterminatedMetaIsHandled() throws Exception {
           Throwable thrown = runOnStack(unterminatedMetaCharset(20000), 1024 * 
1024);
           Assertions.assertNull(thrown, "charset detection threw " + thrown);
       }
   
       @Test
       void largeDocumentWithUnterminatedMetaIsHandled() throws Exception {
           // 400 KB is an ordinary page size and is fetched whole under the 
library
           // default http.content.limit of -1
           Throwable thrown = runOnStack(unterminatedMetaCharset(400_000), 1024 
* 1024);
           Assertions.assertNull(thrown, "charset detection threw " + thrown);
       }
   }
   ```
   
   Run it:
   
   ```
   mvn -pl core test -Dtest=CharsetIdentificationUnterminatedMetaTest
   ```
   
   It runs the call on a thread with a pinned 1 MB stack so the result does not 
depend on the surefire defaults, and asserts the correct behaviour, so it fails 
on main. The 20 KB case passes, the 400 KB case fails.
   
   ```
   [ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 
0.133 s <<< FAILURE! -- in 
org.apache.stormcrawler.util.CharsetIdentificationUnterminatedMetaTest
   [ERROR] 
org.apache.stormcrawler.util.CharsetIdentificationUnterminatedMetaTest.largeDocumentWithUnterminatedMetaIsHandled
 -- Time elapsed: 0.091 s <<< FAILURE!
   org.opentest4j.AssertionFailedError: charset detection threw 
java.lang.StackOverflowError ==> expected: <null> but was: 
<java.lang.StackOverflowError>
   ```
   
   ## Suggested fix
   Make the retry iterative and bounded in 
`CharsetIdentification.getCharsetFromMeta`: search for the closing quote once 
over `min(buffer.length, someCap)` instead of re-decoding a window that grows 
by ten bytes per call, and give up and return null if it is not found. Decoding 
the window once also removes the quadratic copying. Separately, move the 
`getCharset` and `getCharsetFast` calls in `JSoupParserBolt.execute` inside the 
existing `try` that ends at the `catch (Throwable)`, so a failure there routes 
through `handleException` like every other parse failure. Both changes are 
behaviour preserving for well formed pages.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to