-
Notifications
You must be signed in to change notification settings - Fork 38.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Elaborate on ShallowEtagHeaderFilter limitations #30517
Comments
Hello @larsonmp could you elaborate a bit on this issue? Thanks! |
Hello @bclozel. The problem doesn't happen in my custom filter or in controllers, it happens in @RestController
@RequestMapping(value = "/api/v1/games")
public class GameController {
private ETagUtil eTagUtil;
private GameService gameService;
private GameMapper gameMapper = GameMapper.INSTANCE;
public GameController(ETagUtil eTagUtil, GameService gameService) {
this.eTagUtil = eTagUtil;
this.gameService = gameService;
}
@RequestMapping(method = RequestMethod.POST)
public ResponseEntity<Game> create(@RequestBody @Valid Game dto) {
com.example.model.Game model = gameService.create(toModel(dto));
return ResponseEntity.
status(HttpStatus.CREATED).
header(HttpHeaders.LOCATION, "/api/v1/games/" + model.getId()).
body(toDTO(model));
}
@RequestMapping(value = "/{id}", method = RequestMethod.PUT)
public ResponseEntity<Game> update(
@PathVariable String id,
@RequestHeader(value = "If-Match") String etag,
@RequestBody @Valid Game dto) {
if (eTagIsValid(id, etag)) {
return ResponseEntity.ok(toDTO(gameService.update(id, toModel(dto))));
} else {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
}
@RequestMapping(value = "/{id}", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<Game> read(@PathVariable String id) {
return ResponseEntity.ok(toDTO(gameService.read(id)));
}
private Game toDTO(com.example.model.Game model) {
return gameMapper.map(model);
}
private com.example.model.Game toModel(Game dto) {
return gameMapper.map(dto);
}
private boolean eTagIsValid(String id, String eTag) {
return eTagUtil.eTagMatches(toDTO(gameService.read(id)), eTag);
}
} @Component
public class SampleETagHeaderFilter extends ShallowEtagHeaderFilter {
private ETagUtil eTagUtil;
public SampleETagHeaderFilter(ETagUtil eTagUtil) {
this.eTagUtil = eTagUtil;
}
@Override
protected boolean isEligibleForEtag(@Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response, int responseStatusCode, @Nonnull InputStream inputStream) {
String method = request.getMethod();
return eTagUtil.canHandle(inputStream) && 200 <= responseStatusCode && responseStatusCode < 300 &&
( HttpMethod.GET.matches(method) || HttpMethod.PUT.matches(method) );
}
@Nonnull
@Override
protected String generateETagHeaderValue(@Nonnull InputStream inputStream, boolean isWeak) throws IOException {
return eTagUtil.eTag(StreamUtils.copyToString(inputStream, Charset.defaultCharset()));
}
} @Component
public class ETagUtil {
private final ObjectMapper objectMapper;
public ETagUtil(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public boolean canHandle(@Nonnull InputStream stream) {
try {
objectMapper.writeValueAsBytes(StreamUtils.copyToString(stream, Charset.defaultCharset()));
} catch (Exception e) {
return false;
}
return true;
}
public boolean eTagMatches(@Nonnull Game game, @Nonnull String eTag) {
return eTag.equals(eTag(game));
}
public String eTag(Game game) {
return "\"" + sha256(game) + "\"";
}
private String sha256(Object object) {
try {
byte[] bytes = objectMapper.writeValueAsBytes(object);
byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
return String.valueOf(Hex.encode(digest));
} catch (Exception e) {
throw new RuntimeException("unable to generate eTag", e);
}
}
public String eTag(String json) {
return "\"" + sha256(json) + "\"";
}
private String sha256(String json) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8));
return String.valueOf(Hex.encode(digest));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("unable to initialize hash algorithm", e);
}
}
} For an update (PUT), the eTag in the % curl "http://localhost:8080/api/v1/games" --header 'content-type: application/json' --data '{"title": "botw"}' -i
HTTP/1.1 201
Location: /api/v1/games/913395ce-37a3-46b9-b0af-97054eac4f14
{"title":"botw"}
% curl "http://localhost:8080/api/v1/games/913395ce-37a3-46b9-b0af-97054eac4f14" -i
HTTP/1.1 200
ETag: "e9cb013818bdcc39def0848da8064d12809b67ac313b6a4ac9edd2ea8db49d7f"
{"title":"botw"}
% curl -X PUT "http://localhost:8080/api/v1/games/913395ce-37a3-46b9-b0af-97054eac4f14" --header "If-Match: \"wrong-eTag\"" --header 'content-type: application/json' --data-raw '{"title": "totk"}' -i
HTTP/1.1 412
% curl -X PUT "http://localhost:8080/api/v1/games/913395ce-37a3-46b9-b0af-97054eac4f14" --header "If-Match: \"e9cb013818bdcc39def0848da8064d12809b67ac313b6a4ac9edd2ea8db49d7f\"" --header 'content-type: application/json' --data-raw '{"title": "totk"}' -i
HTTP/1.1 200
ETag: "7cae910c5ff9ce3003426793b8772bd63828e6e7154c38b77a0b1fc761af3eb1"
{"title":"totk"} However, @Override
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://datatracker.ietf.org/doc/html/rfc9110#section-13.2.2
if (validateIfMatch(eTag)) {
updateResponseStateChanging(eTag, lastModifiedTimestamp);
return this.notModified;
}
// 2) If-Unmodified-Since
else if (validateIfUnmodifiedSince(lastModifiedTimestamp)) {
updateResponseStateChanging(eTag, lastModifiedTimestamp);
return this.notModified;
}
// 3) If-None-Match
if (!validateIfNoneMatch(eTag)) {
// 4) If-Modified-Since
validateIfModifiedSince(lastModifiedTimestamp);
}
updateResponseIdempotent(eTag, lastModifiedTimestamp);
return this.notModified;
}
private boolean validateIfMatch(@Nullable String eTag) {
if (SAFE_METHODS.contains(getRequest().getMethod())) {
return false;
}
Enumeration<String> ifMatchHeaders = getRequest().getHeaders(HttpHeaders.IF_MATCH);
if (!ifMatchHeaders.hasMoreElements()) {
return false;
}
this.notModified = matchRequestedETags(ifMatchHeaders, eTag, false);
return true;
} For a PUT, Does that make sense? |
Thanks for the detailed response. I managed to turn that into a sample application. There are two issues we need to consider here. First, the Second, I think that the use case you've shown in the sample doesn't match the typical usage for the ETag header filter. I think this should be better covered by direct caching integration in your controllers:
There, we have both access to the resource being updated and its new representation. I am going to turn this issue into a documentation improvement to better warn developers against the limitations of the filter approach. Thanks for raising this issue! |
@bclozel - Thanks for detailed explanation. I can make due with an implementation that eschews the filter for an in-controller solution.
|
Yes you're right, the |
Oh, ok. Thank you. |
Affects: 6.0.8
Problem:
ServletWebRequest::validateIfMatch(String eTag)
compares etag of post-commit state with etag in request, which necessarily is pre-commit state, and sets response status to 412 if they don't match (which is the normal use case for PUTs). For PUTs, validation should occur prior to performing the method, and then need not occur after.Additional details: I have a filter that extends
ShallowEtagHeaderFilter
, overridingisEligibleForEtag(...)
andgenerateETagHeaderValue(...)
. This worked fine with spring 5.x, but now is broken due to changes in 0783f07.Thank you.
The text was updated successfully, but these errors were encountered: