This is an automated email from the ASF dual-hosted git repository.

jnioche pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/stormcrawler.git


The following commit(s) were added to refs/heads/main by this push:
     new 0663b8c7 Escape CR and LF in WARC metadata records and resource 
Content-Type (#2109)
0663b8c7 is described below

commit 0663b8c7f49140daaeba35572e4488b3766d9e8c
Author: Abhinav <[email protected]>
AuthorDate: Tue Sep 1 21:38:28 2026 +0530

    Escape CR and LF in WARC metadata records and resource Content-Type (#2109)
    
    * Escape CR and LF in WARC metadata records and resource Content-Type
    
    MetadataRecordFormat.format() wrote one line per metadata value into the
    application/warc-fields payload without checking for CR or LF. A value
    containing CR LF (e.g. feed.description set by FeedParserBolt, or values
    of parse.* filters such as the XPath, LDJson and Tika filters) therefore
    became additional field lines that look exactly like fields written by
    the crawler - for example a fabricated hopsFromSeed or via. Framing
    stayed valid because Content-Length is computed from the finished
    payload, so WARC readers had no way to detect the injected fields.
    
    - replace CR and LF by spaces in metadata values written into the
      warc-fields payload, and drop metadata keys that are not valid WARC
      field names (printable ASCII without colon, RFC 5322 section 2.2)
    - sanitise the server-supplied Content-Type used for resource records in
      WARCRecordFormat.format(), which was appended verbatim into the WARC
      header block
    - log MetadataRecordFormat messages under MetadataRecordFormat instead
      of WARCRequestRecordFormat
    
    Fixes #2105
    
    * Validate warc.metadata.keys once on instantiation
    
    The configured metadata keys are fixed topology configuration: check
    them for valid WARC field names in the constructor and drop invalid
    keys with a single warning, instead of repeating the check and warning
    for every record written. Suggested in review.
    
    * Make null check in isValidWarcFieldName explicit
    
    The conjunction already returned false for a null name through
    short-circuit evaluation, but the intent was easy to miss. Return false
    explicitly and cover the field name and value sanitisation helpers with
    unit tests. Suggested in review.
---
 .../stormcrawler/warc/MetadataRecordFormat.java    |  25 ++++-
 .../apache/stormcrawler/warc/WARCRecordFormat.java |  40 ++++++-
 .../warc/MetadataRecordFormatCRLFTest.java         | 119 +++++++++++++++++++++
 .../stormcrawler/warc/WARCRecordFormatTest.java    |  66 ++++++++++++
 4 files changed, 244 insertions(+), 6 deletions(-)

diff --git 
a/external/warc/src/main/java/org/apache/stormcrawler/warc/MetadataRecordFormat.java
 
b/external/warc/src/main/java/org/apache/stormcrawler/warc/MetadataRecordFormat.java
index ad054911..75b79924 100644
--- 
a/external/warc/src/main/java/org/apache/stormcrawler/warc/MetadataRecordFormat.java
+++ 
b/external/warc/src/main/java/org/apache/stormcrawler/warc/MetadataRecordFormat.java
@@ -17,6 +17,7 @@ package org.apache.stormcrawler.warc;
 
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.storm.tuple.Tuple;
@@ -46,14 +47,26 @@ fetchTimeMs: 565
 
 public class MetadataRecordFormat extends WARCRecordFormat {
 
-    private static final Logger LOG = 
LoggerFactory.getLogger(WARCRequestRecordFormat.class);
+    private static final Logger LOG = 
LoggerFactory.getLogger(MetadataRecordFormat.class);
 
-    private List<String> metadataKeys;
+    private final List<String> metadataKeys;
 
     public MetadataRecordFormat(List<String> metadataKeys) {
         super("");
-        this.metadataKeys = metadataKeys;
-        LOG.info("MetadataRecordFormat instantiated with {}", String.join(",", 
metadataKeys));
+        // the keys are fixed configuration: validate them once here instead of
+        // for every record
+        final List<String> validKeys = new ArrayList<>(metadataKeys.size());
+        for (String key : metadataKeys) {
+            if (isValidWarcFieldName(key)) {
+                validKeys.add(key);
+            } else {
+                LOG.warn(
+                        "Skipping invalid WARC field name configured in 
warc.metadata.keys: {}",
+                        key);
+            }
+        }
+        this.metadataKeys = List.copyOf(validKeys);
+        LOG.info("MetadataRecordFormat instantiated with {}", String.join(",", 
this.metadataKeys));
     }
 
     @Override
@@ -81,7 +94,9 @@ public class MetadataRecordFormat extends WARCRecordFormat {
                 if (StringUtils.isBlank(value)) {
                     continue;
                 }
-                payload.append(key).append(": ").append(value).append(CRLF);
+                // metadata values often originate from the parsed content: 
replace CR and
+                // LF so that the value cannot forge additional field lines
+                payload.append(key).append(": 
").append(sanitizeWarcFieldValue(value)).append(CRLF);
             }
         }
 
diff --git 
a/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCRecordFormat.java
 
b/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCRecordFormat.java
index cee46c8f..ad8f2d46 100644
--- 
a/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCRecordFormat.java
+++ 
b/external/warc/src/main/java/org/apache/stormcrawler/warc/WARCRecordFormat.java
@@ -89,6 +89,42 @@ public class WARCRecordFormat implements RecordFormat {
     protected static final Pattern HTTP_STATUS_CODE_PATTERN = 
Pattern.compile("^[0-9]{3}$");
     protected static final String HTTP_VERSION_FALLBACK = "HTTP/1.1";
 
+    /*
+     * Named fields (WARC header fields and the fields of an 
application/warc-fields payload) are
+     * terminated by CRLF: their names are limited to printable ASCII 
characters excluding the
+     * colon, cf. RFC 5322 section 2.2, and their values must not contain CR 
or LF, otherwise the
+     * remainder of the value would be read as additional field lines.
+     */
+    private static final Pattern WARC_FIELD_NAME_PATTERN = 
Pattern.compile("[!-9;-~]+");
+
+    /**
+     * Check whether a string is a valid WARC field name, i.e. consists of 
printable ASCII
+     * characters without a colon, cf. RFC 5322 section 2.2.
+     *
+     * @param name field name candidate
+     * @return true if the name can safely be written as the name of a WARC 
field
+     */
+    static boolean isValidWarcFieldName(String name) {
+        if (name == null) {
+            return false;
+        }
+        return WARC_FIELD_NAME_PATTERN.matcher(name).matches();
+    }
+
+    /**
+     * Replace CR and LF characters in a field value by spaces so that the 
value cannot forge
+     * additional field lines in a WARC header block or in an 
application/warc-fields payload.
+     *
+     * @param value field value to sanitise
+     * @return the value without CR and LF characters
+     */
+    static String sanitizeWarcFieldValue(String value) {
+        if (value == null || (value.indexOf('\r') < 0 && value.indexOf('\n') < 
0)) {
+            return value;
+        }
+        return value.replace('\r', ' ').replace('\n', ' ');
+    }
+
     protected static final Pattern PROBLEMATIC_HEADERS =
             
Pattern.compile("(?i)(?:Content-(?:Encoding|Length)|Transfer-Encoding)");
     protected static final String X_HIDE_HEADER = "X-Crawler-";
@@ -445,7 +481,9 @@ public class WARCRecordFormat implements RecordFormat {
             if (StringUtils.isBlank(ct)) {
                 ct = "application/octet-stream";
             }
-            buffer.append("Content-Type: ").append(ct).append(CRLF);
+            // the content type is under the control of the remote server: 
replace CR and LF
+            // so that it cannot forge additional header lines in the WARC 
header block
+            buffer.append("Content-Type: 
").append(sanitizeWarcFieldValue(ct)).append(CRLF);
         }
 
         String truncated =
diff --git 
a/external/warc/src/test/java/org/apache/stormcrawler/warc/MetadataRecordFormatCRLFTest.java
 
b/external/warc/src/test/java/org/apache/stormcrawler/warc/MetadataRecordFormatCRLFTest.java
new file mode 100644
index 00000000..92430dc1
--- /dev/null
+++ 
b/external/warc/src/test/java/org/apache/stormcrawler/warc/MetadataRecordFormatCRLFTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.warc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.storm.tuple.Tuple;
+import org.apache.stormcrawler.Metadata;
+import org.junit.jupiter.api.Test;
+import org.netpreserve.jwarc.MessageHeaders;
+import org.netpreserve.jwarc.WarcMetadata;
+import org.netpreserve.jwarc.WarcReader;
+import org.netpreserve.jwarc.WarcRecord;
+
+/**
+ * Metadata values, e.g. feed descriptions or values extracted from the parsed 
content, may contain
+ * CR and LF characters. Written verbatim into the application/warc-fields 
payload of a WARC
+ * metadata record, a CR LF sequence would end the field line and the 
remainder of the value would
+ * look exactly like an additional field line generated by the crawler.
+ */
+class MetadataRecordFormatCRLFTest {
+
+    private byte[] record(String key, String value) {
+        Metadata metadata = new Metadata();
+        metadata.addValue(key, value);
+        Tuple tuple = mock(Tuple.class);
+        
when(tuple.getStringByField("url")).thenReturn("https://www.example.org/";);
+        when(tuple.getValueByField("metadata")).thenReturn(metadata);
+        MetadataRecordFormat format = new MetadataRecordFormat(List.of(key));
+        return format.format(tuple);
+    }
+
+    private static List<MessageHeaders> parseFields(byte[] warcBytes) {
+        List<MessageHeaders> parsed = new ArrayList<>();
+        try (WarcReader reader = new WarcReader(new 
ByteArrayInputStream(warcBytes))) {
+            for (WarcRecord rec : reader) {
+                assertTrue(rec instanceof WarcMetadata, "Can't parse as WARC 
metadata record");
+                parsed.add(((WarcMetadata) rec).fields());
+            }
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+        return parsed;
+    }
+
+    @Test
+    void valueWithCRLFDoesNotCreateExtraFields() {
+        byte[] warcBytes = record("feed.description", "some 
text\r\nhopsFromSeed: 1");
+        String warcString = new String(warcBytes, StandardCharsets.UTF_8);
+        assertFalse(
+                warcString.contains("\r\nhopsFromSeed: 1\r\n"),
+                "a metadata value must not introduce a new warc-fields line");
+        // CR and LF are replaced by spaces, the value remains on its own 
field line
+        assertTrue(
+                warcString.contains("feed.description: some text  
hopsFromSeed: 1\r\n"),
+                "the sanitised metadata value is expected on a single field 
line");
+
+        List<MessageHeaders> records = parseFields(warcBytes);
+        assertEquals(1, records.size(), "expected a single WARC metadata 
record");
+        MessageHeaders fields = records.get(0);
+        assertEquals(
+                List.of("some text  hopsFromSeed: 1"),
+                fields.all("feed.description"),
+                "the sanitised metadata value must be kept on a single field 
line");
+        assertFalse(
+                fields.contains("hopsFromSeed", "1"),
+                "parsers must not see a field the crawler did not write");
+    }
+
+    @Test
+    void valueWithBareLineBreaksDoesNotCreateExtraFields() {
+        for (String value : List.of("some text\nhopsFromSeed: 1", "some 
text\rhopsFromSeed: 1")) {
+            byte[] warcBytes = record("feed.description", value);
+            String warcString = new String(warcBytes, StandardCharsets.UTF_8);
+            assertFalse(
+                    warcString.contains("\r\nhopsFromSeed: 1\r\n"),
+                    "a metadata value must not introduce a new warc-fields 
line");
+            for (MessageHeaders fields : parseFields(warcBytes)) {
+                assertFalse(
+                        fields.contains("hopsFromSeed", "1"),
+                        "parsers must not see a field the crawler did not 
write");
+            }
+        }
+    }
+
+    @Test
+    void invalidFieldNamesAreDropped() {
+        // a colon or a space makes the key unusable as WARC field name
+        for (String key : List.of("hopsFromSeed: 1", "hops FromSeed")) {
+            byte[] warcBytes = record(key, "any value");
+            assertEquals(
+                    0, warcBytes.length, "a record with an invalid field name 
must not be written");
+        }
+    }
+}
diff --git 
a/external/warc/src/test/java/org/apache/stormcrawler/warc/WARCRecordFormatTest.java
 
b/external/warc/src/test/java/org/apache/stormcrawler/warc/WARCRecordFormatTest.java
index 045579a8..d834d8ff 100644
--- 
a/external/warc/src/test/java/org/apache/stormcrawler/warc/WARCRecordFormatTest.java
+++ 
b/external/warc/src/test/java/org/apache/stormcrawler/warc/WARCRecordFormatTest.java
@@ -19,6 +19,7 @@ package org.apache.stormcrawler.warc;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -123,6 +124,30 @@ class WARCRecordFormatTest {
         }
     }
 
+    @Test
+    void testWarcFieldNameValidation() {
+        assertFalse(WARCRecordFormat.isValidWarcFieldName(null), "null is not 
a field name");
+        assertFalse(WARCRecordFormat.isValidWarcFieldName(""), "empty string 
is not a field name");
+        assertFalse(
+                WARCRecordFormat.isValidWarcFieldName("hops FromSeed"),
+                "a space is not allowed in a field name");
+        assertFalse(
+                WARCRecordFormat.isValidWarcFieldName("hopsFromSeed: 1"),
+                "a colon is not allowed in a field name");
+        assertTrue(WARCRecordFormat.isValidWarcFieldName("via"));
+        assertTrue(WARCRecordFormat.isValidWarcFieldName("feed.description"));
+        assertTrue(WARCRecordFormat.isValidWarcFieldName("WARC-Truncated"));
+    }
+
+    @Test
+    void testSanitizeWarcFieldValue() {
+        assertNull(WARCRecordFormat.sanitizeWarcFieldValue(null));
+        assertEquals("unchanged", 
WARCRecordFormat.sanitizeWarcFieldValue("unchanged"));
+        assertEquals("a  b", 
WARCRecordFormat.sanitizeWarcFieldValue("a\r\nb"));
+        assertEquals("a b", WARCRecordFormat.sanitizeWarcFieldValue("a\rb"));
+        assertEquals("a b", WARCRecordFormat.sanitizeWarcFieldValue("a\nb"));
+    }
+
     @Test
     void testWarcRecord() {
         // test validity of WARC record
@@ -331,6 +356,47 @@ class WARCRecordFormatTest {
                 "WARC record: no or incorrect block, digest");
     }
 
+    @Test
+    void testWarcResourceRecordContentTypeCRLFInjection() {
+        // test that a server-controlled Content-Type cannot forge additional 
WARC header
+        // lines in a resource record
+        String txt = "abcdef";
+        byte[] content = txt.getBytes(StandardCharsets.UTF_8);
+        Metadata metadata = new Metadata();
+        metadata.addValue(
+                protocolMDprefix + HttpHeaders.CONTENT_TYPE,
+                "text/html\r\nWARC-Truncated: length\r\n");
+        Tuple tuple = mock(Tuple.class);
+        when(tuple.getBinaryByField("content")).thenReturn(content);
+        
when(tuple.getStringByField("url")).thenReturn("https://www.example.org/";);
+        when(tuple.getValueByField("metadata")).thenReturn(metadata);
+        WARCRecordFormat format = new WARCRecordFormat(protocolMDprefix);
+        byte[] warcBytes = format.format(tuple);
+        String warcString = new String(warcBytes, StandardCharsets.UTF_8);
+        assertFalse(
+                warcString.contains("\r\nWARC-Truncated: length"),
+                "WARC record: Content-Type must not forge additional WARC 
header lines");
+        // CR and LF are replaced by spaces, the content type remains on its 
header line
+        assertTrue(
+                warcString.contains("Content-Type: text/html  WARC-Truncated: 
length"),
+                "WARC record: sanitised Content-Type expected on a single 
header line");
+
+        // try to read it with Jwarc
+        try (WarcReader reader = new WarcReader(new 
ByteArrayInputStream(warcBytes))) {
+            for (WarcRecord record : reader) {
+                assertFalse(
+                        record.headers().contains("WARC-Truncated", "length"),
+                        "WARC record: WARC header block must not contain a 
forged header");
+                assertEquals(
+                        1,
+                        record.headers().all("Content-Type").size(),
+                        "WARC record: expected a single Content-Type header");
+            }
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     @Test
     void testWarcMetadataRecord() {
         Metadata metadata = new Metadata();

Reply via email to