Repository: zaproxy/zaproxy
Issue: Using the Active Scan for GET targets sends a 'Content-Length' header

When using the Active Scan for a GET target Zap sends the header 'Content-Length: 0' in the requests.

This should not happen and can prevent scanning some servers which do reject the 'Content-Length: 0' header in GET requests.

Reproduction:
Install Python
Start Python http server: `python -m http.server 7777`
Start Zap GUI, proxy at 8080, turn off HUD
Make a request using curl: `curl --proxy http://localhost:8080 http://localhost:7777`
See the request in Zap history
Right click the request in Zap history, select "Open/Resend in request editor"
See that no "content-length" header is present
Right click the request again, select "Attack->Active Scan"
Check the requests in the Active Scan tab
See that all the request have the header `Content-Length: 0`

The requests in the attack should not have the "Content-Length" header as some systems reject GET requests with it.

Zap version used: 2.11.1

Changed files:
zap/src/test/java/org/parosproxy/paros/core/scanner/AbstractPluginUnitTest.java
zap/src/main/java/org/parosproxy/paros/core/scanner/AbstractPlugin.java

diff --git a/zap/src/main/java/org/parosproxy/paros/core/scanner/AbstractPlugin.java b/zap/src/main/java/org/parosproxy/paros/core/scanner/AbstractPlugin.java
index 963f38c27..57e306581 100644
--- a/zap/src/main/java/org/parosproxy/paros/core/scanner/AbstractPlugin.java
+++ b/zap/src/main/java/org/parosproxy/paros/core/scanner/AbstractPlugin.java
@@ -74,6 +74,7 @@
 // ZAP: 2022/06/05 Remove usage of HttpException.
 // ZAP: 2022/08/03 Keep enabled state when setting default alert threshold (Issue 7400).
 // ZAP: 2022/09/08 Use format specifiers instead of concatenation when logging.
+// ZAP: 2022/09/28 Do not set the Content-Length header when the method does not require one.
 package org.parosproxy.paros.core.scanner;
 
 import java.io.IOException;
@@ -95,6 +96,7 @@ import org.parosproxy.paros.core.scanner.Alert.Source;
 import org.parosproxy.paros.model.HistoryReference;
 import org.parosproxy.paros.network.HttpHeader;
 import org.parosproxy.paros.network.HttpMessage;
+import org.parosproxy.paros.network.HttpRequestHeader;
 import org.parosproxy.paros.network.HttpStatusCode;
 import org.zaproxy.zap.control.AddOn;
 import org.zaproxy.zap.extension.anticsrf.ExtensionAntiCSRF;
@@ -297,7 +299,8 @@ public abstract class AbstractPlugin implements Plugin, Comparable<Object> {
         // always get the fresh copy
         message.getRequestHeader().setHeader(HttpHeader.IF_MODIFIED_SINCE, null);
         message.getRequestHeader().setHeader(HttpHeader.IF_NONE_MATCH, null);
-        message.getRequestHeader().setContentLength(message.getRequestBody().length());
+
+        updateRequestContentLength(message);
 
         if (this.getDelayInMs() > 0) {
             try {
@@ -323,6 +326,30 @@ public abstract class AbstractPlugin implements Plugin, Comparable<Object> {
         parent.performScannerHookAfterScan(message, this);
     }
 
+    /**
+     * Updates the Content-Length header of the request.
+     *
+     * <p>For methods with absent or unanticipated enclosed content, the header is removed otherwise
+     * in all other cases the header is updated to match the length of the content.
+     *
+     * @param message the message to update.
+     * @since 2.12.0
+     */
+    protected void updateRequestContentLength(HttpMessage message) {
+        int bodyLength = message.getRequestBody().length();
+        String method = message.getRequestHeader().getMethod();
+        if (bodyLength == 0
+                && (HttpRequestHeader.GET.equalsIgnoreCase(method)
+                        || HttpRequestHeader.CONNECT.equalsIgnoreCase(method)
+                        || HttpRequestHeader.DELETE.equalsIgnoreCase(method)
+                        || HttpRequestHeader.HEAD.equalsIgnoreCase(method)
+                        || HttpRequestHeader.TRACE.equalsIgnoreCase(method))) {
+            message.getRequestHeader().setHeader(HttpHeader.CONTENT_LENGTH, null);
+            return;
+        }
+        message.getRequestHeader().setContentLength(bodyLength);
+    }
+
     @Override
     public void run() {
         // ZAP : set skipped to false otherwise the plugin should stop continuously
diff --git a/zap/src/test/java/org/parosproxy/paros/core/scanner/AbstractPluginUnitTest.java b/zap/src/test/java/org/parosproxy/paros/core/scanner/AbstractPluginUnitTest.java
index c5b3a6fa2..c108385c0 100644
--- a/zap/src/test/java/org/parosproxy/paros/core/scanner/AbstractPluginUnitTest.java
+++ b/zap/src/test/java/org/parosproxy/paros/core/scanner/AbstractPluginUnitTest.java
@@ -36,6 +36,8 @@ import static org.mockito.Mockito.when;
 
 import java.io.IOException;
 import java.security.InvalidParameterException;
+import java.util.Arrays;
+import java.util.List;
 import java.util.stream.Stream;
 import org.apache.commons.configuration.Configuration;
 import org.apache.commons.httpclient.URI;
@@ -50,6 +52,7 @@ import org.mockito.ArgumentCaptor;
 import org.parosproxy.paros.Constant;
 import org.parosproxy.paros.core.scanner.Plugin.AlertThreshold;
 import org.parosproxy.paros.network.HttpHeader;
+import org.parosproxy.paros.network.HttpMalformedHeaderException;
 import org.parosproxy.paros.network.HttpMessage;
 import org.parosproxy.paros.network.HttpRequestHeader;
 import org.parosproxy.paros.network.HttpSender;
@@ -63,6 +66,19 @@ import org.zaproxy.zap.utils.ZapXmlConfiguration;
 /** Unit test for {@link AbstractPlugin}. */
 class AbstractPluginUnitTest extends PluginTestUtils {
 
+    private static final List<String> METHODS_NO_ENCLOSED_CONTENT =
+            Arrays.asList(
+                    HttpRequestHeader.CONNECT,
+                    "connect",
+                    HttpRequestHeader.DELETE,
+                    "delete",
+                    HttpRequestHeader.GET,
+                    "get",
+                    HttpRequestHeader.HEAD,
+                    "head",
+                    HttpRequestHeader.TRACE,
+                    "trace");
+
     HostProcess parent;
     HttpMessage message;
     Analyser analyser;
@@ -2073,6 +2089,95 @@ class AbstractPluginUnitTest extends PluginTestUtils {
         assertThat(message.getRequestHeader().getHeader(HttpHeader.X_ZAP_SCAN_ID), is(nullValue()));
     }
 
+    @ParameterizedTest
+    @MethodSource("methodsNoEnclosedContent")
+    void shouldNotAddContentLenthHeaderWhenNotExpected(String method) {
+        // Given
+        HttpMessage message = messageWithMethod(method);
+        // When
+        plugin.updateRequestContentLength(message);
+        // Then
+        assertContentLength(message.getRequestHeader(), null);
+    }
+
+    @ParameterizedTest
+    @MethodSource("methodsNoEnclosedContent")
+    void shouldRemoveExistingContentLengthHeaderWhenNotExpectedNorNeeded(String method) {
+        // Given
+        HttpMessage message = messageWithMethod(method);
+        message.getRequestHeader().setContentLength(1234);
+        // When
+        plugin.updateRequestContentLength(message);
+        // Then
+        assertContentLength(message.getRequestHeader(), null);
+    }
+
+    @ParameterizedTest
+    @MethodSource("allMethods")
+    void shouldAddContentLengthHeaderWhenNeeded(String method) {
+        // Given
+        HttpMessage message = messageWithMethod(method);
+        message.setRequestBody("1234");
+        // When
+        plugin.updateRequestContentLength(message);
+        // Then
+        assertContentLength(message.getRequestHeader(), "4");
+    }
+
+    @ParameterizedTest
+    @MethodSource("allMethodsExceptNoEnclosedContent")
+    void shouldAddZeroContentLengthHeaderWhenNeeded(String method) {
+        // Given
+        HttpMessage message = messageWithMethod(method);
+        // When
+        plugin.updateRequestContentLength(message);
+        // Then
+        assertContentLength(message.getRequestHeader(), "0");
+    }
+
+    @ParameterizedTest
+    @MethodSource("allMethods")
+    void shouldUpdateExistingContentLengthHeaderWhenNeeded(String method) {
+        // Given
+        HttpMessage message = messageWithMethod(method);
+        message.setRequestBody("1234");
+        message.getRequestHeader().setContentLength(42);
+        // When
+        plugin.updateRequestContentLength(message);
+        // Then
+        assertContentLength(message.getRequestHeader(), "4");
+    }
+
+    private static void assertContentLength(HttpHeader header, String value) {
+        assertThat(header.getHeader(HttpHeader.CONTENT_LENGTH), is(equalTo(value)));
+    }
+
+    private static HttpMessage messageWithMethod(String method) {
+        try {
+            String header =
+                    method
+                            + (HttpRequestHeader.CONNECT.equalsIgnoreCase(method)
+                                    ? " example.com "
+                                    : " / ")
+                            + "HTTP/1.1";
+            return new HttpMessage(new HttpRequestHeader(header));
+        } catch (HttpMalformedHeaderException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static Stream<String> allMethods() {
+        return Stream.of(HttpRequestHeader.METHODS);
+    }
+
+    private static Stream<String> methodsNoEnclosedContent() {
+        return METHODS_NO_ENCLOSED_CONTENT.stream();
+    }
+
+    private static Stream<String> allMethodsExceptNoEnclosedContent() {
+        return allMethods().filter(e -> !METHODS_NO_ENCLOSED_CONTENT.contains(e));
+    }
+
     private static HttpMessage createAlertMessage() {
         return createAlertMessage(null);
     }