From 888232447be4c491ca9bdda547d2358721749a78 Mon Sep 17 00:00:00 2001
From: Arturo Bernal
Date: Sat, 23 Sep 2023 16:07:41 +0200
Subject: [PATCH] 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 +-
.../client5/http/protocol/RequestIfRange.java | 158 ++++++++++++++++++
.../http/protocol/TestRequestIfRange.java | 134 +++++++++++++++
3 files changed, 295 insertions(+), 1 deletion(-)
create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/protocol/RequestIfRange.java
create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/protocol/TestRequestIfRange.java
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
+ * .
+ *
+ */
+
+
+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;
+
+/**
+ *
+ * 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.
+ *
+ *
+ *
+ * Key points:
+ *
+ * - A client MUST NOT generate an If-Range header field in a request that does not contain a Range header field.
+ * - An origin server MUST ignore an If-Range header field received in a request for a target resource that does not support Range requests.
+ * - A client MUST NOT generate an If-Range header field containing an entity tag that is marked as weak.
+ * - 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.
+ * - A server that receives an If-Range header field on a Range request MUST evaluate the condition before performing the method.
+ *
+ *
+ *
+ * @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
+ * .
+ *
+ */
+
+
+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