Skip to content

Commit

Permalink
Improve conditional requests support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bclozel committed Jun 21, 2022
1 parent a3d3667 commit 0783f07
Show file tree
Hide file tree
Showing 7 changed files with 851 additions and 737 deletions.
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -206,97 +206,80 @@ public boolean checkNotModified(String etag) {
}

@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) {
validateIfModifiedSince(lastModifiedTimestamp);
// 2) If-Unmodified-Since
else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
updateResponseStateChanging();
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);
}
if (StringUtils.hasLength(etag) && response.getHeader(HttpHeaders.ETAG) == null) {
response.setHeader(HttpHeaders.ETAG, padEtagIfNecessary(etag));
}
}
// 3) If-None-Match
if (!validateIfNoneMatch(eTag)) {
// 4) If-Modified-Since
validateIfModifiedSince(lastModifiedTimestamp);
}

updateResponseIdempotent(eTag, lastModifiedTimestamp);
return this.notModified;
}

private boolean validateIfUnmodifiedSince(long lastModifiedTimestamp) {
if (lastModifiedTimestamp < 0) {
private boolean validateIfMatch(@Nullable String eTag) {
Enumeration<String> ifMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_MATCH);
if (SAFE_METHODS.contains(getRequest().getMethod())) {
return false;
}
long ifUnmodifiedSince = parseDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
if (ifUnmodifiedSince == -1) {
if (!ifMatchHeaders.hasMoreElements()) {
return false;
}
// We will perform this validation...
this.notModified = (ifUnmodifiedSince < (lastModifiedTimestamp / 1000 * 1000));
this.notModified = matchRequestedETags(ifMatchHeaders, eTag, false);
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()) {
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;
}

// 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;
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 (weakCompare) {
if (eTagWeakMatch(eTag, eTagMatcher.group(1))) {
return false;
}
}
else {
if (eTagStrongMatch(eTag, eTagMatcher.group(1))) {
return false;
}
}
}
}

return true;
}

private String padEtagIfNecessary(String etag) {
@Nullable
private String padEtagIfNecessary(@Nullable String etag) {
if (!StringUtils.hasLength(etag)) {
return etag;
}
Expand All @@ -306,6 +289,44 @@ private String padEtagIfNecessary(String 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) {
if (lastModifiedTimestamp < 0) {
return false;
}
long ifUnmodifiedSince = parseDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
if (ifUnmodifiedSince == -1) {
return false;
}
this.notModified = (ifUnmodifiedSince < (lastModifiedTimestamp / 1000 * 1000));
return true;
}

private boolean validateIfModifiedSince(long lastModifiedTimestamp) {
if (lastModifiedTimestamp < 0) {
return false;
Expand All @@ -319,6 +340,24 @@ private boolean validateIfModifiedSince(long lastModifiedTimestamp) {
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;
}
Expand Down
Loading

0 comments on commit 0783f07

Please sign in to comment.