Efficient ETag parsing
This commit is contained in:
parent
63486bf19c
commit
bb17ad8314
|
@ -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 <a href="https://datatracker.ietf.org/doc/html/rfc7232">RFC 7232</a>
|
||||
*/
|
||||
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<ETag> parse(String source) {
|
||||
|
||||
List<ETag> 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
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -41,8 +41,6 @@ import java.util.Map;
|
|||
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<String, String>, 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 <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
|
||||
*/
|
||||
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 class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
|
||||
/**
|
||||
* 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<String> getETagValuesAsList(String headerName) {
|
||||
List<String> values = get(headerName);
|
||||
if (values != null) {
|
||||
List<String> 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<String> getETagValuesAsList(String name) {
|
||||
List<String> values = get(name);
|
||||
if (values == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
if (value != null) {
|
||||
List<ETag> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -25,13 +25,12 @@ import java.util.Locale;
|
|||
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<String> SAFE_METHODS = Set.of("GET", "HEAD");
|
||||
|
||||
/**
|
||||
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
|
||||
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
|
||||
*/
|
||||
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
|
||||
|
||||
/**
|
||||
* Date formats as specified in the HTTP RFC.
|
||||
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
|
||||
|
@ -255,20 +248,19 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 @@ class ServletWebRequestHttpMethodsTests {
|
|||
assertOkWithETag(etag);
|
||||
}
|
||||
|
||||
// gh-19127
|
||||
@SafeHttpMethodsTest
|
||||
// SPR-14559
|
||||
void ifNoneMatchShouldNotFailForUnquotedETag(String method) {
|
||||
setUpRequest(method);
|
||||
String etag = "\"etagvalue\"";
|
||||
|
|
Loading…
Reference in New Issue