From bb17ad8314b81850a939fd265fb53b3361705e85 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 14 Aug 2024 07:19:59 +0300 Subject: [PATCH] Efficient ETag parsing --- .../java/org/springframework/http/ETag.java | 144 ++++++++++++++++++ .../org/springframework/http/HttpHeaders.java | 44 ++---- .../context/request/ServletWebRequest.java | 18 +-- .../ServletWebRequestHttpMethodsTests.java | 4 +- 4 files changed, 165 insertions(+), 45 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/ETag.java diff --git a/spring-web/src/main/java/org/springframework/http/ETag.java b/spring-web/src/main/java/org/springframework/http/ETag.java new file mode 100644 index 000000000000..e279ac6a05b4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/ETag.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.http; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.StringUtils; + +/** + * Represents an ETag for HTTP conditional requests. + * + * @param tag the unquoted tag value + * @param weak whether the entity tag is for weak or strong validation + * @author Rossen Stoyanchev + * @since 5.3.38 + * @see RFC 7232 + */ +public record ETag(String tag, boolean weak) { + + private static final Log logger = LogFactory.getLog(ETag.class); + + private static final ETag WILDCARD = new ETag("*", false); + + + /** + * Whether this a wildcard tag matching to any entity tag value. + */ + public boolean isWildcard() { + return (this == WILDCARD); + } + + /** + * Return the fully formatted tag including "W/" prefix and quotes. + */ + public String formattedTag() { + if (isWildcard()) { + return "*"; + } + return (this.weak ? "W/" : "") + "\"" + this.tag + "\""; + } + + @Override + public String toString() { + return formattedTag(); + } + + + /** + * Parse entity tags from an "If-Match" or "If-None-Match" header. + * @param source the source string to parse + * @return the parsed ETags + */ + public static List parse(String source) { + + List result = new ArrayList<>(); + State state = State.BEFORE_QUOTES; + int startIndex = -1; + boolean weak = false; + + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + + if (state == State.IN_QUOTES) { + if (c == '"') { + String tag = source.substring(startIndex, i); + if (StringUtils.hasText(tag)) { + result.add(new ETag(tag, weak)); + } + state = State.AFTER_QUOTES; + startIndex = -1; + weak = false; + } + continue; + } + + if (Character.isWhitespace(c)) { + continue; + } + + if (c == ',') { + state = State.BEFORE_QUOTES; + continue; + } + + if (state == State.BEFORE_QUOTES) { + if (c == '*') { + result.add(WILDCARD); + state = State.AFTER_QUOTES; + continue; + } + if (c == '"') { + state = State.IN_QUOTES; + startIndex = i + 1; + continue; + } + if (c == 'W' && source.length() > i + 2) { + if (source.charAt(i + 1) == '/' && source.charAt(i + 2) == '"') { + state = State.IN_QUOTES; + i = i + 2; + startIndex = i + 1; + weak = true; + continue; + } + } + } + + if (logger.isDebugEnabled()) { + logger.debug("Unexpected char at index " + i); + } + } + + if (state != State.IN_QUOTES && logger.isDebugEnabled()) { + logger.debug("Expected closing '\"'"); + } + + return result; + } + + + private enum State { + + BEFORE_QUOTES, IN_QUOTES, AFTER_QUOTES + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 6afe9f5003ba..521382cbdf24 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -41,8 +41,6 @@ import java.util.Set; import java.util.StringJoiner; import java.util.function.BiConsumer; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import org.springframework.lang.Nullable; @@ -394,12 +392,6 @@ public class HttpHeaders implements MultiValueMap, Serializable */ public static final HttpHeaders EMPTY = new ReadOnlyHttpHeaders(new LinkedMultiValueMap<>()); - /** - * Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match". - * @see Section 2.3 of RFC 7232 - */ - private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?"); - private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = new DecimalFormatSymbols(Locale.ENGLISH); private static final ZoneId GMT = ZoneId.of("GMT"); @@ -1629,35 +1621,27 @@ public void clearContentHeaders() { /** * Retrieve a combined result from the field values of the ETag header. - * @param headerName the header name + * @param name the header name * @return the combined result * @throws IllegalArgumentException if parsing fails * @since 4.3 */ - protected List getETagValuesAsList(String headerName) { - List values = get(headerName); - if (values != null) { - List result = new ArrayList<>(); - for (String value : values) { - if (value != null) { - Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value); - while (matcher.find()) { - if ("*".equals(matcher.group())) { - result.add(matcher.group()); - } - else { - result.add(matcher.group(1)); - } - } - if (result.isEmpty()) { - throw new IllegalArgumentException( - "Could not parse header '" + headerName + "' with value '" + value + "'"); - } + protected List getETagValuesAsList(String name) { + List values = get(name); + if (values == null) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (String value : values) { + if (value != null) { + List tags = ETag.parse(value); + Assert.notEmpty(tags, "Could not parse header '" + name + "' with value '" + value + "'"); + for (ETag tag : tags) { + result.add(tag.formattedTag()); } } - return result; } - return Collections.emptyList(); + return result; } /** diff --git a/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java b/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java index 8545894cfff0..462fd89cf7fc 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java @@ -25,13 +25,12 @@ import java.util.Map; import java.util.Set; import java.util.TimeZone; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import org.springframework.http.ETag; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -53,12 +52,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ private static final Set SAFE_METHODS = Set.of("GET", "HEAD"); - /** - * Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match". - * @see Section 2.3 of RFC 7232 - */ - private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?"); - /** * Date formats as specified in the HTTP RFC. * @see Section 7.1.1.1 of RFC 7231 @@ -255,20 +248,19 @@ private boolean matchRequestedETags(Enumeration requestedETags, @Nullabl etag = padEtagIfNecessary(etag); while (requestedETags.hasMoreElements()) { // Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3 - Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(requestedETags.nextElement()); - while (etagMatcher.find()) { + for (ETag requestedETag : ETag.parse(requestedETags.nextElement())) { // only consider "lost updates" checks for unsafe HTTP methods - if ("*".equals(etagMatcher.group()) && StringUtils.hasLength(etag) + if (requestedETag.isWildcard() && StringUtils.hasLength(etag) && !SAFE_METHODS.contains(getRequest().getMethod())) { return false; } if (weakCompare) { - if (etagWeakMatch(etag, etagMatcher.group(1))) { + if (etagWeakMatch(etag, requestedETag.formattedTag())) { return false; } } else { - if (etagStrongMatch(etag, etagMatcher.group(1))) { + if (etagStrongMatch(etag, requestedETag.formattedTag())) { return false; } } diff --git a/spring-web/src/test/java/org/springframework/web/context/request/ServletWebRequestHttpMethodsTests.java b/spring-web/src/test/java/org/springframework/web/context/request/ServletWebRequestHttpMethodsTests.java index 81cdb8e47f95..7fdc186b6a1f 100644 --- a/spring-web/src/test/java/org/springframework/web/context/request/ServletWebRequestHttpMethodsTests.java +++ b/spring-web/src/test/java/org/springframework/web/context/request/ServletWebRequestHttpMethodsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -163,8 +163,8 @@ void ifNoneMatchShouldNotMatchDifferentETag(String method) { assertOkWithETag(etag); } + // gh-19127 @SafeHttpMethodsTest - // SPR-14559 void ifNoneMatchShouldNotFailForUnquotedETag(String method) { setUpRequest(method); String etag = "\"etagvalue\"";