From 582bfccbb72e5c8959a0b472d1dc7d03a20520f3 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 14 Aug 2024 07:49:31 +0300 Subject: [PATCH] Efficient ETag parsing Closes gh-33372 --- .../java/org/springframework/http/ETag.java | 161 ++++++++++++++++++ .../org/springframework/http/HttpHeaders.java | 44 ++--- .../context/request/ServletWebRequest.java | 18 +- 3 files changed, 180 insertions(+), 43 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..c4d670d50cbc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/ETag.java @@ -0,0 +1,161 @@ +/* + * 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. + * + * @author Rossen Stoyanchev + * @since 5.3.38 + * @see RFC 7232 + */ +public class ETag { + + private static final Log logger = LogFactory.getLog(ETag.class); + + private static final ETag WILDCARD = new ETag("*", false); + + + private final String tag; + + private final boolean weak; + + + public ETag(String tag, boolean weak) { + this.tag = tag; + this.weak = weak; + } + + + public String tag() { + return this.tag; + } + + public boolean weak() { + return this.weak; + } + + /** + * 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 (this == WILDCARD) { + 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 cb1dee45bbaa..5fe14a360be4 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -40,8 +40,6 @@ import java.util.Map; import java.util.Set; import java.util.StringJoiner; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import org.springframework.lang.Nullable; @@ -393,12 +391,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"); @@ -1568,35 +1560,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 27ea338ee2dd..8bb3b4817f6b 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 @@ -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. @@ -26,13 +26,12 @@ import java.util.Locale; import java.util.Map; import java.util.TimeZone; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; +import org.springframework.http.ETag; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -54,12 +53,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ private static final List SAFE_METHODS = Arrays.asList("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 @@ -289,11 +282,10 @@ private boolean validateIfNoneMatch(@Nullable String etag) { etag = etag.substring(2); } while (ifNoneMatch.hasMoreElements()) { - String clientETags = ifNoneMatch.nextElement(); - Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(clientETags); // Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3 - while (etagMatcher.find()) { - if (StringUtils.hasLength(etagMatcher.group()) && etag.equals(etagMatcher.group(3))) { + for (ETag requestedETag : ETag.parse(ifNoneMatch.nextElement())) { + String tag = requestedETag.tag(); + if (StringUtils.hasLength(tag) && etag.equals(padEtagIfNecessary(tag))) { this.notModified = true; break; }