Skip to content

Commit 0783f07

Browse files
committed
Improve conditional requests support
Prior to this commit, Spring MVC and Spring WebFlux would not support conditional requests with `If-Match` preconditions. As underlined in the RFC9110 Section 13.1, those are related to the `If-None-Match` conditions, but this time only performing requests if the resource matches the given ETag. This feature, and in general the `"*"` request Etag, are generally useful to prevent "lost updates" when performing a POST/PUT request: we want to ensure that we're updating a version with a known version or create a new resource only if it doesn't exist already. This commit adds `If-Match` conditional requests support and ensures that both `If-Match` and `If-None-Match` work well with `"*"` request ETags. We can't rely on `checkNotModified(null)`, as the compiler can't decide between method variants accepting an ETag `String` or a Last Modified `long`. Instead, developers should use empty ETags `""` to signal that no resource is known on the server side. Closes gh-24881
1 parent a3d3667 commit 0783f07

File tree

7 files changed

+851
-737
lines changed

7 files changed

+851
-737
lines changed

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

Lines changed: 103 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2018 the original author or authors.
2+
* Copyright 2002-2022 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.
@@ -206,97 +206,80 @@ public boolean checkNotModified(String etag) {
206206
}
207207

208208
@Override
209-
public boolean checkNotModified(@Nullable String etag, long lastModifiedTimestamp) {
209+
public boolean checkNotModified(@Nullable String eTag, long lastModifiedTimestamp) {
210210
HttpServletResponse response = getResponse();
211211
if (this.notModified || (response != null && HttpStatus.OK.value() != response.getStatus())) {
212212
return this.notModified;
213213
}
214-
215214
// Evaluate conditions in order of precedence.
216-
// See https://tools.ietf.org/html/rfc7232#section-6
217-
218-
if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
219-
if (this.notModified && response != null) {
220-
response.setStatus(HttpStatus.PRECONDITION_FAILED.value());
221-
}
215+
// See https://datatracker.ietf.org/doc/html/rfc9110#section-13.2.2
216+
if (validateIfMatch(eTag)) {
217+
updateResponseStateChanging();
222218
return this.notModified;
223219
}
224-
225-
boolean validated = validateIfNoneMatch(etag);
226-
if (!validated) {
227-
validateIfModifiedSince(lastModifiedTimestamp);
220+
// 2) If-Unmodified-Since
221+
else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
222+
updateResponseStateChanging();
223+
return this.notModified;
228224
}
229-
230-
// Update response
231-
if (response != null) {
232-
boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod());
233-
if (this.notModified) {
234-
response.setStatus(isHttpGetOrHead ?
235-
HttpStatus.NOT_MODIFIED.value() : HttpStatus.PRECONDITION_FAILED.value());
236-
}
237-
if (isHttpGetOrHead) {
238-
if (lastModifiedTimestamp > 0 && parseDateValue(response.getHeader(HttpHeaders.LAST_MODIFIED)) == -1) {
239-
response.setDateHeader(HttpHeaders.LAST_MODIFIED, lastModifiedTimestamp);
240-
}
241-
if (StringUtils.hasLength(etag) && response.getHeader(HttpHeaders.ETAG) == null) {
242-
response.setHeader(HttpHeaders.ETAG, padEtagIfNecessary(etag));
243-
}
244-
}
225+
// 3) If-None-Match
226+
if (!validateIfNoneMatch(eTag)) {
227+
// 4) If-Modified-Since
228+
validateIfModifiedSince(lastModifiedTimestamp);
245229
}
246-
230+
updateResponseIdempotent(eTag, lastModifiedTimestamp);
247231
return this.notModified;
248232
}
249233

250-
private boolean validateIfUnmodifiedSince(long lastModifiedTimestamp) {
251-
if (lastModifiedTimestamp < 0) {
234+
private boolean validateIfMatch(@Nullable String eTag) {
235+
Enumeration<String> ifMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_MATCH);
236+
if (SAFE_METHODS.contains(getRequest().getMethod())) {
252237
return false;
253238
}
254-
long ifUnmodifiedSince = parseDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
255-
if (ifUnmodifiedSince == -1) {
239+
if (!ifMatchHeaders.hasMoreElements()) {
256240
return false;
257241
}
258-
// We will perform this validation...
259-
this.notModified = (ifUnmodifiedSince < (lastModifiedTimestamp / 1000 * 1000));
242+
this.notModified = matchRequestedETags(ifMatchHeaders, eTag, false);
260243
return true;
261244
}
262245

263-
private boolean validateIfNoneMatch(@Nullable String etag) {
264-
if (!StringUtils.hasLength(etag)) {
265-
return false;
266-
}
267-
268-
Enumeration<String> ifNoneMatch;
269-
try {
270-
ifNoneMatch = getRequest().getHeaders(HttpHeaders.IF_NONE_MATCH);
271-
}
272-
catch (IllegalArgumentException ex) {
273-
return false;
274-
}
275-
if (!ifNoneMatch.hasMoreElements()) {
246+
private boolean validateIfNoneMatch(@Nullable String eTag) {
247+
Enumeration<String> ifNoneMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_NONE_MATCH);
248+
if (!ifNoneMatchHeaders.hasMoreElements()) {
276249
return false;
277250
}
251+
this.notModified = !matchRequestedETags(ifNoneMatchHeaders, eTag, true);
252+
return true;
253+
}
278254

279-
// We will perform this validation...
280-
etag = padEtagIfNecessary(etag);
281-
if (etag.startsWith("W/")) {
282-
etag = etag.substring(2);
283-
}
284-
while (ifNoneMatch.hasMoreElements()) {
285-
String clientETags = ifNoneMatch.nextElement();
286-
Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(clientETags);
287-
// Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3
288-
while (etagMatcher.find()) {
289-
if (StringUtils.hasLength(etagMatcher.group()) && etag.equals(etagMatcher.group(3))) {
290-
this.notModified = true;
291-
break;
255+
private boolean matchRequestedETags(Enumeration<String> requestedETags, @Nullable String eTag, boolean weakCompare) {
256+
eTag = padEtagIfNecessary(eTag);
257+
while (requestedETags.hasMoreElements()) {
258+
// Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
259+
Matcher eTagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(requestedETags.nextElement());
260+
while (eTagMatcher.find()) {
261+
// only consider "lost updates" checks for unsafe HTTP methods
262+
if ("*".equals(eTagMatcher.group()) && StringUtils.hasLength(eTag)
263+
&& !SAFE_METHODS.contains(getRequest().getMethod())) {
264+
return false;
265+
}
266+
if (weakCompare) {
267+
if (eTagWeakMatch(eTag, eTagMatcher.group(1))) {
268+
return false;
269+
}
270+
}
271+
else {
272+
if (eTagStrongMatch(eTag, eTagMatcher.group(1))) {
273+
return false;
274+
}
292275
}
293276
}
294277
}
295-
296278
return true;
297279
}
298280

299-
private String padEtagIfNecessary(String etag) {
281+
@Nullable
282+
private String padEtagIfNecessary(@Nullable String etag) {
300283
if (!StringUtils.hasLength(etag)) {
301284
return etag;
302285
}
@@ -306,6 +289,44 @@ private String padEtagIfNecessary(String etag) {
306289
return "\"" + etag + "\"";
307290
}
308291

292+
private boolean eTagStrongMatch(@Nullable String first, @Nullable String second) {
293+
if (!StringUtils.hasLength(first) || first.startsWith("W/")) {
294+
return false;
295+
}
296+
return first.equals(second);
297+
}
298+
299+
private boolean eTagWeakMatch(@Nullable String first, @Nullable String second) {
300+
if (!StringUtils.hasLength(first) || !StringUtils.hasLength(second)) {
301+
return false;
302+
}
303+
if (first.startsWith("W/")) {
304+
first = first.substring(2);
305+
}
306+
if (second.startsWith("W/")) {
307+
second = second.substring(2);
308+
}
309+
return first.equals(second);
310+
}
311+
312+
private void updateResponseStateChanging() {
313+
if (this.notModified && getResponse() != null) {
314+
getResponse().setStatus(HttpStatus.PRECONDITION_FAILED.value());
315+
}
316+
}
317+
318+
private boolean validateIfUnmodifiedSince(long lastModifiedTimestamp) {
319+
if (lastModifiedTimestamp < 0) {
320+
return false;
321+
}
322+
long ifUnmodifiedSince = parseDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
323+
if (ifUnmodifiedSince == -1) {
324+
return false;
325+
}
326+
this.notModified = (ifUnmodifiedSince < (lastModifiedTimestamp / 1000 * 1000));
327+
return true;
328+
}
329+
309330
private boolean validateIfModifiedSince(long lastModifiedTimestamp) {
310331
if (lastModifiedTimestamp < 0) {
311332
return false;
@@ -319,6 +340,24 @@ private boolean validateIfModifiedSince(long lastModifiedTimestamp) {
319340
return true;
320341
}
321342

343+
private void updateResponseIdempotent(String eTag, long lastModifiedTimestamp) {
344+
if (getResponse() != null) {
345+
boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod());
346+
if (this.notModified) {
347+
getResponse().setStatus(isHttpGetOrHead ?
348+
HttpStatus.NOT_MODIFIED.value() : HttpStatus.PRECONDITION_FAILED.value());
349+
}
350+
if (isHttpGetOrHead) {
351+
if (lastModifiedTimestamp > 0 && parseDateValue(getResponse().getHeader(HttpHeaders.LAST_MODIFIED)) == -1) {
352+
getResponse().setDateHeader(HttpHeaders.LAST_MODIFIED, lastModifiedTimestamp);
353+
}
354+
if (StringUtils.hasLength(eTag) && getResponse().getHeader(HttpHeaders.ETAG) == null) {
355+
getResponse().setHeader(HttpHeaders.ETAG, padEtagIfNecessary(eTag));
356+
}
357+
}
358+
}
359+
}
360+
322361
public boolean isNotModified() {
323362
return this.notModified;
324363
}

0 commit comments

Comments
 (0)