Annotation Interface Mutating
The mistake this exists to catch
RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe: a client, a proxy,
a crawler or a browser prefetcher may issue them freely, repeat them, and follow them without asking, precisely
because they are defined not to change anything. Everything downstream of a request is built on that promise
— caches store the response, link prefetchers fire the request before the user clicks, and every CSRF
defence in existence, including LoopbackBoundary, applies its checks
only to the methods that are not safe.
So a handler that mutates state behind GET does not merely misuse a verb. It sits behind whichever of
those checks the application relies on, having quietly opted out of all of them — not by disabling
anything, but by being classified as harmless. Nothing about it looks wrong at the call site, and nothing
reports it at runtime, because from the boundary's point of view a GET arriving without a CSRF token is
an ordinary read.
That is the failure mode this annotation exists to convert into a boot error:
The @RestOp case above is worth singling out, because the developer never typed GET anywhere:
with no method= and a Java method name whose prefix is not one of get/put/post/
delete, the resolved method defaults to GET. A mutating operation therefore lands on a
safe method through nobody's decision. The check resolves the method the same way dispatch does, so it sees the
inferred GET rather than the absent annotation.
Why this is declared and not detected
The obvious alternative is for the framework to work out for itself which handlers mutate, and it was rejected because no sound version of it exists. "Mutates" is a claim about effects on state the application cares about, and that has no syntactic signature:
Those are the same call to the same method with the same argument type, and telling them apart requires knowing
what svn does. No analysis of the Java program can supply that. Any analysis strong enough to see
through the indirection real applications use — an interface for the process runner, a supplier for the
credential, a method reference for the callback, an HTTP client whose URL is chosen at runtime — is a
whole-program points-to analysis, and it would still be wrong on the two lines above.
A naming heuristic (set*, save*, delete*) fails in both directions
— getOrCreateSession mutates, applyFilter does not — and has a worse property than
being unreliable: it would make renaming a Java method change the application's security posture.
Inspecting the signature fares no better. A mutating operation need take nothing but a @Path: deleting
a record by id, or validating a stored credential and caching the verdict, both mutate while taking no body at
all.
The decisive argument is about the cost of being wrong. This check's failure mode is the application does not start. A check that can refuse to start an application it merely suspects will be switched off, and a check that has been switched off protects nothing — including against the real contradictions it would have caught. So the rule here is one a machine can evaluate with certainty: the developer stated that this operation mutates, and the operation is bound to a method defined as safe. Those cannot both be intended, and no inference is involved in noticing it.
What this does not catch
An operation that mutates and says nothing is invisible to this check. The check finds contradictions, not omissions, and a developer who does not reach for the annotation gets no protection from it. That limit is inherent in the previous section: the alternative to a declaration that can be forgotten is an inference that can be wrong, and at a gate that stops the application from booting, wrong is worse.
What makes the limit acceptable is that the annotation is strictly additive. It can only ever cause a
boot failure; it never relaxes a runtime check. Its opposite, @RestOp(read=true), is
accepted only on custom methods and cannot declare a POST, PUT, PATCH or DELETE
safe. An operation that omits it is therefore treated exactly as it would be if this
annotation did not exist — the boundary still classifies by HTTP method, still treats an unknown method as
state-changing, and still applies every write check to every non-safe method. Adopting the annotation can move
an application from "wrong and silent" to "refuses to start"; it cannot move one from "protected" to
"unprotected".
For the same reason this is method-level only. A class-level form meaning "everything here mutates"
would be convenient and would immediately produce false failures on the page-rendering GET that nearly
every such resource also has — reintroducing exactly the false-positive problem that ruled out inference.
When it runs
When the resource's operation table is assembled: at startup under Rest.eagerInit(), and otherwise on
the first request to that resource. In both cases it runs before any operation on that resource can be
dispatched, so a contradiction cannot be reached by a request. An application wanting the failure at boot
literally should set eagerInit.
The check is unconditional and needs no wiring. It is not part of any filter, because HTTP method safety is not
a property of a security filter — it is a property of the resource, which the filter then depends on. An
application with no @Mutating anywhere is unaffected.
Example:
See Also:
- Since:
- 10.0.0
-
Optional Element Summary
Optional Elements
-
Element Details
-
value
Optional note describing what this operation changes.Included in the startup failure message when this operation is bound to a safe method, so the error can say what is at stake rather than only which method is wrong. Has no other effect.
- Returns:
- The description of what this operation changes.
- Default:
- ""
-