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: + *

+ *

+ * + * @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