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

   
   ## What happens
   `http.content.limit` bounds the bytes the fetcher stores, and on the normal 
path the compression interceptor decodes `Content-Encoding` before the counter 
runs. A sitemap served as a gzip file, an `.xml.gz` body with no 
`Content-Encoding`, is stored and trimmed while still compressed. 
`parseSiteMap` hands those bytes to crawler-commons 1.6, which decompresses 
them with nothing on the StormCrawler side limiting the output, and no 
effective cap arrives from the library either: the test below stores 54,547 
bytes and gets 2,989,004 bytes and 20,001 emitted URLs back. Separately, line 
102 reads the content type as 
`metadata.getFirstValue(HttpHeaders.CONTENT_TYPE)`, without the 
`protocol.md.prefix` the fetcher applies and without matching the lowercased 
header name, so `ct` is always null in a shipped topology and the parser always 
guesses.
   
   ## Where
   `core/src/main/java/org/apache/stormcrawler/bolt/SiteMapParserBolt.java:102` 
and `:183-195`. Config keys: `http.content.limit` (`65536` in the archetype), 
`protocol.md.prefix` (`protocol.` in `crawler-default.yaml:239`).
   
   ```java
           String ct = metadata.getFirstValue(HttpHeaders.CONTENT_TYPE);
   ```
   
   ```java
           if (StringUtils.isBlank(contentType) || 
contentType.contains("octet-stream")) {
               siteMap = parser.parseSiteMap(content, url1);
           } else {
               siteMap = parser.parseSiteMap(contentType, content, url1);
           }
   ```
   
   `JSoupParserBolt.java:230` shows the intended form of that read, using the 
configured prefix.
   
   ## Why it matters
   An operator who sets `http.content.limit` expects it to bound what a single 
document can cost, and on the gzip sitemap path it does not: the ratio between 
the stored bytes and what the parser bolt allocates is limited only by gzip, up 
to about 1000 to 1. Several parser executors expanding such documents at the 
same time push a worker toward memory exhaustion, and after a restart the same 
URLs are still queued. The dead content type read is milder: it means the 
declared type is never used, so every sitemap is identified by guessing, and 
any future logic that branches on `ct` would be dead too.
   
   ## Reproduction
   
   Save as 
`core/src/test/java/org/apache/stormcrawler/bolt/SiteMapParserBoltGzipTest.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.bolt;
   
   import java.io.ByteArrayOutputStream;
   import java.io.IOException;
   import java.nio.charset.StandardCharsets;
   import java.util.HashMap;
   import java.util.Map;
   import java.util.zip.GZIPOutputStream;
   import org.apache.stormcrawler.Constants;
   import org.apache.stormcrawler.Metadata;
   import org.apache.stormcrawler.parse.ParsingTester;
   import org.junit.jupiter.api.Assertions;
   import org.junit.jupiter.api.BeforeEach;
   import org.junit.jupiter.api.Test;
   
   /**
    * http.content.limit bounds what the fetcher stores. A sitemap shipped as a 
.xml.gz file is stored
    * compressed, so the bolt expands it without any bound.
    */
   class SiteMapParserBoltGzipTest extends ParsingTester {
   
       /** archetype value of http.content.limit */
       private static final int CONTENT_LIMIT = 65536;
   
       private static final int URLS = 20000;
   
       @BeforeEach
       void setupParserBolt() {
           bolt = new SiteMapParserBolt();
           setupParserBolt(bolt);
       }
   
       private static byte[] gzippedSitemap() throws IOException {
           StringBuilder sb = new StringBuilder();
           sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
           sb.append("<urlset 
xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\";>");
           for (int i = 0; i < URLS; i++) {
               
sb.append("<url><loc>https://a.example/page-";).append(i).append("</loc></url>");
           }
           // padding, as a large sitemap would carry
           sb.append("<!--");
           for (int i = 0; i < 200_000; i++) {
               sb.append("aaaaaaaaaa");
           }
           sb.append("-->");
           sb.append("</urlset>");
           byte[] plain = sb.toString().getBytes(StandardCharsets.UTF_8);
           ByteArrayOutputStream baos = new ByteArrayOutputStream();
           try (GZIPOutputStream gz = new GZIPOutputStream(baos)) {
               gz.write(plain);
           }
           System.out.println(
                   "sitemap: " + plain.length + " bytes uncompressed, " + 
baos.size() + " gzipped");
           return baos.toByteArray();
       }
   
       /**
        * Documents the current behaviour. The stored document is well under 
http.content.limit, the
        * expansion is two orders of magnitude above it, and every entry is 
emitted. Expected
        * behaviour: expansion beyond the configured content limit is refused 
and the document is
        * treated as a parse error.
        */
       @Test
       void gzippedSitemapIsExpandedWithoutAnyBound() throws IOException {
           Map<String, Object> config = new HashMap<>();
           config.put("http.content.limit", CONTENT_LIMIT);
           prepareParserBolt("test.parsefilters.json", config);
   
           byte[] content = gzippedSitemap();
           Assertions.assertTrue(
                   content.length < CONTENT_LIMIT,
                   "stored document is " + content.length + " bytes, within the 
content limit");
   
           Metadata metadata = new Metadata();
           metadata.setValue(SiteMapParserBolt.isSitemapKey, "true");
           parse("https://a.example/sitemap.xml.gz";, content, metadata);
   
           int emitted = output.getEmitted(Constants.StatusStreamName).size();
           System.out.println("emitted status tuples: " + emitted);
           Assertions.assertEquals(URLS + 1, emitted);
       }
   }
   ```
   
   Run it:
   
   ```
   mvn -pl core test -Dtest=SiteMapParserBoltGzipTest
   ```
   
   This one passes on main and documents the present behaviour, because the 
intended bound does not exist yet to assert against; the comment in the test 
states what should happen. The stored document is 54,547 bytes, inside the 
archetype value, and expands to 2,989,004 bytes with every entry emitted. The 
ratio here is limited only by how much padding the test builds.
   
   ```
   sitemap: 2989004 bytes uncompressed, 54547 gzipped
   emitted status tuples: 20001
   [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
   ```
   
   ## Suggested fix
   In `SiteMapParserBolt.parseSiteMap`, detect the gzip magic bytes and 
decompress into a buffer capped at `http.content.limit`, or at a dedicated 
`sitemap.max.expanded.size` if operators need a larger bound for sitemaps than 
for pages, and treat an overflow as a parse failure rather than passing the 
content on. Pass the decompressed bytes to crawler-commons. Also change line 
102 to `metadata.getFirstValue(HttpHeaders.CONTENT_TYPE, protocolMDprefix)` 
with the prefix read from `protocol.md.prefix` in `prepare`, and match the 
header name case the protocols produce. Fixing the content type read changes 
which parse path is taken for documents that declare a type, so it is worth a 
release note.
   


-- 
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