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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2018 the original author or authors.
|
||||
* Copyright 2002-2022 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.
|
||||
@@ -206,45 +206,113 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkNotModified(@Nullable String etag, long lastModifiedTimestamp) {
|
||||
public boolean checkNotModified(@Nullable String eTag, long lastModifiedTimestamp) {
|
||||
HttpServletResponse response = getResponse();
|
||||
if (this.notModified || (response != null && HttpStatus.OK.value() != response.getStatus())) {
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
// Evaluate conditions in order of precedence.
|
||||
// See https://tools.ietf.org/html/rfc7232#section-6
|
||||
|
||||
if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
|
||||
if (this.notModified && response != null) {
|
||||
response.setStatus(HttpStatus.PRECONDITION_FAILED.value());
|
||||
}
|
||||
// See https://datatracker.ietf.org/doc/html/rfc9110#section-13.2.2
|
||||
if (validateIfMatch(eTag)) {
|
||||
updateResponseStateChanging();
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
boolean validated = validateIfNoneMatch(etag);
|
||||
if (!validated) {
|
||||
// 2) If-Unmodified-Since
|
||||
else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
|
||||
updateResponseStateChanging();
|
||||
return this.notModified;
|
||||
}
|
||||
// 3) If-None-Match
|
||||
if (!validateIfNoneMatch(eTag)) {
|
||||
// 4) If-Modified-Since
|
||||
validateIfModifiedSince(lastModifiedTimestamp);
|
||||
}
|
||||
updateResponseIdempotent(eTag, lastModifiedTimestamp);
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
// Update response
|
||||
if (response != null) {
|
||||
boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod());
|
||||
if (this.notModified) {
|
||||
response.setStatus(isHttpGetOrHead ?
|
||||
HttpStatus.NOT_MODIFIED.value() : HttpStatus.PRECONDITION_FAILED.value());
|
||||
}
|
||||
if (isHttpGetOrHead) {
|
||||
if (lastModifiedTimestamp > 0 && parseDateValue(response.getHeader(HttpHeaders.LAST_MODIFIED)) == -1) {
|
||||
response.setDateHeader(HttpHeaders.LAST_MODIFIED, lastModifiedTimestamp);
|
||||
private boolean validateIfMatch(@Nullable String eTag) {
|
||||
Enumeration<String> ifMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_MATCH);
|
||||
if (SAFE_METHODS.contains(getRequest().getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if (!ifMatchHeaders.hasMoreElements()) {
|
||||
return false;
|
||||
}
|
||||
this.notModified = matchRequestedETags(ifMatchHeaders, eTag, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean validateIfNoneMatch(@Nullable String eTag) {
|
||||
Enumeration<String> ifNoneMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_NONE_MATCH);
|
||||
if (!ifNoneMatchHeaders.hasMoreElements()) {
|
||||
return false;
|
||||
}
|
||||
this.notModified = !matchRequestedETags(ifNoneMatchHeaders, eTag, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean matchRequestedETags(Enumeration<String> requestedETags, @Nullable String eTag, boolean weakCompare) {
|
||||
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()) {
|
||||
// only consider "lost updates" checks for unsafe HTTP methods
|
||||
if ("*".equals(eTagMatcher.group()) && StringUtils.hasLength(eTag)
|
||||
&& !SAFE_METHODS.contains(getRequest().getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if (StringUtils.hasLength(etag) && response.getHeader(HttpHeaders.ETAG) == null) {
|
||||
response.setHeader(HttpHeaders.ETAG, padEtagIfNecessary(etag));
|
||||
if (weakCompare) {
|
||||
if (eTagWeakMatch(eTag, eTagMatcher.group(1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (eTagStrongMatch(eTag, eTagMatcher.group(1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.notModified;
|
||||
@Nullable
|
||||
private String padEtagIfNecessary(@Nullable String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return etag;
|
||||
}
|
||||
if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) {
|
||||
return etag;
|
||||
}
|
||||
return "\"" + etag + "\"";
|
||||
}
|
||||
|
||||
private boolean eTagStrongMatch(@Nullable String first, @Nullable String second) {
|
||||
if (!StringUtils.hasLength(first) || first.startsWith("W/")) {
|
||||
return false;
|
||||
}
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private boolean eTagWeakMatch(@Nullable String first, @Nullable String second) {
|
||||
if (!StringUtils.hasLength(first) || !StringUtils.hasLength(second)) {
|
||||
return false;
|
||||
}
|
||||
if (first.startsWith("W/")) {
|
||||
first = first.substring(2);
|
||||
}
|
||||
if (second.startsWith("W/")) {
|
||||
second = second.substring(2);
|
||||
}
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private void updateResponseStateChanging() {
|
||||
if (this.notModified && getResponse() != null) {
|
||||
getResponse().setStatus(HttpStatus.PRECONDITION_FAILED.value());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validateIfUnmodifiedSince(long lastModifiedTimestamp) {
|
||||
@@ -255,57 +323,10 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
|
||||
if (ifUnmodifiedSince == -1) {
|
||||
return false;
|
||||
}
|
||||
// We will perform this validation...
|
||||
this.notModified = (ifUnmodifiedSince < (lastModifiedTimestamp / 1000 * 1000));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean validateIfNoneMatch(@Nullable String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Enumeration<String> ifNoneMatch;
|
||||
try {
|
||||
ifNoneMatch = getRequest().getHeaders(HttpHeaders.IF_NONE_MATCH);
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
return false;
|
||||
}
|
||||
if (!ifNoneMatch.hasMoreElements()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We will perform this validation...
|
||||
etag = padEtagIfNecessary(etag);
|
||||
if (etag.startsWith("W/")) {
|
||||
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))) {
|
||||
this.notModified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String padEtagIfNecessary(String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return etag;
|
||||
}
|
||||
if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) {
|
||||
return etag;
|
||||
}
|
||||
return "\"" + etag + "\"";
|
||||
}
|
||||
|
||||
private boolean validateIfModifiedSince(long lastModifiedTimestamp) {
|
||||
if (lastModifiedTimestamp < 0) {
|
||||
return false;
|
||||
@@ -319,6 +340,24 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateResponseIdempotent(String eTag, long lastModifiedTimestamp) {
|
||||
if (getResponse() != null) {
|
||||
boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod());
|
||||
if (this.notModified) {
|
||||
getResponse().setStatus(isHttpGetOrHead ?
|
||||
HttpStatus.NOT_MODIFIED.value() : HttpStatus.PRECONDITION_FAILED.value());
|
||||
}
|
||||
if (isHttpGetOrHead) {
|
||||
if (lastModifiedTimestamp > 0 && parseDateValue(getResponse().getHeader(HttpHeaders.LAST_MODIFIED)) == -1) {
|
||||
getResponse().setDateHeader(HttpHeaders.LAST_MODIFIED, lastModifiedTimestamp);
|
||||
}
|
||||
if (StringUtils.hasLength(eTag) && getResponse().getHeader(HttpHeaders.ETAG) == null) {
|
||||
getResponse().setHeader(HttpHeaders.ETAG, padEtagIfNecessary(eTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNotModified() {
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2019 the original author or authors.
|
||||
* Copyright 2002-2022 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.
|
||||
@@ -57,6 +57,7 @@ import org.springframework.web.server.session.WebSessionManager;
|
||||
* Default implementation of {@link ServerWebExchange}.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @author Brian Clozel
|
||||
* @since 5.0
|
||||
*/
|
||||
public class DefaultServerWebExchange implements ServerWebExchange {
|
||||
@@ -249,44 +250,135 @@ public class DefaultServerWebExchange implements ServerWebExchange {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkNotModified(@Nullable String etag, Instant lastModified) {
|
||||
public boolean checkNotModified(@Nullable String eTag, Instant lastModified) {
|
||||
HttpStatusCode status = getResponse().getStatusCode();
|
||||
if (this.notModified || (status != null && !HttpStatus.OK.equals(status))) {
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
// Evaluate conditions in order of precedence.
|
||||
// See https://tools.ietf.org/html/rfc7232#section-6
|
||||
|
||||
if (validateIfUnmodifiedSince(lastModified)) {
|
||||
if (this.notModified) {
|
||||
getResponse().setStatusCode(HttpStatus.PRECONDITION_FAILED);
|
||||
}
|
||||
// See https://datatracker.ietf.org/doc/html/rfc9110#section-13.2.2
|
||||
// 1) If-Match
|
||||
if (validateIfMatch(eTag)) {
|
||||
updateResponseStateChanging();
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
boolean validated = validateIfNoneMatch(etag);
|
||||
if (!validated) {
|
||||
// 2) If-Unmodified-Since
|
||||
else if (validateIfUnmodifiedSince(lastModified)) {
|
||||
updateResponseStateChanging();
|
||||
return this.notModified;
|
||||
}
|
||||
// 3) If-None-Match
|
||||
if (!validateIfNoneMatch(eTag)) {
|
||||
// 4) If-Modified-Since
|
||||
validateIfModifiedSince(lastModified);
|
||||
}
|
||||
updateResponseIdempotent(eTag, lastModified);
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
// Update response
|
||||
private boolean validateIfMatch(@Nullable String eTag) {
|
||||
try {
|
||||
if (SAFE_METHODS.contains(getRequest().getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if (CollectionUtils.isEmpty(getRequest().getHeaders().get(HttpHeaders.IF_MATCH))) {
|
||||
return false;
|
||||
}
|
||||
this.notModified = matchRequestedETags(getRequestHeaders().getIfMatch(), eTag, false);
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean isHttpGetOrHead = SAFE_METHODS.contains(getRequest().getMethod());
|
||||
private boolean matchRequestedETags(List<String> requestedETags, @Nullable String eTag, boolean weakCompare) {
|
||||
eTag = padEtagIfNecessary(eTag);
|
||||
for (String clientEtag : requestedETags) {
|
||||
// only consider "lost updates" checks for unsafe HTTP methods
|
||||
if ("*".equals(clientEtag) && StringUtils.hasLength(eTag)
|
||||
&& !SAFE_METHODS.contains(getRequest().getMethod())) {
|
||||
return false;
|
||||
}
|
||||
// Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
|
||||
if (weakCompare) {
|
||||
if (eTagWeakMatch(eTag, clientEtag)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (eTagStrongMatch(eTag, clientEtag)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String padEtagIfNecessary(@Nullable String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return etag;
|
||||
}
|
||||
if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) {
|
||||
return etag;
|
||||
}
|
||||
return "\"" + etag + "\"";
|
||||
}
|
||||
|
||||
private boolean eTagStrongMatch(@Nullable String first, @Nullable String second) {
|
||||
if (!StringUtils.hasLength(first) || first.startsWith("W/")) {
|
||||
return false;
|
||||
}
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private boolean eTagWeakMatch(@Nullable String first, @Nullable String second) {
|
||||
if (!StringUtils.hasLength(first) || !StringUtils.hasLength(second)) {
|
||||
return false;
|
||||
}
|
||||
if (first.startsWith("W/")) {
|
||||
first = first.substring(2);
|
||||
}
|
||||
if (second.startsWith("W/")) {
|
||||
second = second.substring(2);
|
||||
}
|
||||
return first.equals(second);
|
||||
}
|
||||
|
||||
private void updateResponseStateChanging() {
|
||||
if (this.notModified) {
|
||||
getResponse().setStatusCode(isHttpGetOrHead ?
|
||||
getResponse().setStatusCode(HttpStatus.PRECONDITION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validateIfNoneMatch(@Nullable String eTag) {
|
||||
try {
|
||||
if (CollectionUtils.isEmpty(getRequest().getHeaders().get(HttpHeaders.IF_NONE_MATCH))) {
|
||||
return false;
|
||||
}
|
||||
this.notModified = !matchRequestedETags(getRequestHeaders().getIfNoneMatch(), eTag, true);
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateResponseIdempotent(@Nullable String eTag, Instant lastModified) {
|
||||
boolean isSafeMethod = SAFE_METHODS.contains(getRequest().getMethod());
|
||||
if (this.notModified) {
|
||||
getResponse().setStatusCode(isSafeMethod ?
|
||||
HttpStatus.NOT_MODIFIED : HttpStatus.PRECONDITION_FAILED);
|
||||
}
|
||||
if (isHttpGetOrHead) {
|
||||
if (isSafeMethod) {
|
||||
if (lastModified.isAfter(Instant.EPOCH) && getResponseHeaders().getLastModified() == -1) {
|
||||
getResponseHeaders().setLastModified(lastModified.toEpochMilli());
|
||||
}
|
||||
if (StringUtils.hasLength(etag) && getResponseHeaders().getETag() == null) {
|
||||
getResponseHeaders().setETag(padEtagIfNecessary(etag));
|
||||
if (StringUtils.hasLength(eTag) && getResponseHeaders().getETag() == null) {
|
||||
getResponseHeaders().setETag(padEtagIfNecessary(eTag));
|
||||
}
|
||||
}
|
||||
|
||||
return this.notModified;
|
||||
}
|
||||
|
||||
private boolean validateIfUnmodifiedSince(Instant lastModified) {
|
||||
@@ -297,56 +389,11 @@ public class DefaultServerWebExchange implements ServerWebExchange {
|
||||
if (ifUnmodifiedSince == -1) {
|
||||
return false;
|
||||
}
|
||||
// We will perform this validation...
|
||||
Instant sinceInstant = Instant.ofEpochMilli(ifUnmodifiedSince);
|
||||
this.notModified = sinceInstant.isBefore(lastModified.truncatedTo(ChronoUnit.SECONDS));
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean validateIfNoneMatch(@Nullable String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return false;
|
||||
}
|
||||
List<String> ifNoneMatch;
|
||||
try {
|
||||
ifNoneMatch = getRequestHeaders().getIfNoneMatch();
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
return false;
|
||||
}
|
||||
if (ifNoneMatch.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// We will perform this validation...
|
||||
etag = padEtagIfNecessary(etag);
|
||||
if (etag.startsWith("W/")) {
|
||||
etag = etag.substring(2);
|
||||
}
|
||||
for (String clientEtag : ifNoneMatch) {
|
||||
// Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3
|
||||
if (StringUtils.hasLength(clientEtag)) {
|
||||
if (clientEtag.startsWith("W/")) {
|
||||
clientEtag = clientEtag.substring(2);
|
||||
}
|
||||
if (clientEtag.equals(etag)) {
|
||||
this.notModified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String padEtagIfNecessary(String etag) {
|
||||
if (!StringUtils.hasLength(etag)) {
|
||||
return etag;
|
||||
}
|
||||
if ((etag.startsWith("\"") || etag.startsWith("W/\"")) && etag.endsWith("\"")) {
|
||||
return etag;
|
||||
}
|
||||
return "\"" + etag + "\"";
|
||||
}
|
||||
|
||||
private boolean validateIfModifiedSince(Instant lastModified) {
|
||||
if (lastModified.isBefore(Instant.EPOCH)) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user