You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2023/09/23 14:07:47 UTC

[httpcomponents-client] branch 5.4.x updated: HTTPCLIENT-2293 - Implement 'If-Range' request validation as per RFC 9110 (#485)

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

abernal pushed a commit to branch 5.4.x
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


The following commit(s) were added to refs/heads/5.4.x by this push:
     new 16aa55834 HTTPCLIENT-2293 - Implement 'If-Range' request validation as per RFC 9110 (#485)
16aa55834 is described below

commit 16aa5583477b6d6ed6d37632d36678689ec23aef
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Sat Sep 23 16:07:41 2023 +0200

    HTTPCLIENT-2293 - Implement 'If-Range' request validation as per RFC 9110 (#485)
    
    - Ensure the presence of 'Range' header when 'If-Range' is specified.
    - Enforce strong validator requirements when 'If-Range' is paired with a Date.
    - Exit processing early if 'Last-Modified' header is missing, ensuring strong validation adherence.
---
 .../http/impl/classic/HttpClientBuilder.java       |   4 +-
 .../hc/client5/http/protocol/RequestIfRange.java   | 158 +++++++++++++++++++++
 .../client5/http/protocol/TestRequestIfRange.java  | 134 +++++++++++++++++
 3 files changed, 295 insertions(+), 1 deletion(-)

diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
index 8711f4315..b2f41dfeb 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java
@@ -80,6 +80,7 @@ import org.apache.hc.client5.http.protocol.RequestAddCookies;
 import org.apache.hc.client5.http.protocol.RequestClientConnControl;
 import org.apache.hc.client5.http.protocol.RequestDefaultHeaders;
 import org.apache.hc.client5.http.protocol.RequestExpectContinue;
+import org.apache.hc.client5.http.protocol.RequestIfRange;
 import org.apache.hc.client5.http.protocol.ResponseProcessCookies;
 import org.apache.hc.client5.http.routing.HttpRoutePlanner;
 import org.apache.hc.core5.annotation.Internal;
@@ -818,7 +819,8 @@ public class HttpClientBuilder {
                 new RequestTargetHost(),
                 new RequestClientConnControl(),
                 new RequestUserAgent(userAgentCopy),
-                new RequestExpectContinue());
+                new RequestExpectContinue(),
+                new RequestIfRange());
         if (!cookieManagementDisabled) {
             b.add(RequestAddCookies.INSTANCE);
         }
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/RequestIfRange.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/RequestIfRange.java
new file mode 100644
index 000000000..3910e619e
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/RequestIfRange.java
@@ -0,0 +1,158 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+
+package org.apache.hc.client5.http.protocol;
+
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpRequestInterceptor;
+import org.apache.hc.core5.http.ProtocolException;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * <p>
+ * The {@code RequestIfRange} interceptor ensures that the request adheres to the RFC guidelines for the 'If-Range' header.
+ * The "If-Range" header field is used in conjunction with the Range header to conditionally request parts of a representation.
+ * If the validator given in the "If-Range" header matches the current validator for the representation, then the server should respond with the specified range of the document.
+ * If they do not match, the server should return the entire representation.
+ * </p>
+ *
+ * <p>
+ * Key points:
+ * <ul>
+ *     <li>A client MUST NOT generate an If-Range header field in a request that does not contain a Range header field.</li>
+ *     <li>An origin server MUST ignore an If-Range header field received in a request for a target resource that does not support Range requests.</li>
+ *     <li>A client MUST NOT generate an If-Range header field containing an entity tag that is marked as weak.</li>
+ *     <li>A client MUST NOT generate an If-Range header field containing an HTTP-date unless the client has no entity tag for the corresponding representation and the date is a strong validator.</li>
+ *     <li>A server that receives an If-Range header field on a Range request MUST evaluate the condition before performing the method.</li>
+ * </ul>
+ * </p>
+ *
+ * @since 5.4
+ */
+@Contract(threading = ThreadingBehavior.IMMUTABLE)
+public class RequestIfRange implements HttpRequestInterceptor {
+
+    /**
+     * This {@link DateTimeFormatter} is used to format and parse date-time objects in a specific format commonly
+     * used in HTTP protocol messages. The format includes the day of the week, day of the month, month, year, and time
+     * of day, all represented in GMT time. An example of a date-time string in this format is "Tue, 15 Nov 1994 08:12:31 GMT".
+     */
+    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME;
+
+    /**
+     * Singleton instance.
+     *
+     * @since 5.4
+     */
+    public static final RequestIfRange INSTANCE = new RequestIfRange();
+
+    public RequestIfRange() {
+        super();
+    }
+
+    /**
+     * Processes the given request to ensure it adheres to the RFC guidelines for the 'If-Range' header.
+     *
+     * @param request The HTTP request to be processed.
+     * @param entity  The entity details of the request.
+     * @param context The HTTP context.
+     * @throws HttpException If the request does not adhere to the RFC guidelines.
+     * @throws IOException   If an I/O error occurs.
+     */
+    @Override
+    public void process(final HttpRequest request, final EntityDetails entity, final HttpContext context)
+            throws HttpException, IOException {
+        Args.notNull(request, "HTTP request");
+
+        final Header ifRangeHeader = request.getFirstHeader(HttpHeaders.IF_RANGE);
+
+        // If there's no If-Range header, just return
+        if (ifRangeHeader == null) {
+            return;
+        }
+
+        // If there's an If-Range header but no Range header, throw an exception
+        if (!request.containsHeader(HttpHeaders.RANGE)) {
+            throw new ProtocolException("Request with 'If-Range' header must also contain a 'Range' header.");
+        }
+
+        final Header eTag = request.getFirstHeader(HttpHeaders.ETAG);
+
+        // If there's a weak ETag in the If-Range header, throw an exception
+        if (eTag != null && eTag.getValue().startsWith("W/")) {
+            throw new ProtocolException("'If-Range' header must not contain a weak entity tag.");
+        }
+
+        final Header dateHeader = request.getFirstHeader(HttpHeaders.DATE);
+
+        if (dateHeader == null) {
+            return;
+        }
+
+
+        final Instant lastModifiedInstant;
+        final Instant dateInstant;
+        final Header lastModifiedHeader = request.getFirstHeader(HttpHeaders.LAST_MODIFIED);
+
+        if (lastModifiedHeader != null) {
+            final String lastModifiedValue = lastModifiedHeader.getValue();
+            lastModifiedInstant = FORMATTER.parse(lastModifiedValue, Instant::from);
+        }
+        else {
+            // If there's no Last-Modified header, we exit early because we can't deduce that it is strong.
+            return;
+        }
+
+        final String dateValue = dateHeader.getValue();
+        dateInstant = FORMATTER.parse(dateValue, Instant::from);
+
+        long difference = 0;
+        if (lastModifiedInstant != null && dateInstant != null) {
+            difference = Duration.between(lastModifiedInstant, dateInstant).getSeconds();
+        }
+
+        // If the difference between the Last-Modified and Date headers is less than 1 second, throw an exception
+        if (difference < 1 && eTag!= null) {
+            throw new ProtocolException("'If-Range' header with a Date must be a strong validator.");
+        }
+    }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestRequestIfRange.java b/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestRequestIfRange.java
new file mode 100644
index 000000000..89acd0993
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestRequestIfRange.java
@@ -0,0 +1,134 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+
+package org.apache.hc.client5.http.protocol;
+
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Answers.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.ProtocolException;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+class TestRequestIfRange {
+    @Mock
+    private HttpRequest request;
+
+    @Mock
+    private EntityDetails entity;
+
+    @Mock
+    private HttpContext context;
+
+    private RequestIfRange requestIfRange;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        requestIfRange = new RequestIfRange();
+    }
+
+    @Test
+    void testNoIfRangeHeader() throws Exception {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(null);
+        requestIfRange.process(request, entity, context);
+        // No exception should be thrown
+    }
+
+    @Test
+    void testIfRangeWithoutRangeHeader() {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(mock(Header.class));
+        when(request.containsHeader(HttpHeaders.RANGE)).thenReturn(false);
+
+        assertThrows(ProtocolException.class, () -> requestIfRange.process(request, entity, context));
+    }
+
+    @Test
+    void testWeakETagInIfRange() {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(mock(Header.class));
+        when(request.containsHeader(HttpHeaders.RANGE)).thenReturn(true);
+        when(request.getFirstHeader(HttpHeaders.ETAG)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.ETAG).getValue()).thenReturn("W/\"weak-etag\"");
+
+        assertThrows(ProtocolException.class, () -> requestIfRange.process(request, entity, context));
+    }
+
+
+    @Test
+    void testDateHeaderWithStrongValidator() throws Exception {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(mock(Header.class));
+        when(request.containsHeader(HttpHeaders.RANGE)).thenReturn(true);
+        when(request.getFirstHeader(HttpHeaders.DATE)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.DATE).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:31 GMT");
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:30 GMT");
+
+        requestIfRange.process(request, entity, context);
+        // No exception should be thrown
+    }
+
+    @Test
+    void testSmallDifferenceWithETagPresent() {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(mock(Header.class));
+        when(request.containsHeader(HttpHeaders.RANGE)).thenReturn(true);
+        when(request.getFirstHeader(HttpHeaders.DATE)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.DATE).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:30 GMT"); // Same as Last-Modified
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:30 GMT");
+        final Header mockETagHeader = mock(Header.class);
+        when(mockETagHeader.getValue()).thenReturn("\"some-value\""); // Mocking a weak ETag value
+        when(request.getFirstHeader(HttpHeaders.ETAG)).thenReturn(mockETagHeader);
+
+        assertThrows(ProtocolException.class, () -> requestIfRange.process(request, entity, context));
+    }
+
+    @Test
+    void testSmallDifferenceWithETagAbsent() throws Exception {
+        when(request.getFirstHeader(HttpHeaders.IF_RANGE)).thenReturn(mock(Header.class));
+        when(request.containsHeader(HttpHeaders.RANGE)).thenReturn(true);
+        when(request.getFirstHeader(HttpHeaders.DATE)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.DATE).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:31 GMT");
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED)).thenReturn(mock(Header.class, RETURNS_DEEP_STUBS));
+        when(request.getFirstHeader(HttpHeaders.LAST_MODIFIED).getValue()).thenReturn("Tue, 15 Nov 2022 08:12:30 GMT");
+        when(request.getFirstHeader(HttpHeaders.ETAG)).thenReturn(null);
+
+        requestIfRange.process(request, entity, context);
+        // No exception should be thrown
+    }
+
+}
\ No newline at end of file