Skip to content

Commit 8d16a50

Browse files
committed
Efficient ETag parsing
Closes gh-33372
1 parent ab161ca commit 8d16a50

File tree

4 files changed

+166
-46
lines changed

4 files changed

+166
-46
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
import org.apache.commons.logging.Log;
23+
import org.apache.commons.logging.LogFactory;
24+
25+
import org.springframework.util.StringUtils;
26+
27+
/**
28+
* Represents an ETag for HTTP conditional requests.
29+
*
30+
* @param tag the unquoted tag value
31+
* @param weak whether the entity tag is for weak or strong validation
32+
* @author Rossen Stoyanchev
33+
* @since 5.3.38
34+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7232">RFC 7232</a>
35+
*/
36+
public record ETag(String tag, boolean weak) {
37+
38+
private static final Log logger = LogFactory.getLog(ETag.class);
39+
40+
private static final ETag WILDCARD = new ETag("*", false);
41+
42+
43+
/**
44+
* Whether this a wildcard tag matching to any entity tag value.
45+
*/
46+
public boolean isWildcard() {
47+
return (this == WILDCARD);
48+
}
49+
50+
/**
51+
* Return the fully formatted tag including "W/" prefix and quotes.
52+
*/
53+
public String formattedTag() {
54+
if (isWildcard()) {
55+
return "*";
56+
}
57+
return (this.weak ? "W/" : "") + "\"" + this.tag + "\"";
58+
}
59+
60+
@Override
61+
public String toString() {
62+
return formattedTag();
63+
}
64+
65+
66+
/**
67+
* Parse entity tags from an "If-Match" or "If-None-Match" header.
68+
* @param source the source string to parse
69+
* @return the parsed ETags
70+
*/
71+
public static List<ETag> parse(String source) {
72+
73+
List<ETag> result = new ArrayList<>();
74+
State state = State.BEFORE_QUOTES;
75+
int startIndex = -1;
76+
boolean weak = false;
77+
78+
for (int i = 0; i < source.length(); i++) {
79+
char c = source.charAt(i);
80+
81+
if (state == State.IN_QUOTES) {
82+
if (c == '"') {
83+
String tag = source.substring(startIndex, i);
84+
if (StringUtils.hasText(tag)) {
85+
result.add(new ETag(tag, weak));
86+
}
87+
state = State.AFTER_QUOTES;
88+
startIndex = -1;
89+
weak = false;
90+
}
91+
continue;
92+
}
93+
94+
if (Character.isWhitespace(c)) {
95+
continue;
96+
}
97+
98+
if (c == ',') {
99+
state = State.BEFORE_QUOTES;
100+
continue;
101+
}
102+
103+
if (state == State.BEFORE_QUOTES) {
104+
if (c == '*') {
105+
result.add(WILDCARD);
106+
state = State.AFTER_QUOTES;
107+
continue;
108+
}
109+
if (c == '"') {
110+
state = State.IN_QUOTES;
111+
startIndex = i + 1;
112+
continue;
113+
}
114+
if (c == 'W' && source.length() > i + 2) {
115+
if (source.charAt(i + 1) == '/' && source.charAt(i + 2) == '"') {
116+
state = State.IN_QUOTES;
117+
i = i + 2;
118+
startIndex = i + 1;
119+
weak = true;
120+
continue;
121+
}
122+
}
123+
}
124+
125+
if (logger.isDebugEnabled()) {
126+
logger.debug("Unexpected char at index " + i);
127+
}
128+
}
129+
130+
if (state != State.IN_QUOTES && logger.isDebugEnabled()) {
131+
logger.debug("Expected closing '\"'");
132+
}
133+
134+
return result;
135+
}
136+
137+
138+
private enum State {
139+
140+
BEFORE_QUOTES, IN_QUOTES, AFTER_QUOTES
141+
142+
}
143+
144+
}

spring-web/src/main/java/org/springframework/http/HttpHeaders.java

+14-30
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@
4141
import java.util.Set;
4242
import java.util.StringJoiner;
4343
import java.util.function.BiConsumer;
44-
import java.util.regex.Matcher;
45-
import java.util.regex.Pattern;
4644
import java.util.stream.Collectors;
4745

4846
import org.springframework.lang.Nullable;
@@ -394,12 +392,6 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
394392
*/
395393
public static final HttpHeaders EMPTY = new ReadOnlyHttpHeaders(new LinkedMultiValueMap<>());
396394

397-
/**
398-
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
399-
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
400-
*/
401-
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
402-
403395
private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = new DecimalFormatSymbols(Locale.ENGLISH);
404396

405397
private static final ZoneId GMT = ZoneId.of("GMT");
@@ -1625,35 +1617,27 @@ public void clearContentHeaders() {
16251617

16261618
/**
16271619
* Retrieve a combined result from the field values of the ETag header.
1628-
* @param headerName the header name
1620+
* @param name the header name
16291621
* @return the combined result
16301622
* @throws IllegalArgumentException if parsing fails
16311623
* @since 4.3
16321624
*/
1633-
protected List<String> getETagValuesAsList(String headerName) {
1634-
List<String> values = get(headerName);
1635-
if (values != null) {
1636-
List<String> result = new ArrayList<>();
1637-
for (String value : values) {
1638-
if (value != null) {
1639-
Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value);
1640-
while (matcher.find()) {
1641-
if ("*".equals(matcher.group())) {
1642-
result.add(matcher.group());
1643-
}
1644-
else {
1645-
result.add(matcher.group(1));
1646-
}
1647-
}
1648-
if (result.isEmpty()) {
1649-
throw new IllegalArgumentException(
1650-
"Could not parse header '" + headerName + "' with value '" + value + "'");
1651-
}
1625+
protected List<String> getETagValuesAsList(String name) {
1626+
List<String> values = get(name);
1627+
if (values == null) {
1628+
return Collections.emptyList();
1629+
}
1630+
List<String> result = new ArrayList<>();
1631+
for (String value : values) {
1632+
if (value != null) {
1633+
List<ETag> tags = ETag.parse(value);
1634+
Assert.notEmpty(tags, "Could not parse header '" + name + "' with value '" + value + "'");
1635+
for (ETag tag : tags) {
1636+
result.add(tag.formattedTag());
16521637
}
16531638
}
1654-
return result;
16551639
}
1656-
return Collections.emptyList();
1640+
return result;
16571641
}
16581642

16591643
/**

spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java

+6-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -25,13 +25,12 @@
2525
import java.util.Map;
2626
import java.util.Set;
2727
import java.util.TimeZone;
28-
import java.util.regex.Matcher;
29-
import java.util.regex.Pattern;
3028

3129
import jakarta.servlet.http.HttpServletRequest;
3230
import jakarta.servlet.http.HttpServletResponse;
3331
import jakarta.servlet.http.HttpSession;
3432

33+
import org.springframework.http.ETag;
3534
import org.springframework.http.HttpHeaders;
3635
import org.springframework.http.HttpMethod;
3736
import org.springframework.http.HttpStatus;
@@ -53,12 +52,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
5352

5453
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD");
5554

56-
/**
57-
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
58-
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
59-
*/
60-
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
61-
6255
/**
6356
* Date formats as specified in the HTTP RFC.
6457
* @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 @@ private boolean matchRequestedETags(Enumeration<String> requestedETags, @Nullabl
255248
eTag = padEtagIfNecessary(eTag);
256249
while (requestedETags.hasMoreElements()) {
257250
// Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
258-
Matcher eTagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(requestedETags.nextElement());
259-
while (eTagMatcher.find()) {
251+
for (ETag requestedETag : ETag.parse(requestedETags.nextElement())) {
260252
// only consider "lost updates" checks for unsafe HTTP methods
261-
if ("*".equals(eTagMatcher.group()) && StringUtils.hasLength(eTag)
253+
if (requestedETag.isWildcard() && StringUtils.hasLength(eTag)
262254
&& !SAFE_METHODS.contains(getRequest().getMethod())) {
263255
return false;
264256
}
265257
if (weakCompare) {
266-
if (eTagWeakMatch(eTag, eTagMatcher.group(1))) {
258+
if (eTagWeakMatch(eTag, requestedETag.formattedTag())) {
267259
return false;
268260
}
269261
}
270262
else {
271-
if (eTagStrongMatch(eTag, eTagMatcher.group(1))) {
263+
if (eTagStrongMatch(eTag, requestedETag.formattedTag())) {
272264
return false;
273265
}
274266
}

spring-web/src/test/java/org/springframework/web/context/request/ServletWebRequestHttpMethodsTests.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -163,8 +163,8 @@ void ifNoneMatchShouldNotMatchDifferentETag(String method) {
163163
assertOkWithETag(etag);
164164
}
165165

166+
// gh-19127
166167
@SafeHttpMethodsTest
167-
// SPR-14559
168168
void ifNoneMatchShouldNotFailForUnquotedETag(String method) {
169169
setUpRequest(method);
170170
String etag = "\"etagvalue\"";

0 commit comments

Comments
 (0)