This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit e1c1394eaa611cd061b8e953865c057b47726c25
Author: James Bognar <[email protected]>
AuthorDate: Tue Aug 18 13:38:34 2026 -0400

    TODO-419: Add a loopback write boundary (CSRF/DNS-rebinding defense) for 
loopback-bound REST apps
    
    A from-scratch defense for apps bound to 127.0.0.1-style dev/admin servers:
    Host is checked on every request (DNS-rebinding); Origin / Sec-Fetch-Site /
    JSON-content-type / CSRF token are checked on state-changing requests; a
    server-memory, never-cookie SynchronizerToken; a servlet Filter applying it 
at
    /* so no endpoint can opt out by omission; a @Mutating marker annotation 
plus a
    MethodSafety startup check (wired into the one RestOperations constructor 
every
    operation table passes through) that fails the app at boot if a mutating
    operation is bound to a safe HTTP method, which would bypass the boundary
    entirely.
    
    Javadoc explicitly scopes the threat model: this does not defend against a
    same-user local process, a malicious browser extension, or human intent.
---
 .../apache/juneau/rest/server/MethodSafety.java    | 148 ++++++
 .../org/apache/juneau/rest/server/Mutating.java    | 150 ++++++
 .../apache/juneau/rest/server/RestOperations.java  |   6 +
 .../rest/server/filter/LoopbackBoundary.java       | 535 +++++++++++++++++++++
 .../rest/server/filter/LoopbackBoundaryFilter.java | 174 +++++++
 .../rest/server/filter/SynchronizerToken.java      | 153 ++++++
 .../juneau/rest/server/filter/package-info.java    |   4 +-
 .../juneau/rest/server/MethodSafety_Test.java      | 257 ++++++++++
 .../server/filter/LoopbackBoundaryFilter_Test.java | 224 +++++++++
 .../rest/server/filter/LoopbackBoundary_Test.java  | 481 ++++++++++++++++++
 .../rest/server/filter/SynchronizerToken_Test.java | 100 ++++
 11 files changed, 2231 insertions(+), 1 deletion(-)

diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java
new file mode 100644
index 0000000000..e02a660423
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+// Single-type import, not the usual wildcard: this package declares its own 
Method type, which would otherwise
+// shadow java.lang.reflect.Method here.
+import java.lang.reflect.Method;
+import java.util.*;
+
+import org.apache.juneau.commons.reflect.*;
+import org.apache.juneau.http.response.*;
+
+/**
+ * Which HTTP methods are safe, and the startup check that an operation 
declared {@link Mutating} is not bound to
+ * one of them.
+ *
+ * <p>
+ * This is the single definition of the safe-method set for this framework.  
Anything that needs to know whether a
+ * request may change state &mdash; notably
+ * {@link 
org.apache.juneau.rest.server.filter.LoopbackBoundary#isStateChanging(String)}, 
which delegates here
+ * &mdash; reads it from this class, so a security filter and a startup check 
cannot come to disagree about which
+ * methods are writes.  A divergence between those two would be silent and 
would defeat both.
+ *
+ * <h5 class='section'>The check</h5>
+ * <p>
+ * {@link #check(List)} runs when a resource's operation table is assembled, 
from
+ * {@link RestOperations#RestOperations(RestOperations.Builder)} &mdash; the 
one constructor every operation table
+ * passes through, so there is no registration to remember and no path around 
it.  It fails the resource when an
+ * operation carrying {@link Mutating @Mutating} resolves to a safe method.
+ * <p>
+ * The resolved method is read from {@link RestOpContext#getHttpMethod()}, 
which is what dispatch itself uses.  That
+ * matters more than it sounds: the method may have been <b>inferred</b> from 
the Java method name rather than
+ * written down (see {@link RestOp#method()}), and a check that re-derived it 
independently could disagree with
+ * dispatch about which operation is bound to what.  A security-relevant check 
that disagrees with the router is
+ * worse than none.
+ * <p>
+ * A wildcard operation ({@code @RestOp(method="*")}) matches {@code GET} 
among everything else, so it is a
+ * contradiction too and is reported as one.
+ *
+ * <h5 class='section'>Why the rule is a declared contradiction rather than a 
detected one</h5>
+ * <p>
+ * See {@link Mutating}, which records why inferring "this handler mutates" is 
not soundly possible and why a check
+ * that can wrongly refuse to start an application is worse than no check at 
all.  The short form: this class only
+ * ever reports a contradiction the developer wrote down, so it cannot produce 
a false positive, and it cannot catch
+ * a handler that mutates and says nothing.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='ja'>{@link Mutating}
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.server.filter.LoopbackBoundary}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class MethodSafety {
+
+       /**
+        * The request methods RFC 9110 defines as safe.
+        *
+        * <p>
+        * A method absent from this set &mdash; including one this framework 
does not recognize &mdash; is treated as
+        * state-changing, so an unusual or future method fails closed rather 
than skipping write checks.
+        */
+       private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", 
"OPTIONS", "TRACE");
+
+       /**
+        * Constructor.
+        */
+       protected MethodSafety() {}
+
+       /**
+        * Whether {@code method} is defined as safe, and therefore promises 
not to change state.
+        *
+        * @param method The request method.  Can be <jk>null</jk>, which is 
not safe.
+        * @return <jk>true</jk> if the method is one of {@code GET}, {@code 
HEAD}, {@code OPTIONS} or {@code TRACE}.
+        */
+       public static boolean isSafe(String method) {
+               return method != null && 
SAFE_METHODS.contains(method.toUpperCase(Locale.ROOT));
+       }
+
+       /**
+        * Fails when any operation declared {@link Mutating @Mutating} is 
bound to a safe method.
+        *
+        * @param ops The operations of one resource.  Must not be 
<jk>null</jk>.
+        * @throws InternalServerError If a mutating operation is bound to a 
safe method.
+        */
+       public static void check(List<RestOpContext> ops) {
+               assertArgNotNull("ops", ops);
+               for (var op : ops)
+                       checkOperation(op.getHttpMethod(), op.getJavaMethod());
+       }
+
+       /**
+        * Fails when {@code javaMethod} is declared {@link Mutating @Mutating} 
and {@code httpMethod} is safe.
+        *
+        * <p>
+        * The per-operation half of {@link #check(List)}, taking the two facts 
it compares rather than a built
+        * {@link RestOpContext}, so the rule can be exercised directly.
+        *
+        * @param httpMethod The resolved HTTP method, as dispatch sees it.  
Can be <jk>null</jk>, which is not safe.
+        *      {@code "*"} matches every method and is therefore treated as 
including a safe one.
+        * @param javaMethod The Java method implementing the operation.  Must 
not be <jk>null</jk>.
+        * @throws InternalServerError If {@code javaMethod} is declared 
mutating and {@code httpMethod} is safe.
+        */
+       public static void checkOperation(String httpMethod, Method javaMethod) 
{
+               assertArgNotNull("javaMethod", javaMethod);
+
+               // Resolved through MethodInfo rather than 
Method.getAnnotation, so that an operation inheriting its
+               // declaration from a superclass or interface method is seen. 
Method.getAnnotation does not walk overrides,
+               // which would let an inherited @Mutating go unchecked; 
MethodInfo walks matching methods child-to-parent,
+               // the same way the framework resolves every other method 
annotation.
+               var mutating = MethodInfo.of(javaMethod.getDeclaringClass(), 
javaMethod).getAnnotations(Mutating.class)
+                       .findFirst().map(AnnotationInfo::inner).orElse(null);
+               if (mutating == null)
+                       return;
+
+               // A wildcard operation answers GET along with everything else, 
so it is bound to a safe method too.
+               var wildcard = "*".equals(httpMethod);
+               if (! wildcard && ! isSafe(httpMethod))
+                       return;
+
+               var what = mutating.value().isBlank() ? "" : " It changes: " + 
mutating.value() + ".";
+               throw new InternalServerError(
+                       "Operation '" + 
javaMethod.getDeclaringClass().getSimpleName() + "." + javaMethod.getName()
+                       + "' is annotated @Mutating but is bound to "
+                       + (wildcard ? "every method, including safe ones" : "'" 
+ httpMethod + "', which is a safe method")
+                       + "." + what
+                       + " A safe method promises not to change state, and 
CSRF and origin checks are applied only to methods"
+                       + " that are not safe, so this operation would be 
reachable without them."
+                       + " Bind it to POST/PUT/PATCH/DELETE, or remove 
@Mutating if it does not actually change state."
+                       + " Note that @RestOp with no method= infers the method 
from the Java method name and defaults to GET.");
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Mutating.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Mutating.java
new file mode 100644
index 0000000000..2e9f03d8f9
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/Mutating.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server;
+
+import static java.lang.annotation.ElementType.*;
+import static java.lang.annotation.RetentionPolicy.*;
+
+import java.lang.annotation.*;
+
+/**
+ * Declares that a REST operation changes state, so that binding it to an HTTP 
method defined as safe becomes a
+ * startup failure instead of a silent mistake.
+ *
+ * <h5 class='section'>The mistake this exists to catch</h5>
+ * <p>
+ * RFC 9110 defines {@code GET}, {@code HEAD}, {@code OPTIONS} and {@code 
TRACE} as <i>safe</i>: 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
+ * &mdash; caches store the response, link prefetchers fire the request before 
the user clicks, and every CSRF
+ * defence in existence, including {@link 
org.apache.juneau.rest.server.filter.LoopbackBoundary}, applies its checks
+ * only to the methods that are <i>not</i> safe.
+ * <p>
+ * So a handler that mutates state behind {@code 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 &mdash; 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 {@code 
GET} arriving without a CSRF token is
+ * an ordinary read.
+ * <p>
+ * That is the failure mode this annotation exists to convert into a boot 
error:
+ * <p class='bjava'>
+ *     <jc>// Fails at startup: this mutates, and @RestOp with no method= and 
a name that starts with none of
+ *     // get/put/post/delete resolves to GET.</jc>
+ *     <ja>@Mutating</ja>
+ *     <ja>@RestOp</ja>
+ *     <jk>public</jk> Result <jsm>armRelease</jsm>(...) {...}
+ * </p>
+ * <p>
+ * The {@code @RestOp} case above is worth singling out, because the developer 
never typed {@code GET} anywhere:
+ * with no {@code method=} and a Java method name whose prefix is not one of 
{@code get}/{@code put}/{@code post}/
+ * {@code delete}, the resolved method <b>defaults to {@code GET}</b>.  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 {@code GET} rather than the absent annotation.
+ *
+ * <h5 class='section'>Why this is declared and not detected</h5>
+ * <p>
+ * 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 <i>application</i> cares
+ * about, and that has no syntactic signature:
+ * <p class='bjava'>
+ *     <jv>runner</jv>.run(List.<jsm>of</jsm>(<js>"svn"</js>, 
<js>"commit"</js>, <jv>path</jv>));  <jc>// mutates the world</jc>
+ *     <jv>runner</jv>.run(List.<jsm>of</jsm>(<js>"svn"</js>, <js>"info"</js>, 
<jv>path</jv>));    <jc>// mutates nothing</jc>
+ * </p>
+ * <p>
+ * Those are the same call to the same method with the same argument type, and 
telling them apart requires knowing
+ * what {@code svn} does.  No analysis of the Java program can supply that.  
Any analysis strong enough to see
+ * through the indirection real applications use &mdash; 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 &mdash; is a
+ * whole-program points-to analysis, and it would still be wrong on the two 
lines above.
+ * <p>
+ * A naming heuristic ({@code set*}, {@code save*}, {@code delete*}) fails in 
both directions
+ * &mdash; {@code getOrCreateSession} mutates, {@code applyFilter} does not 
&mdash; and has a worse property than
+ * being unreliable: it would make renaming a Java method change the 
application's security posture.
+ * <p>
+ * Inspecting the signature fares no better.  A mutating operation need take 
nothing but a {@code @Path}: deleting
+ * a record by id, or validating a stored credential and caching the verdict, 
both mutate while taking no body at
+ * all.
+ * <p>
+ * The decisive argument is about the cost of being wrong.  This check's 
failure mode is <b>the application does
+ * not start</b>.  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 &mdash; 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.
+ *
+ * <h5 class='section'>What this does not catch</h5>
+ * <p>
+ * <b>An operation that mutates and says nothing is invisible to this 
check.</b>  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.
+ * <p>
+ * What makes the limit acceptable is that the annotation is <b>strictly 
additive</b>.  It can only ever cause a
+ * boot failure; it never relaxes a runtime check, and there is deliberately 
no inverse annotation declaring a
+ * {@code POST} to be safe.  An operation that omits it is therefore treated 
exactly as it would be if this
+ * annotation did not exist &mdash; 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".
+ * <p>
+ * For the same reason this is <b>method-level only</b>.  A class-level form 
meaning "everything here mutates"
+ * would be convenient and would immediately produce false failures on the 
page-rendering {@code GET} that nearly
+ * every such resource also has &mdash; reintroducing exactly the 
false-positive problem that ruled out inference.
+ *
+ * <h5 class='section'>When it runs</h5>
+ * <p>
+ * When the resource's operation table is assembled: at startup under {@link 
Rest#eagerInit()}, and otherwise on
+ * the first request to that resource.  In both cases it runs <b>before any 
operation on that resource can be
+ * dispatched</b>, so a contradiction cannot be reached by a request.  An 
application wanting the failure at boot
+ * literally should set {@code eagerInit}.
+ * <p>
+ * 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 &mdash; it is a property of the resource, 
which the filter then depends on.  An
+ * application with no {@code @Mutating} anywhere is unaffected.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ *     <jc>// Correct: declared mutating, bound to a method that is not safe.  
Boots, and the boundary's write
+ *     // checks apply to it because POST is not in the safe set.</jc>
+ *     <ja>@Mutating</ja>
+ *     <ja>@RestPost</ja>(<js>"/{version}/arm"</js>)
+ *     <jk>public</jk> ArmResult 
<jsm>arm</jsm>(<ja>@Path</ja>(<js>"version"</js>) String <jv>version</jv>, 
<ja>@Content</ja> ArmRequest <jv>body</jv>) {...}
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.server.filter.LoopbackBoundary}
+ *     <li class='jm'>{@link MethodSafety#check(java.util.List)}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@Target(METHOD)
+@Retention(RUNTIME)
+@Inherited
+public @interface Mutating {
+
+       /**
+        * Optional note describing what this operation changes.
+        *
+        * <p>
+        * 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.
+        *
+        * @return The description of what this operation changes.
+        */
+       String value() default "";
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOperations.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOperations.java
index 1aba17b5c8..ffd7fe0664 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOperations.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/RestOperations.java
@@ -140,6 +140,12 @@ public class RestOperations {
                        m.put(e.getKey(), toList(e.getValue()));
                this.map = m;
                this.list = array(builder.set, RestOpContext.class);
+
+               // Fail the resource if an operation declared @Mutating is 
bound to a method defined as safe. Done here
+               // because this constructor is the one path every operation 
table passes through, so the check cannot be
+               // skipped by omission -- and it runs before findOperation can 
dispatch anything. A resource with no
+               // @Mutating anywhere is unaffected.
+               MethodSafety.check(getOpContexts());
        }
 
        /**
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundary.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundary.java
new file mode 100644
index 0000000000..f056422558
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundary.java
@@ -0,0 +1,535 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.server.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * The request-authenticity half of a loopback application's write protection: 
decides whether a request came from
+ * the page this process served, and refuses it when it did not.
+ *
+ * <h5 class='section'>What this answers, and what it does not</h5>
+ * <p>
+ * An application bound to <c>127.0.0.1</c> is reachable only from this host, 
but "reachable only from this host"
+ * is not an authorization boundary &mdash; every browser the user has open is 
on this host, and any page in any of
+ * them can attempt a request to the port.  This class answers exactly one 
question:
+ * <p class='bcode'>
+ *     Did this request come from the page this process served?
+ * </p>
+ * <p>
+ * It does <b>not</b> answer <i>did the user mean it</i>.  A user who has the 
application's own page open, and who
+ * is induced to click something on it, produces a request that passes every 
check here.  That is a question of
+ * intent, and it needs a separate, independent mechanism &mdash; a typed 
confirmation, an arming gate, a
+ * per-action phrase naming the specific target.  <b>The two gates are 
deliberately not combined, in this class or
+ * in its naming.</b>  Treating an intent gate as though it authenticated the 
request, or this boundary as though
+ * it established intent, is the specific confusion this separation exists to 
prevent: an application with only an
+ * arming gate is forgeable by any page in the browser, and an application 
with only this boundary will faithfully
+ * execute whatever its own page was tricked into asking for.
+ *
+ * <h5 class='section'>The checks</h5>
+ * <table class='styled'>
+ *     <tr><th>Check</th><th>Applies 
to</th><th>Rule</th><th>Rejection</th></tr>
+ *     <tr>
+ *             <td>{@code Host}</td><td><b>every</b> request</td>
+ *             <td>equals the configured authority exactly</td>
+ *             <td>421 Misdirected Request</td>
+ *     </tr>
+ *     <tr>
+ *             <td>{@code Origin}</td><td>state-changing requests</td>
+ *             <td>present, and exactly <c>http://&lt;authority&gt;</c></td>
+ *             <td>403 Forbidden</td>
+ *     </tr>
+ *     <tr>
+ *             <td>{@code Sec-Fetch-Site}</td><td>state-changing requests</td>
+ *             <td>absent, or exactly <c>same-origin</c></td>
+ *             <td>403 Forbidden</td>
+ *     </tr>
+ *     <tr>
+ *             <td>{@code Content-Type}</td><td>state-changing requests</td>
+ *             <td>base type is exactly <c>application/json</c></td>
+ *             <td>415 Unsupported Media Type</td>
+ *     </tr>
+ *     <tr>
+ *             <td>CSRF token</td><td>state-changing requests</td>
+ *             <td>the configured header equals the process's {@link 
SynchronizerToken}</td>
+ *             <td>403 Forbidden</td>
+ *     </tr>
+ * </table>
+ *
+ * <h5 class='section'>Why each check is present</h5>
+ * <p>
+ * <b>{@code Host} on every request, not only writes.</b>  This is the check 
that defeats DNS rebinding, and it is
+ * the reason the boundary cannot be scoped to the write path.  An attacker 
serves a page from
+ * <c>http://evil.example</c> with a very short DNS TTL, then re-resolves that 
name to <c>127.0.0.1</c>.  Requests
+ * the page then makes to <c>http://evil.example/...</c> are 
<i>same-origin</i> from the browser's point of view:
+ * no CORS, no preflight, {@code Origin} is the page's own origin and 
therefore consistent, and the response body
+ * is readable.  Neither origin checking nor a CSRF token stops this &mdash; 
the page can simply fetch this
+ * application's own HTML and scrape the token out of it.  What the attacker 
cannot change is the {@code Host}
+ * header, which after rebinding still carries <c>evil.example</c>.
+ * <p>
+ * Restricting that check to writes would leave every read surface open to the 
one attack it exists to stop, and a
+ * rebound page that can read the application's data tables is already an 
exfiltration problem whether or not it
+ * can write.
+ * <p>
+ * <b>{@code Origin} on writes.</b>  Browsers set it on every cross-origin 
request and on same-origin POSTs, and
+ * page JavaScript cannot forge it.  An absent {@code Origin} is a rejection 
rather than a pass, so a client that
+ * simply omits the header does not thereby skip the check.
+ * <p>
+ * <b>A JSON-only content type on writes.</b>  The three form-encodable 
content types
+ * (<c>application/x-www-form-urlencoded</c>, <c>multipart/form-data</c>, 
<c>text/plain</c>) are precisely the
+ * ones a cross-origin request may use without a preflight, which is what 
makes a plain cross-origin
+ * <c>&lt;form&gt;</c> POST possible with no JavaScript at all.  Refusing them 
forces any cross-origin caller into
+ * a preflight, which this application never answers with CORS headers, so the 
real request is never sent.
+ * <p>
+ * <b>{@code Sec-Fetch-Site} on writes.</b>  Tolerates absence, so a 
non-browser client used during development is
+ * not broken, and rejects any present-but-wrong value.  It adds nothing 
against a local process, which can set
+ * the header freely; it is here because it is one line and closes a 
browser-side gap cheaply.
+ * <p>
+ * <b>A CSRF token on writes.</b>  A server-held {@link SynchronizerToken}, 
embedded in the served page.  See that
+ * class for why a double-submit cookie is unsound on a loopback port.
+ *
+ * <h5 class='section'>One canonical origin</h5>
+ * <p>
+ * The authority is a single exact spelling.  If the application is reached at 
<c>127.0.0.1:8790</c> then
+ * <c>localhost:8790</c> is <b>not</b> accepted, and vice versa.  Accepting 
both doubles the surface for no
+ * benefit, and an application that links only to the spelling it accepts 
never notices the difference.
+ *
+ * <h5 class='section'>What this does not defend against</h5>
+ * <p>
+ * These are accepted residual risks, not oversights.  Stating them plainly 
matters, because a boundary described
+ * as stopping more than it does invites the wrong decisions to be built on 
top of it.
+ * <ul>
+ *     <li><b>A local process running as the same user is not defended 
against, and cannot be.</b>  It already holds
+ *             every credential this application holds &mdash; the same 
keychain entries, the same config files, the same
+ *             environment.  It does not need this application to do anything. 
 Nor does any of the machinery here
+ *             obstruct it: everything the browser must be able to present in 
order for the UI to work is equally
+ *             obtainable by a local HTTP client that fetches the same page 
first and reads the token out of it.  A
+ *             shared secret cannot separate "our page" from "a local program 
impersonating our page", because both are
+ *             given the secret by the same server.  This boundary raises the 
bar for pages in a browser; against a
+ *             same-user local process it is not a control at all.
+ *     <li><b>A malicious or compromised browser extension is not defended 
against.</b>  An extension can read the
+ *             page, read the token out of it, and issue requests as the page. 
 Every check here is satisfied from inside
+ *             the page.
+ *     <li><b>The human is not authenticated.</b>  There is no login.  The 
operating-system session is the
+ *             authentication; whoever is at the keyboard is the user.
+ *     <li><b>Intent is not established.</b>  See the first section: that is a 
separate gate's job.
+ * </ul>
+ *
+ * <h5 class='section'>Non-browser callers</h5>
+ * <p>
+ * There is deliberately no path-exemption or trusted-caller list.  A 
non-browser client that must write &mdash;
+ * including a process calling back into its own loopback port &mdash; 
presents the same {@code Host},
+ * {@code Origin}, content type and token as the page does.  An exemption list 
is the shape of API that gets one
+ * more entry added under deadline pressure until it covers the endpoint that 
mattered, so the boundary does not
+ * offer one.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ *     LoopbackBoundary <jv>boundary</jv> = 
LoopbackBoundary.<jsm>create</jsm>()
+ *             .authority(<js>"127.0.0.1:8790"</js>)
+ *             .token(SynchronizerToken.<jsm>generate</jsm>())
+ *             .build();
+ *
+ *     LoopbackBoundary.Result <jv>result</jv> = 
<jv>boundary</jv>.check(<jv>request</jv>);
+ *     <jk>if</jk> (! <jv>result</jv>.isAllowed())
+ *             <jv>response</jv>.sendError(<jv>result</jv>.status(), 
<jv>result</jv>.message());
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link LoopbackBoundaryFilter}
+ *     <li class='jc'>{@link SynchronizerToken}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "java:S1192" // Duplicate string literals are HTTP header names (e.g. 
Origin); a constant per header would obscure the wire contract.
+})
+public class LoopbackBoundary {
+
+       /** Default name of the request header carrying the CSRF token. */
+       public static final String DEFAULT_CSRF_HEADER = "X-Csrf-Token";
+
+       /** The only content type accepted on a state-changing request. */
+       public static final String JSON_CONTENT_TYPE = "application/json";
+
+       private final String authority;
+       private final String origin;
+       private final String csrfHeader;
+       private final SynchronizerToken token;
+
+       /**
+        * Constructor.
+        *
+        * @param builder The builder.  Must have had an authority and a token 
set.
+        * @throws IllegalArgumentException If the builder carries no authority 
or no token.
+        */
+       protected LoopbackBoundary(Builder builder) {
+               if (builder.authority == null)
+                       throw iaex("An authority is required; call 
Builder.authority(...).");
+               if (builder.token == null)
+                       throw iaex("A token is required; call 
Builder.token(...).");
+               this.authority = builder.authority;
+               this.origin = "http://"; + builder.authority;
+               this.csrfHeader = builder.csrfHeader;
+               this.token = builder.token;
+       }
+
+       /**
+        * Creates a new builder.
+        *
+        * @return A new builder.
+        */
+       public static Builder create() {
+               return new Builder();
+       }
+
+       /**
+        * Applies the boundary to a request.
+        *
+        * @param req The request to check.  Must not be <jk>null</jk>.
+        * @return {@link Result#ALLOWED} when every applicable check passed, 
else the first rejection encountered.
+        */
+       public Result check(HttpServletRequest req) {
+               assertArgNotNull("req", req);
+
+               // Host applies to every request; it is the DNS-rebinding check 
and a read is just as exfiltratable as a write.
+               var host = req.getHeader("Host");
+               if (host == null || ! host.equalsIgnoreCase(authority))
+                       return reject(Reason.HOST_MISMATCH, 421,
+                               "Request 'Host' does not match this server's 
expected authority '%s'.", authority);
+
+               if (! isStateChanging(req.getMethod()))
+                       return Result.ALLOWED;
+
+               var origin2 = req.getHeader("Origin");
+               if (origin2 == null || origin2.isBlank())
+                       return reject(Reason.ORIGIN_MISSING, 403,
+                               "A state-changing request must carry an 
'Origin' header of '%s'.", origin);
+               if (! origin2.equals(origin))
+                       return reject(Reason.ORIGIN_MISMATCH, 403,
+                               "Request 'Origin' is not this server's origin 
'%s'.", origin);
+
+               // The presented value is deliberately not echoed back: it is 
caller-controlled, and a rejection message is
+               // rendered into a response body and a log line, neither of 
which should carry attacker-chosen text.
+               var fetchSite = req.getHeader("Sec-Fetch-Site");
+               if (fetchSite != null && ! "same-origin".equals(fetchSite))
+                       return reject(Reason.FETCH_SITE_NOT_SAME_ORIGIN, 403,
+                               "Request 'Sec-Fetch-Site' must be absent or 
'same-origin' on a state-changing request.");
+
+               if (! isJson(req.getContentType()))
+                       return reject(Reason.CONTENT_TYPE_NOT_JSON, 415,
+                               "A state-changing request must use content type 
'%s'.", JSON_CONTENT_TYPE);
+
+               var presented = req.getHeader(csrfHeader);
+               if (presented == null || presented.isBlank())
+                       return reject(Reason.CSRF_TOKEN_MISSING, 403,
+                               "A state-changing request must carry this 
server's CSRF token in the '%s' header.", csrfHeader);
+               if (! token.matches(presented))
+                       return reject(Reason.CSRF_TOKEN_MISMATCH, 403,
+                               "The CSRF token in the '%s' header is not this 
server's token.", csrfHeader);
+
+               return Result.ALLOWED;
+       }
+
+       /**
+        * Whether a request method is treated as state-changing, and therefore 
subject to the write checks.
+        *
+        * <p>
+        * {@code GET}, {@code HEAD}, {@code OPTIONS} and {@code TRACE} are 
read-only.  Everything else, including an
+        * unrecognized method, is state-changing.
+        *
+        * <p>
+        * Delegates to {@link MethodSafety#isSafe(String)} rather than 
carrying its own copy of the safe-method set.
+        * The startup check that refuses a {@link 
org.apache.juneau.rest.server.Mutating @Mutating} operation bound to
+        * a safe method reads the same definition, and a boundary that 
disagreed with that check about which methods
+        * are writes would silently defeat both.
+        *
+        * @param method The request method.  Can be <jk>null</jk>, which is 
treated as state-changing.
+        * @return <jk>true</jk> if the method is subject to the write checks.
+        */
+       public static boolean isStateChanging(String method) {
+               return ! MethodSafety.isSafe(method);
+       }
+
+       /**
+        * The expected {@code Host} value.
+        *
+        * @return The configured authority, e.g. {@code "127.0.0.1:8790"}.
+        */
+       public String authority() { return authority; }
+
+       /**
+        * The single accepted request origin, derived from the authority.
+        *
+        * @return The canonical origin, e.g. {@code "http://127.0.0.1:8790"}.
+        */
+       public String origin() { return origin; }
+
+       /**
+        * The name of the header a state-changing request must carry the CSRF 
token in.
+        *
+        * @return The CSRF header name.
+        */
+       public String csrfHeader() { return csrfHeader; }
+
+       /**
+        * The headers an in-process client must add when calling this 
application's own loopback port.
+        *
+        * <p>
+        * An application that calls back into itself over HTTP &mdash; a mock 
of an external service mounted on its
+        * own port, a background task that drives its own API &mdash; is a 
state-changing caller like any other, and
+        * the boundary offers it no exemption (see the class javadoc's 
non-browser-callers section).  Rather than
+        * leaving each such caller to rediscover what the boundary wants, this 
returns it: the accepted
+        * {@code Origin} and the CSRF token under its configured header name.
+        *
+        * <p>
+        * {@code Host} is deliberately absent.  An HTTP client derives it from 
the request URI, so a caller already
+        * sending to this application's authority sends the right one; and 
{@code Host} is a restricted header that
+        * {@link java.net.http.HttpClient} refuses to set anyway.  The 
caller's remaining obligation is to send
+        * {@code Content-Type: application/json} on writes, which such a 
client is normally doing already.
+        *
+        * <p>
+        * This is not a back door.  It hands the token only to code already 
running inside this process, which could
+        * equally read it off {@link #token()}; it exists so that "call your 
own port correctly" does not become the
+        * argument for adding a path exemption.
+        *
+        * @return An immutable map of header name to value.
+        */
+       public Map<String,String> selfCallHeaders() {
+               return Map.of("Origin", origin, csrfHeader, token.value());
+       }
+
+       /**
+        * The token a page must embed and a state-changing request must 
present.
+        *
+        * @return This boundary's token.
+        */
+       public SynchronizerToken token() { return token; }
+
+       /**
+        * Whether a {@code Content-Type} header value's base type is exactly 
{@code application/json}.
+        *
+        * <p>
+        * Parameters are ignored, so {@code application/json;charset=utf-8} 
passes.  A suffixed type such as
+        * {@code application/problem+json} does not: the check exists to 
exclude the form-encodable types, and
+        * widening it to "anything ending in json" would be a per-type 
judgement call at a security boundary.
+        */
+       private static boolean isJson(String contentType) {
+               if (contentType == null)
+                       return false;
+               var semi = contentType.indexOf(';');
+               var base = (semi < 0 ? contentType : contentType.substring(0, 
semi)).trim();
+               return base.equalsIgnoreCase(JSON_CONTENT_TYPE);
+       }
+
+       private static Result reject(Reason reason, int status, String message, 
Object... args) {
+               return new Result(reason, status, f(message, args));
+       }
+
+       /**
+        * Why a request was rejected.
+        *
+        * <p>
+        * Enumerated rather than collapsed into a single "forbidden" so the 
application can render an accurate
+        * reason and log an actionable one.  Distinguishing missing from 
mismatched leaks nothing a cross-origin
+        * caller does not already know about its own request.
+        */
+       public enum Reason {
+
+               /** The {@code Host} header was absent or was not this server's 
authority.  Rejected on every request. */
+               HOST_MISMATCH,
+
+               /** A state-changing request carried no {@code Origin} header. 
*/
+               ORIGIN_MISSING,
+
+               /** A state-changing request's {@code Origin} was not this 
server's origin. */
+               ORIGIN_MISMATCH,
+
+               /** A state-changing request's {@code Sec-Fetch-Site} was 
present and was not {@code same-origin}. */
+               FETCH_SITE_NOT_SAME_ORIGIN,
+
+               /** A state-changing request's content type was not {@code 
application/json}. */
+               CONTENT_TYPE_NOT_JSON,
+
+               /** A state-changing request carried no CSRF token header. */
+               CSRF_TOKEN_MISSING,
+
+               /** A state-changing request's CSRF token was not this server's 
token. */
+               CSRF_TOKEN_MISMATCH
+       }
+
+       /**
+        * The outcome of {@link LoopbackBoundary#check(HttpServletRequest)}: 
either allowed, or a rejection carrying
+        * the reason, the HTTP status to answer with, and a message safe to 
return to the caller.
+        */
+       public static final class Result {
+
+               /** The outcome of a request that passed every applicable 
check. */
+               public static final Result ALLOWED = new Result(null, 0, null);
+
+               private final Reason reason;
+               private final int status;
+               private final String message;
+
+               Result(Reason reason, int status, String message) {
+                       this.reason = reason;
+                       this.status = status;
+                       this.message = message;
+               }
+
+               /**
+                * Whether the request passed.
+                *
+                * @return <jk>true</jk> if the request may proceed.
+                */
+               public boolean isAllowed() { return reason == null; }
+
+               /**
+                * Why the request was rejected.
+                *
+                * @return The rejection reason, or <jk>null</jk> when {@link 
#isAllowed()} is <jk>true</jk>.
+                */
+               public Reason reason() { return reason; }
+
+               /**
+                * The HTTP status to answer a rejected request with.
+                *
+                * @return The status code, or {@code 0} when {@link 
#isAllowed()} is <jk>true</jk>.
+                */
+               public int status() { return status; }
+
+               /**
+                * A message describing the rejection.
+                *
+                * <p>
+                * Names the header at fault and what was expected of it, and 
never echoes the value the request
+                * presented for the CSRF token or reveals this server's token.
+                *
+                * @return The message, or <jk>null</jk> when {@link 
#isAllowed()} is <jk>true</jk>.
+                */
+               public String message() { return message; }
+
+               @Override /* Object */
+               public String toString() { return isAllowed() ? "ALLOWED" : 
reason + "(" + status + "): " + message; }
+       }
+
+       /**
+        * Builder for {@link LoopbackBoundary}.
+        */
+       public static class Builder {
+
+               String authority;
+               String csrfHeader = DEFAULT_CSRF_HEADER;
+               SynchronizerToken token;
+
+               /**
+                * Constructor.
+                */
+               protected Builder() {}
+
+               /**
+                * The authority every request's {@code Host} must equal, and 
from which the accepted {@code Origin} is
+                * derived.
+                *
+                * <p>
+                * Required.  Must be the exact host-and-port spelling the 
application links to, e.g.
+                * {@code "127.0.0.1:8790"} &mdash; the boundary accepts one 
spelling, not a set (see the class javadoc's
+                * canonical-origin section).
+                *
+                * @param value The expected authority.  Must not be 
<jk>null</jk>, blank, or carry a scheme or path.
+                * @return This object.
+                * @throws IllegalArgumentException If {@code value} is 
<jk>null</jk>, blank, or is not a bare
+                *      host-and-port.
+                */
+               public Builder authority(String value) {
+                       assertArgNotNull("value", value);
+                       var v = value.trim();
+                       if (v.isEmpty())
+                               throw iaex("Argument 'value' must not be 
blank.");
+                       if (v.contains("://") || v.indexOf('/') >= 0)
+                               throw iaex("Argument 'value' must be a bare 
host and port with no scheme or path: ''%s''.", value);
+                       authority = v;
+                       return this;
+               }
+
+               /**
+                * Convenience form of {@link #authority(String)} taking the 
host and port separately.
+                *
+                * @param host The bind host, e.g. {@code "127.0.0.1"}.  Must 
not be <jk>null</jk> or blank.
+                * @param port The bind port.  Must be positive.
+                * @return This object.
+                * @throws IllegalArgumentException If {@code host} is 
<jk>null</jk>/blank or {@code port} is not
+                *      positive.
+                */
+               public Builder authority(String host, int port) {
+                       assertArgNotNull("host", host);
+                       if (port <= 0)
+                               throw iaex("Argument 'port' must be positive: 
%s.", port);
+                       return authority(host.trim() + ":" + port);
+               }
+
+               /**
+                * The header a state-changing request must carry the CSRF 
token in.
+                *
+                * @param value The header name.  Must not be <jk>null</jk> or 
blank.  Defaults to
+                *      {@link LoopbackBoundary#DEFAULT_CSRF_HEADER}.
+                * @return This object.
+                * @throws IllegalArgumentException If {@code value} is 
<jk>null</jk> or blank.
+                */
+               public Builder csrfHeader(String value) {
+                       assertArgNotNull("value", value);
+                       if (value.isBlank())
+                               throw iaex("Argument 'value' must not be 
blank.");
+                       csrfHeader = value.trim();
+                       return this;
+               }
+
+               /**
+                * The token a state-changing request must present.
+                *
+                * <p>
+                * Required.  Normally one {@link SynchronizerToken#generate() 
generated} token per process, also embedded
+                * into every page the application serves.
+                *
+                * @param value The token.  Must not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder token(SynchronizerToken value) {
+                       assertArgNotNull("value", value);
+                       token = value;
+                       return this;
+               }
+
+               /**
+                * Builds the boundary.
+                *
+                * @return A new {@link LoopbackBoundary}.
+                * @throws IllegalArgumentException If no authority or no token 
was supplied.
+                */
+               public LoopbackBoundary build() {
+                       return new LoopbackBoundary(this);
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter.java
new file mode 100644
index 0000000000..51d9ead6e9
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.nio.charset.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+/**
+ * Applies a {@link LoopbackBoundary} to every request reaching the servlet 
container, rejecting those that did not
+ * come from the page this process served.
+ *
+ * <h5 class='section'>Why a servlet filter, and not a mixin or a guard</h5>
+ * <p>
+ * The boundary's value depends entirely on there being no way around it, and 
the two lighter-weight Juneau
+ * mechanisms both leave one:
+ * <ul>
+ *     <li>A {@link org.apache.juneau.rest.server.guard.RestGuard RestGuard} 
is declared per resource or per operation, so
+ *             an endpoint added later is unprotected until someone remembers 
to declare it &mdash; and its omission is
+ *             invisible in review, because nothing about the new endpoint 
looks different.
+ *     <li>A mixin's {@link org.apache.juneau.rest.server.RestStartCall 
@RestStartCall} hook fires only for requests
+ *             that resolved to one of that mixin's own endpoints, so a 
boundary packaged that way would not see the host
+ *             resource's operations at all.
+ * </ul>
+ * <p>
+ * A filter registered at {@code /*} sees every request the container handles: 
every Juneau resource and mixin,
+ * static resources served by the container or by another framework in the 
same application, and the paths that
+ * resolve to nothing and would 404.  Nothing can opt out by omission, which 
is the property that makes it worth
+ * having.
+ *
+ * <h5 class='section'>Rejection behavior</h5>
+ * <p>
+ * A rejected request is answered directly and the chain is not invoked, so 
the request never reaches application
+ * code.  The response carries the boundary's chosen status, a {@code 
X-Loopback-Boundary} header naming the
+ * {@link LoopbackBoundary.Reason reason}, and a small JSON body carrying the 
same reason and a message.
+ * <p>
+ * A rejection is never rendered as an empty result or a silent no-op.  A 
security refusal that looks like "no data"
+ * teaches the user to ignore it, and the point of answering explicitly is 
that a genuine misconfiguration &mdash;
+ * the application being reached at {@code localhost} when it accepts {@code 
127.0.0.1}, say &mdash; is immediately
+ * diagnosable instead of presenting as an inexplicably broken page.
+ *
+ * <h5 class='section'>Token availability to the page renderer</h5>
+ * <p>
+ * On an allowed request the boundary's token value is placed under the {@link 
#TOKEN_ATTRIBUTE} request attribute,
+ * so a page-rendering endpoint can embed it without needing its own reference 
to the boundary.
+ *
+ * <h5 class='section'>Example (Spring Boot):</h5>
+ * <p class='bjava'>
+ *     <ja>@Bean</ja>
+ *     <jk>public</jk> FilterRegistrationBean&lt;LoopbackBoundaryFilter&gt; 
boundary(LoopbackBoundary <jv>b</jv>) {
+ *             <jk>var</jk> <jv>reg</jv> = <jk>new</jk> 
FilterRegistrationBean&lt;&gt;(<jk>new</jk> LoopbackBoundaryFilter(<jv>b</jv>));
+ *             <jv>reg</jv>.addUrlPatterns(<js>"/*"</js>);
+ *             <jv>reg</jv>.setOrder(Ordered.<jsf>HIGHEST_PRECEDENCE</jsf>);
+ *             <jk>return</jk> <jv>reg</jv>;
+ *     }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link LoopbackBoundary}
+ *     <li class='jc'>{@link SynchronizerToken}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class LoopbackBoundaryFilter implements Filter {
+
+       /** Request attribute under which an allowed request carries the 
boundary's CSRF token value. */
+       public static final String TOKEN_ATTRIBUTE = 
"org.apache.juneau.rest.server.filter.csrfToken";
+
+       /** Response header naming the {@link LoopbackBoundary.Reason} a 
request was rejected for. */
+       public static final String REJECTION_HEADER = "X-Loopback-Boundary";
+
+       private final LoopbackBoundary boundary;
+
+       /**
+        * Constructor.
+        *
+        * @param boundary The boundary to apply to every request.  Must not be 
<jk>null</jk>.
+        */
+       public LoopbackBoundaryFilter(LoopbackBoundary boundary) {
+               this.boundary = assertArgNotNull("boundary", boundary);
+       }
+
+       /**
+        * The boundary this filter applies.
+        *
+        * @return The boundary.
+        */
+       public LoopbackBoundary boundary() { return boundary; }
+
+       @Override /* Filter */
+       public void doFilter(ServletRequest req, ServletResponse res, 
FilterChain chain) throws IOException, ServletException {
+               if (! (req instanceof HttpServletRequest req2) || ! (res 
instanceof HttpServletResponse res2)) {
+                       chain.doFilter(req, res);  // HTT: a non-HTTP servlet 
request cannot reach a filter mapped into an HTTP container.
+                       return;
+               }
+               var result = boundary.check(req2);
+               if (! result.isAllowed()) {
+                       reject(res2, result);
+                       return;
+               }
+               req2.setAttribute(TOKEN_ATTRIBUTE, boundary.token().value());
+               chain.doFilter(req, res);
+       }
+
+       /**
+        * Answers a rejected request directly, without invoking the chain, so 
it never reaches application code.
+        *
+        * <p>
+        * The stream is written to but deliberately not closed: it belongs to 
the container, which commits and
+        * releases it once the filter returns.  Closing it here would commit 
the response early and cut off any
+        * outer filter that wraps it.
+        */
+       @SuppressWarnings({
+               "resource" // getOutputStream() returns the container-owned 
response stream; the filter is not its owner and must not close it.
+       })
+       private static void reject(HttpServletResponse res, 
LoopbackBoundary.Result result) throws IOException {
+               res.reset();
+               res.setStatus(result.status());
+               res.setHeader(REJECTION_HEADER, result.reason().name());
+               res.setContentType("application/json;charset=utf-8");
+               var body = "{\"reason\":\"" + result.reason().name() + 
"\",\"message\":\"" + escape(result.message()) + "\"}";
+               var bytes = body.getBytes(StandardCharsets.UTF_8);
+               res.setContentLength(bytes.length);
+               res.getOutputStream().write(bytes);
+       }
+
+       /**
+        * Escapes a rejection message for a JSON string literal.
+        *
+        * <p>
+        * Written out rather than delegated to a serializer so the rejection 
path has no dependency on marshalling
+        * configuration: this response must be producible even when the 
application's serializers are misconfigured,
+        * because a boundary refusal that fails to render degrades into an 
opaque 500.
+        */
+       private static String escape(String s) {
+               var sb = new StringBuilder(s.length() + 16);
+               for (var i = 0; i < s.length(); i++) {
+                       var c = s.charAt(i);
+                       switch (c) {
+                               case '"' -> sb.append("\\\"");
+                               case '\\' -> sb.append("\\\\");
+                               case '\n' -> sb.append("\\n");
+                               case '\r' -> sb.append("\\r");
+                               case '\t' -> sb.append("\\t");
+                               default -> {
+                                       if (c < 0x20)
+                                               
sb.append(String.format("\\u%04x", (int)c));
+                                       else
+                                               sb.append(c);
+                               }
+                       }
+               }
+               return sb.toString();
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/SynchronizerToken.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/SynchronizerToken.java
new file mode 100644
index 0000000000..d998edda11
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/SynchronizerToken.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.nio.charset.*;
+import java.security.*;
+import java.util.*;
+
+/**
+ * A server-held CSRF token: a secret minted in this process's memory, 
embedded into the pages this process
+ * serves, and required back on every state-changing request.
+ *
+ * <h5 class='section'>Why this is a synchronizer token and not a 
double-submit cookie</h5>
+ * <p>
+ * The cheap, common CSRF design is <i>double submit</i>: the server sets a 
random value in a cookie, the page
+ * reads it back out and echoes it in a header, and the server accepts the 
request when the cookie and the header
+ * agree.  It needs no server-side state, which is why it keeps getting 
reinvented.
+ * <p>
+ * <b>It is unsound for an application on a loopback port, because cookies are 
scoped by host and ignore the
+ * port.</b>  A cookie set by a page served from <c>http://localhost:3000</c> 
is sent by the browser to
+ * <c>http://localhost:8790</c> as well &mdash; the port is not part of a 
cookie's origin.  So any other local
+ * development server, any other tool the developer happens to be running, and 
any page served by any of them can
+ * plant a cookie that this application would read back and accept as its own. 
 Under double submit that is a
+ * complete CSRF bypass: the attacker chooses the value, plants it in the 
cookie, and echoes the same value in the
+ * header.  Both halves match, because the attacker supplied both.
+ * <p>
+ * The token here is instead held only in this object, in this process's 
memory.  It is written into the served
+ * HTML and compared against the request header.  Nothing the browser stores 
by host can influence it, so the
+ * port-blindness of cookies is irrelevant.
+ * <p>
+ * Consequently: <b>never place this value in a cookie</b>, and never add a 
code path that accepts a token read
+ * from one.  Doing so does not weaken the mechanism slightly &mdash; it 
reintroduces exactly the bypass described
+ * above, because a cookie-borne token is attacker-choosable from any port on 
the same host.
+ *
+ * <h5 class='section'>Lifetime</h5>
+ * <p>
+ * A token instance is a secret with no expiry and no rotation: it lives as 
long as the object does.  An
+ * application that constructs one at startup therefore gets per-boot tokens, 
and a restart invalidates every
+ * token previously embedded in a page.  That is deliberate &mdash; a page 
held open across a restart is a page
+ * whose server no longer shares any state with it, and requiring a reload is 
the honest outcome.  Two instances
+ * never share a value, so a token minted by one is rejected by the other.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ *     <jc>// One token per process, embedded into every page this process 
serves.</jc>
+ *     SynchronizerToken <jv>token</jv> = 
SynchronizerToken.<jsm>generate</jsm>();
+ *     String <jv>html</jv> = <js>"&lt;meta name='csrf-token' content='"</js> 
+ <jv>token</jv>.value() + <js>"'&gt;"</js>;
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link LoopbackBoundary}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class SynchronizerToken {
+
+       /** Number of random bytes behind a generated token.  256 bits, well 
beyond guessing range. */
+       private static final int TOKEN_BYTES = 32;
+
+       private final String value;
+
+       /**
+        * Constructor.
+        *
+        * @param value The token value.  Must not be <jk>null</jk> or blank.
+        */
+       private SynchronizerToken(String value) {
+               this.value = value;
+       }
+
+       /**
+        * Mints a new token from {@link SecureRandom}.
+        *
+        * @return A new token holding a fresh 256-bit secret, hex-encoded.
+        */
+       public static SynchronizerToken generate() {
+               var bytes = new byte[TOKEN_BYTES];
+               new SecureRandom().nextBytes(bytes);
+               return new SynchronizerToken(HexFormat.of().formatHex(bytes));
+       }
+
+       /**
+        * Wraps a caller-supplied token value.
+        *
+        * <p>
+        * Intended for tests and for an application that mints its secret 
elsewhere.  Prefer {@link #generate()},
+        * which cannot be given a weak value by accident.
+        *
+        * @param value The token value.  Must not be <jk>null</jk> or blank.
+        * @return A token holding {@code value}.
+        * @throws IllegalArgumentException If {@code value} is <jk>null</jk> 
or blank.
+        */
+       public static SynchronizerToken of(String value) {
+               assertArgNotNull("value", value);
+               if (value.isBlank())
+                       throw iaex("Argument 'value' must not be blank.");
+               return new SynchronizerToken(value);
+       }
+
+       /**
+        * The token value, for embedding into a served page.
+        *
+        * @return The token value.  Never <jk>null</jk> or blank.
+        */
+       public String value() { return value; }
+
+       /**
+        * Whether {@code candidate} is this token.
+        *
+        * <p>
+        * Compares in time independent of how many leading characters match, 
so a caller cannot recover the token
+        * one character at a time by measuring how long a rejection takes.  A 
<jk>null</jk> or blank candidate is
+        * not this token.
+        *
+        * @param candidate The value presented by the request.  Can be 
<jk>null</jk>.
+        * @return <jk>true</jk> if {@code candidate} equals this token's value.
+        */
+       public boolean matches(String candidate) {
+               if (candidate == null || candidate.isEmpty())
+                       return false;
+               return 
MessageDigest.isEqual(value.getBytes(StandardCharsets.UTF_8), 
candidate.getBytes(StandardCharsets.UTF_8));
+       }
+
+       /**
+        * Returns a description that does <b>not</b> include the token value.
+        *
+        * <p>
+        * The value is a secret, and a bean-dumping logger or a debug view 
that stringifies its collaborators would
+        * otherwise write it somewhere it can be read back.
+        *
+        * @return A value-free description.
+        */
+       @Override /* Object */
+       public String toString() { return 
"SynchronizerToken(value=<redacted>)"; }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/package-info.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/package-info.java
index af159a8325..d1e31b6bcd 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/package-info.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/filter/package-info.java
@@ -20,6 +20,8 @@
  *
  * <p>
  * Includes {@link org.apache.juneau.rest.server.filter.RequestIdFilter}, 
which assigns a unique
- * request ID to each incoming request for correlation in logs and responses.
+ * request ID to each incoming request for correlation in logs and responses, 
and
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter}, which 
rejects requests to a
+ * loopback-bound application that did not originate from the page that 
application served.
  */
 package org.apache.juneau.rest.server.filter;
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MethodSafety_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MethodSafety_Test.java
new file mode 100644
index 0000000000..f896c38a49
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/MethodSafety_Test.java
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.rest.server.filter.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link MethodSafety} and {@link Mutating @Mutating}: the 
safe-method set, the per-operation rule, and
+ * the startup failure a real {@link Rest @Rest} resource gets when it 
declares a mutating operation behind a safe
+ * method.
+ *
+ * <p>
+ * The group-c tests construct real {@link RestContext} instances, because the 
claim being made is that the
+ * application does not start &mdash; not that a static method throws when 
called directly.  They use
+ * {@code eagerInit} so the failure lands in the constructor, which is what 
"fail at boot" means.
+ *
+ * @since 10.0.0
+ */
+class MethodSafety_Test extends org.apache.juneau.TestBase {
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // Fixtures
+       
//-----------------------------------------------------------------------------------------------------------
+
+       /** Carries one method per shape the rule cares about; group b reflects 
over these rather than dispatching. */
+       @SuppressWarnings("unused")
+       static class Fix_Methods {
+               @Mutating public void declared() {}
+               @Mutating("the stored credential") public void 
declaredWithNote() {}
+               public void undeclared() {}
+       }
+
+       /** A superclass declaration must still be seen when the subclass 
overrides the method. */
+       @SuppressWarnings("unused")
+       static class Fix_Parent {
+               @Mutating public void inherited() {}
+       }
+
+       @SuppressWarnings("unused")
+       static class Fix_Child extends Fix_Parent {
+               @Override public void inherited() {}
+       }
+
+       static java.lang.reflect.Method method(Class<?> c, String name) throws 
Exception {
+               return c.getMethod(name);
+       }
+
+       static RestContext.Args argsOf(Class<?> resourceClass, 
java.util.function.Supplier<?> supplier) {
+               return new RestContext.Args(resourceClass, null, null, 
supplier, null, null, null, null, null, null);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // a - the safe-method set, and its agreement with the boundary
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Test void a01_safeMethods() {
+               assertTrue(MethodSafety.isSafe("GET"));
+               assertTrue(MethodSafety.isSafe("HEAD"));
+               assertTrue(MethodSafety.isSafe("OPTIONS"));
+               assertTrue(MethodSafety.isSafe("TRACE"));
+       }
+
+       @Test void a02_unsafeMethods() {
+               assertFalse(MethodSafety.isSafe("POST"));
+               assertFalse(MethodSafety.isSafe("PUT"));
+               assertFalse(MethodSafety.isSafe("PATCH"));
+               assertFalse(MethodSafety.isSafe("DELETE"));
+       }
+
+       @Test void a03_caseInsensitive() {
+               assertTrue(MethodSafety.isSafe("get"));
+               assertTrue(MethodSafety.isSafe("Get"));
+       }
+
+       @Test void a04_nullAndUnknownAreNotSafe() {
+               // Fail-closed: a method this framework does not know must not 
inherit the read-only promise.
+               assertFalse(MethodSafety.isSafe(null));
+               assertFalse(MethodSafety.isSafe("QUERY"));
+               assertFalse(MethodSafety.isSafe(""));
+               assertFalse(MethodSafety.isSafe("*"));
+       }
+
+       @Test void a05_theBoundaryAndTheBootCheckAgreeOnEveryMethod() {
+               // The point of the delegation. If these two ever disagree, the 
boot check would clear a method the
+               // boundary treats as a write (or worse, the reverse) and both 
controls would be quietly wrong. Asserted
+               // over the safe set and a spread of others rather than 
trusting that one delegates to the other today.
+               for (var m : new String[] { "GET", "HEAD", "OPTIONS", "TRACE", 
"POST", "PUT", "PATCH", "DELETE", "QUERY", "", "*" })
+                       assertEquals(MethodSafety.isSafe(m), ! 
LoopbackBoundary.isStateChanging(m), "disagreement on '" + m + "'");
+               assertEquals(MethodSafety.isSafe(null), ! 
LoopbackBoundary.isStateChanging(null), "disagreement on null");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // b - the per-operation rule
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Test void b01_declaredMutatingOnGet_rejected() throws Exception {
+               var e = assertThrows(RuntimeException.class,
+                       () -> MethodSafety.checkOperation("GET", 
method(Fix_Methods.class, "declared")));
+               assertTrue(e.getMessage().contains("Fix_Methods.declared"), 
e.getMessage());
+               assertTrue(e.getMessage().contains("safe method"), 
e.getMessage());
+       }
+
+       @Test void b02_declaredMutatingOnEveryOtherSafeMethod_rejected() throws 
Exception {
+               var m = method(Fix_Methods.class, "declared");
+               for (var httpMethod : new String[] { "HEAD", "OPTIONS", "TRACE" 
})
+                       assertThrows(RuntimeException.class, () -> 
MethodSafety.checkOperation(httpMethod, m), httpMethod);
+       }
+
+       @Test void b03_declaredMutatingOnWildcard_rejected() throws Exception {
+               // @RestOp(method="*") answers GET along with everything else, 
so the operation is reachable by a safe
+               // method and the write checks would not run for that arrival.
+               var e = assertThrows(RuntimeException.class,
+                       () -> MethodSafety.checkOperation("*", 
method(Fix_Methods.class, "declared")));
+               assertTrue(e.getMessage().contains("every method"), 
e.getMessage());
+       }
+
+       @Test void b04_declaredMutatingOnUnsafeMethods_allowed() throws 
Exception {
+               var m = method(Fix_Methods.class, "declared");
+               for (var httpMethod : new String[] { "POST", "PUT", "PATCH", 
"DELETE", "QUERY" })
+                       assertDoesNotThrow(() -> 
MethodSafety.checkOperation(httpMethod, m), httpMethod);
+       }
+
+       @Test void b05_undeclaredOnGet_allowed() throws Exception {
+               // The documented limit, asserted so it is a stated property 
rather than an accident: the check finds
+               // contradictions, not omissions. A handler that mutates and 
says nothing is invisible here.
+               assertDoesNotThrow(() -> MethodSafety.checkOperation("GET", 
method(Fix_Methods.class, "undeclared")));
+       }
+
+       @Test void b06_noteIsIncludedInTheFailure() throws Exception {
+               var e = assertThrows(RuntimeException.class,
+                       () -> MethodSafety.checkOperation("GET", 
method(Fix_Methods.class, "declaredWithNote")));
+               assertTrue(e.getMessage().contains("the stored credential"), 
e.getMessage());
+       }
+
+       @Test void b07_failureNamesTheRestOpInferenceTrap() throws Exception {
+               // The message has to mention it: in the @RestOp case the 
developer never typed GET, so a message that
+               // only says "bound to GET" reads as wrong rather than as 
informative.
+               var e = assertThrows(RuntimeException.class,
+                       () -> MethodSafety.checkOperation("GET", 
method(Fix_Methods.class, "declared")));
+               assertTrue(e.getMessage().contains("infers the method"), 
e.getMessage());
+       }
+
+       @Test void b08_inheritedDeclarationIsSeenThroughAnOverride() throws 
Exception {
+               // Method.getAnnotation would return null here; the check 
resolves through MethodInfo for this reason.
+               assertThrows(RuntimeException.class,
+                       () -> MethodSafety.checkOperation("GET", 
method(Fix_Child.class, "inherited")));
+       }
+
+       @Test void b09_nullHttpMethodIsNotAContradiction() throws Exception {
+               // Not safe, so nothing is being claimed twice. Fail-closed at 
request time covers it.
+               assertDoesNotThrow(() -> MethodSafety.checkOperation(null, 
method(Fix_Methods.class, "declared")));
+       }
+
+       @Test void b10_nullJavaMethodRejected() {
+               assertThrows(IllegalArgumentException.class, () -> 
MethodSafety.checkOperation("GET", null));
+               assertThrows(IllegalArgumentException.class, () -> 
MethodSafety.check(null));
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------
+       // c - the boot failure, against real resources
+       
//-----------------------------------------------------------------------------------------------------------
+
+       @Rest(eagerInit = "true")
+       static class Fix_MutatingGet {
+               @Mutating("the run's armed state")
+               @RestGet("/arm")
+               public String arm() { return "armed"; }
+       }
+
+       @Rest(eagerInit = "true")
+       static class Fix_MutatingPost {
+               @Mutating("the run's armed state")
+               @RestPost("/arm")
+               public String arm() { return "armed"; }
+       }
+
+       /**
+        * The case the check exists for: no {@code method=}, and a Java method 
name whose prefix is none of
+        * get/put/post/delete, so the resolved method defaults to {@code GET} 
without the developer writing it.
+        */
+       @Rest(eagerInit = "true")
+       static class Fix_MutatingBareRestOp {
+               @Mutating("the stored credential")
+               @RestOp
+               public String armRelease() { return "armed"; }
+       }
+
+       @Rest(eagerInit = "true")
+       static class Fix_MutatingWildcard {
+               @Mutating
+               @RestOp(method = "*", path = "/anything")
+               public String anything() { return "x"; }
+       }
+
+       @Rest(eagerInit = "true")
+       static class Fix_PlainGet {
+               @RestGet("/page")
+               public String page() { return "page"; }
+       }
+
+       @Test void c01_resourceWithMutatingGet_failsToInitialize() {
+               var e = assertThrows(Exception.class, () -> new 
RestContext(argsOf(Fix_MutatingGet.class, Fix_MutatingGet::new)));
+               assertTrue(messageChain(e).contains("Fix_MutatingGet.arm"), 
messageChain(e));
+               assertTrue(messageChain(e).contains("the run's armed state"), 
messageChain(e));
+       }
+
+       @Test void c02_resourceWithMutatingPost_initializes() throws Exception {
+               var ctx = new RestContext(argsOf(Fix_MutatingPost.class, 
Fix_MutatingPost::new));
+               assertEquals(1, ctx.getRestOperations().getOpContexts().size());
+       }
+
+       @Test void c03_bareRestOpInferringGet_failsToInitialize() {
+               // The headline case. Nobody typed GET; the framework inferred 
it, and the operation would have sat behind
+               // every write check while looking correct at the call site.
+               var e = assertThrows(Exception.class,
+                       () -> new 
RestContext(argsOf(Fix_MutatingBareRestOp.class, Fix_MutatingBareRestOp::new)));
+               
assertTrue(messageChain(e).contains("Fix_MutatingBareRestOp.armRelease"), 
messageChain(e));
+       }
+
+       @Test void c04_wildcardMethod_failsToInitialize() {
+               var e = assertThrows(Exception.class,
+                       () -> new 
RestContext(argsOf(Fix_MutatingWildcard.class, Fix_MutatingWildcard::new)));
+               
assertTrue(messageChain(e).contains("Fix_MutatingWildcard.anything"), 
messageChain(e));
+       }
+
+       @Test void c05_resourceWithNoMutatingAnnotation_initializes() throws 
Exception {
+               // Strictly additive: a resource that never mentions @Mutating 
behaves exactly as it did before the check
+               // existed, which is what makes turning this on safe for every 
application already in the wild.
+               var ctx = new RestContext(argsOf(Fix_PlainGet.class, 
Fix_PlainGet::new));
+               assertEquals(1, ctx.getRestOperations().getOpContexts().size());
+       }
+
+       /** The framework wraps init failures, so assertions match against the 
whole cause chain. */
+       static String messageChain(Throwable t) {
+               var sb = new StringBuilder();
+               for (var x = t; x != null; x = x.getCause())
+                       sb.append(x.getMessage()).append(" | ");
+               return sb.toString();
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter_Test.java
new file mode 100644
index 0000000000..fce38089f3
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundaryFilter_Test.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for {@link LoopbackBoundaryFilter} — that a refused request never 
reaches the chain, that the refusal is
+ * rendered explicitly rather than as a silent no-op, and that an allowed 
request passes through with the token
+ * available to the page renderer.
+ *
+ * @since 10.0.0
+ */
+class LoopbackBoundaryFilter_Test extends TestBase {
+
+       private static final String AUTHORITY = "127.0.0.1:8790";
+       private static final SynchronizerToken TOKEN = 
SynchronizerToken.of("the-real-token");
+
+       private static LoopbackBoundaryFilter filter() {
+               return new 
LoopbackBoundaryFilter(LoopbackBoundary.create().authority(AUTHORITY).token(TOKEN).build());
+       }
+
+       /** Captures what the filter wrote to the response body. */
+       private static final class CapturingOutputStream extends 
ServletOutputStream {
+               final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+               @Override public void write(int b) { baos.write(b); }
+               @Override public boolean isReady() { return true; }
+               @Override public void setWriteListener(WriteListener l) { /* 
not used by this filter */ }
+               String captured() { return 
baos.toString(StandardCharsets.UTF_8); }
+       }
+
+       private static HttpServletRequest req(String method, Map<String,String> 
headers, String contentType) {
+               var r = mock(HttpServletRequest.class);
+               when(r.getMethod()).thenReturn(method);
+               when(r.getContentType()).thenReturn(contentType);
+               headers.forEach((k, v) -> when(r.getHeader(k)).thenReturn(v));
+               return r;
+       }
+
+       private static Map<String,String> goodWriteHeaders() {
+               var m = new LinkedHashMap<String,String>();
+               m.put("Host", AUTHORITY);
+               m.put("Origin", "http://"; + AUTHORITY);
+               m.put("Sec-Fetch-Site", "same-origin");
+               m.put("X-Csrf-Token", TOKEN.value());
+               return m;
+       }
+
+       /** A response paired with the body the filter wrote to it. */
+       private record Capture(HttpServletResponse res, CapturingOutputStream 
out) {
+               String body() { return out.captured(); }
+       }
+
+       /**
+        * A response whose output stream is captured, since the filter writes 
a body on every refusal.
+        *
+        * <p>
+        * Every refusal test funnels through here so the stubbing lives in one 
place.
+        */
+       @SuppressWarnings({
+               "resource" // The capture wraps an in-memory buffer and the 
mock's getOutputStream() acquires nothing; there is no resource to release.
+       })
+       private static Capture capturing() throws IOException {
+               var out = new CapturingOutputStream();
+               var r = mock(HttpServletResponse.class);
+               when(r.getOutputStream()).thenReturn(out);
+               return new Capture(r, out);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // a) Allowed requests pass through
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_allowedWrite_reachesTheChain() throws Exception {
+               var req = req("POST", goodWriteHeaders(), "application/json");
+               var res = mock(HttpServletResponse.class);
+               var chain = mock(FilterChain.class);
+               filter().doFilter(req, res, chain);
+               verify(chain).doFilter(req, res);
+               verify(res, never()).setStatus(anyInt());
+       }
+
+       @Test void a02_allowedRequest_exposesTheTokenToThePageRenderer() throws 
Exception {
+               var req = req("GET", Map.of("Host", AUTHORITY), null);
+               filter().doFilter(req, mock(HttpServletResponse.class), 
mock(FilterChain.class));
+               
verify(req).setAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE, TOKEN.value());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // b) Refused requests never reach application code
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_foreignOrigin_chainIsNeverInvoked() throws Exception {
+               var h = goodWriteHeaders();
+               h.put("Origin", "http://evil.example";);
+               var chain = mock(FilterChain.class);
+               filter().doFilter(req("POST", h, "application/json"), 
capturing().res(), chain);
+               verifyNoInteractions(chain);
+       }
+
+       @Test void b02_mismatchedHost_chainIsNeverInvoked() throws Exception {
+               var chain = mock(FilterChain.class);
+               filter().doFilter(req("GET", Map.of("Host", "evil.example"), 
null), capturing().res(), chain);
+               verifyNoInteractions(chain);
+       }
+
+       @Test void b03_missingToken_chainIsNeverInvoked() throws Exception {
+               var h = goodWriteHeaders();
+               h.remove("X-Csrf-Token");
+               var chain = mock(FilterChain.class);
+               filter().doFilter(req("POST", h, "application/json"), 
capturing().res(), chain);
+               verifyNoInteractions(chain);
+       }
+
+       @Test void b04_formEncodedWrite_chainIsNeverInvoked() throws Exception {
+               var chain = mock(FilterChain.class);
+               filter().doFilter(req("POST", goodWriteHeaders(), 
"application/x-www-form-urlencoded"),
+                       capturing().res(), chain);
+               verifyNoInteractions(chain);
+       }
+
+       @Test void b05_refusedRequestDoesNotExposeTheToken() throws Exception {
+               var req = req("GET", Map.of("Host", "evil.example"), null);
+               filter().doFilter(req, capturing().res(), 
mock(FilterChain.class));
+               verify(req, 
never()).setAttribute(eq(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE), any());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // c) A refusal is rendered explicitly — never as an empty result or a 
silent no-op
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_refusalCarriesTheStatusReasonHeaderAndAJsonBody() throws 
Exception {
+               var h = goodWriteHeaders();
+               h.put("Origin", "http://evil.example";);
+               var capture = capturing();
+
+               filter().doFilter(req("POST", h, "application/json"), 
capture.res(), mock(FilterChain.class));
+
+               verify(capture.res()).reset();
+               verify(capture.res()).setStatus(403);
+               
verify(capture.res()).setHeader(LoopbackBoundaryFilter.REJECTION_HEADER, 
"ORIGIN_MISMATCH");
+               
verify(capture.res()).setContentType("application/json;charset=utf-8");
+               var body = capture.body();
+               assertTrue(body.contains("\"reason\":\"ORIGIN_MISMATCH\""), 
body);
+               assertTrue(body.contains("\"message\":\""), body);
+               assertFalse(body.isBlank(), "a refusal must not render as an 
empty body");
+       }
+
+       @Test void c02_hostMismatchAnswers421() throws Exception {
+               var capture = capturing();
+               filter().doFilter(req("GET", Map.of("Host", "evil.example"), 
null), capture.res(), mock(FilterChain.class));
+               verify(capture.res()).setStatus(421);
+               
verify(capture.res()).setHeader(LoopbackBoundaryFilter.REJECTION_HEADER, 
"HOST_MISMATCH");
+       }
+
+       @Test void c03_formEncodedWriteAnswers415() throws Exception {
+               var capture = capturing();
+               filter().doFilter(req("POST", goodWriteHeaders(), 
"application/x-www-form-urlencoded"), capture.res(),
+                       mock(FilterChain.class));
+               verify(capture.res()).setStatus(415);
+               
verify(capture.res()).setHeader(LoopbackBoundaryFilter.REJECTION_HEADER, 
"CONTENT_TYPE_NOT_JSON");
+       }
+
+       @Test void c04_refusalBodyDoesNotLeakTheServersToken() throws Exception 
{
+               var h = goodWriteHeaders();
+               h.put("X-Csrf-Token", "wrong");
+               var capture = capturing();
+               filter().doFilter(req("POST", h, "application/json"), 
capture.res(), mock(FilterChain.class));
+               assertFalse(capture.body().contains(TOKEN.value()), 
capture::body);
+       }
+
+       @Test void 
c05_refusalBodyIsWellFormedJsonWhenTheMessageCarriesQuotableCharacters() throws 
Exception {
+               // The configured header name reaches the message, so a name 
carrying a quote or backslash must not
+               // produce a malformed body.
+               var b = 
LoopbackBoundary.create().authority(AUTHORITY).token(TOKEN).csrfHeader("X-\"Odd\"\\Header").build();
+               var capture = capturing();
+               var h = goodWriteHeaders();
+               h.remove("X-Csrf-Token");
+               new LoopbackBoundaryFilter(b).doFilter(req("POST", h, 
"application/json"), capture.res(),
+                       mock(FilterChain.class));
+               var body = capture.body();
+               assertTrue(body.contains("\\\"Odd\\\""), () -> "expected 
escaped quotes: " + body);
+               assertTrue(body.contains("\\\\Header"), () -> "expected escaped 
backslash: " + body);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // d) Construction
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_boundaryIsRequired() {
+               assertThrows(IllegalArgumentException.class, () -> new 
LoopbackBoundaryFilter(null));
+       }
+
+       @Test void d02_boundaryAccessor() {
+               var b = 
LoopbackBoundary.create().authority(AUTHORITY).token(TOKEN).build();
+               assertSame(b, new LoopbackBoundaryFilter(b).boundary());
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundary_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundary_Test.java
new file mode 100644
index 0000000000..faff65ea42
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/LoopbackBoundary_Test.java
@@ -0,0 +1,481 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.apache.juneau.rest.server.filter.LoopbackBoundary.Reason.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.filter.LoopbackBoundary.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for {@link LoopbackBoundary}.
+ *
+ * <p>
+ * The negative cases carry the weight here.  A boundary with only positive 
tests passes just as happily when a
+ * check has been accidentally removed, so every rule below is asserted from 
both directions: the legitimate
+ * request must succeed, <i>and</i> a request violating exactly that one rule 
must be refused with the specific
+ * reason and status that rule is supposed to produce.
+ *
+ * @since 10.0.0
+ */
+class LoopbackBoundary_Test extends TestBase {
+
+       private static final String AUTHORITY = "127.0.0.1:8790";
+       private static final String ORIGIN = "http://127.0.0.1:8790";;
+       private static final SynchronizerToken TOKEN = 
SynchronizerToken.of("the-real-token");
+
+       private static final LoopbackBoundary BOUNDARY = 
LoopbackBoundary.create()
+               .authority(AUTHORITY)
+               .token(TOKEN)
+               .build();
+
+       /**
+        * A fully legitimate write request: correct {@code Host}, correct 
{@code Origin}, same-origin fetch metadata,
+        * JSON content type, and this server's token.  Each negative test 
below is this request with exactly one
+        * header spoiled, so a failure isolates the rule that broke.
+        */
+       private static Map<String,String> goodWriteHeaders() {
+               var m = new LinkedHashMap<String,String>();
+               m.put("Host", AUTHORITY);
+               m.put("Origin", ORIGIN);
+               m.put("Sec-Fetch-Site", "same-origin");
+               m.put("X-Csrf-Token", TOKEN.value());
+               return m;
+       }
+
+       private static HttpServletRequest req(String method, Map<String,String> 
headers) {
+               return req(method, headers, "application/json");
+       }
+
+       private static HttpServletRequest req(String method, Map<String,String> 
headers, String contentType) {
+               var r = mock(HttpServletRequest.class);
+               when(r.getMethod()).thenReturn(method);
+               when(r.getContentType()).thenReturn(contentType);
+               headers.forEach((k, v) -> when(r.getHeader(k)).thenReturn(v));
+               return r;
+       }
+
+       /** The legitimate write request, optionally with one header replaced 
or (on a null value) removed. */
+       private static HttpServletRequest write(String spoiledHeader, String 
value) {
+               var h = goodWriteHeaders();
+               if (spoiledHeader != null) {
+                       if (value == null)
+                               h.remove(spoiledHeader);
+                       else
+                               h.put(spoiledHeader, value);
+               }
+               return req("POST", h);
+       }
+
+       private static void assertRejected(Result r, Reason expectedReason, int 
expectedStatus) {
+               assertFalse(r.isAllowed(), () -> "expected a rejection, got: " 
+ r);
+               assertEquals(expectedReason, r.reason());
+               assertEquals(expectedStatus, r.status());
+               assertNotNull(r.message(), "a rejection must carry a message; a 
silent refusal is not diagnosable");
+               assertFalse(r.message().isBlank());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // a) The legitimate path still works
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_legitimateWrite_allowed() {
+               assertTrue(BOUNDARY.check(write(null, null)).isAllowed());
+       }
+
+       @Test void 
a02_legitimateWrite_withCharsetParameterOnContentType_allowed() {
+               assertTrue(BOUNDARY.check(req("POST", goodWriteHeaders(), 
"application/json;charset=utf-8")).isAllowed());
+       }
+
+       @Test void a03_legitimateWrite_withoutSecFetchSite_allowed() {
+               // Absence is tolerated so a non-browser client used during 
development is not broken.
+               assertTrue(BOUNDARY.check(write("Sec-Fetch-Site", 
null)).isAllowed());
+       }
+
+       @Test void a04b_selfCallHeaders_satisfyTheBoundary() {
+               // The headers handed to an in-process loopback caller must 
actually pass, or the "no path exemptions"
+               // stance is unimplementable and the next person adds one.  
Host comes from the request URI, not the map.
+               var h = new LinkedHashMap<String,String>();
+               h.put("Host", AUTHORITY);
+               h.putAll(BOUNDARY.selfCallHeaders());
+               assertTrue(BOUNDARY.check(req("POST", h)).isAllowed());
+       }
+
+       @Test void a04c_selfCallHeaders_omitHostAndCarryNoSurprises() {
+               var h = BOUNDARY.selfCallHeaders();
+               assertEquals(Set.of("Origin", "X-Csrf-Token"), h.keySet());
+               assertEquals(ORIGIN, h.get("Origin"));
+               assertEquals(TOKEN.value(), h.get("X-Csrf-Token"));
+       }
+
+       @Test void a04d_selfCallHeaders_useTheConfiguredCsrfHeaderName() {
+               var b = 
LoopbackBoundary.create().authority(AUTHORITY).token(TOKEN).csrfHeader("X-App-Token").build();
+               assertEquals(Set.of("Origin", "X-App-Token"), 
b.selfCallHeaders().keySet());
+       }
+
+       @Test void 
a04_legitimateRead_allowed_andNeedsNoOriginContentTypeOrToken() {
+               var r = mock(HttpServletRequest.class);
+               when(r.getMethod()).thenReturn("GET");
+               when(r.getHeader("Host")).thenReturn(AUTHORITY);
+               assertTrue(BOUNDARY.check(r).isAllowed());
+       }
+
+       @Test void a05_everyWriteMethodTakesTheWriteChecks() {
+               for (var m : List.of("POST", "PUT", "PATCH", "DELETE")) {
+                       var h = goodWriteHeaders();
+                       h.remove("X-Csrf-Token");
+                       assertRejected(BOUNDARY.check(req(m, h)), 
CSRF_TOKEN_MISSING, 403);
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // b) Host — enforced on every request, including reads.  This is the 
DNS-rebinding check.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_mismatchedHostOnWrite_rejected() {
+               assertRejected(BOUNDARY.check(write("Host", "evil.example")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b02_mismatchedHostOnRead_rejected() {
+               // The whole reason Host is not scoped to writes: a rebound 
page reading a data table is already an
+               // exfiltration problem.
+               var r = mock(HttpServletRequest.class);
+               when(r.getMethod()).thenReturn("GET");
+               when(r.getHeader("Host")).thenReturn("evil.example");
+               assertRejected(BOUNDARY.check(r), HOST_MISMATCH, 421);
+       }
+
+       @Test void b03_absentHost_rejected() {
+               assertRejected(BOUNDARY.check(write("Host", null)), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b04_reboundHostWithCorrectOriginAndValidToken_rejected() {
+               // The DNS-rebinding shape specifically: after rebinding the 
attacker's page is same-origin with itself,
+               // so it can scrape the real token out of this application's 
own HTML and present consistent fetch
+               // metadata.  Only Host still gives it away.
+               var h = goodWriteHeaders();
+               h.put("Host", "evil.example");
+               h.put("Origin", "http://evil.example";);
+               h.put("Sec-Fetch-Site", "same-origin");
+               h.put("X-Csrf-Token", TOKEN.value());
+               assertRejected(BOUNDARY.check(req("POST", h)), HOST_MISMATCH, 
421);
+       }
+
+       @Test void b05_localhostSpellingOfTheSamePort_rejected() {
+               // One canonical spelling by decision; localhost:8790 is not 
127.0.0.1:8790.
+               assertRejected(BOUNDARY.check(write("Host", "localhost:8790")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b06_hostPrefixingOursOnAnAttackerDomain_rejected() {
+               // Guards against a prefix/startsWith comparison creeping in: 
127.0.0.1:8790.evil.example is a name an
+               // attacker can register and resolve to anything, and it starts 
with our authority.
+               assertRejected(BOUNDARY.check(write("Host", AUTHORITY + 
".evil.example")), HOST_MISMATCH, 421);
+       }
+
+       @Test void b07_hostWithOurAuthorityAsASuffix_rejected() {
+               assertRejected(BOUNDARY.check(write("Host", "evil.example." + 
AUTHORITY)), HOST_MISMATCH, 421);
+       }
+
+       @Test void b08_hostWithTrailingWhitespace_rejected() {
+               assertRejected(BOUNDARY.check(write("Host", AUTHORITY + " ")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b09_hostMissingThePort_rejected() {
+               assertRejected(BOUNDARY.check(write("Host", "127.0.0.1")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b10_hostOnADifferentPort_rejected() {
+               // Ports matter: another loopback application on a different 
port is a different application.
+               assertRejected(BOUNDARY.check(write("Host", "127.0.0.1:3000")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void b11_hostCaseIsInsignificant() {
+               var b = 
LoopbackBoundary.create().authority("LocalHost:8790").token(TOKEN).build();
+               var h = goodWriteHeaders();
+               h.put("Host", "localhost:8790");
+               h.put("Origin", "http://LocalHost:8790";);
+               assertTrue(b.check(req("POST", h)).isAllowed());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // c) Origin — required on writes
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_foreignOrigin_rejected() {
+               assertRejected(BOUNDARY.check(write("Origin", 
"http://evil.example";)), ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void c02_absentOrigin_rejected() {
+               // Absent must not mean "skip the check" — otherwise omitting 
the header is the bypass.
+               assertRejected(BOUNDARY.check(write("Origin", null)), 
ORIGIN_MISSING, 403);
+       }
+
+       @Test void c03_blankOrigin_rejected() {
+               assertRejected(BOUNDARY.check(write("Origin", "   ")), 
ORIGIN_MISSING, 403);
+       }
+
+       @Test void c04_nullOriginLiteral_rejected() {
+               // Browsers send the literal string "null" for some opaque 
origins (e.g. a sandboxed iframe).
+               assertRejected(BOUNDARY.check(write("Origin", "null")), 
ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void c05_localhostSpellingOfOrigin_rejected() {
+               assertRejected(BOUNDARY.check(write("Origin", 
"http://localhost:8790";)), ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void c06_httpsSpellingOfOrigin_rejected() {
+               assertRejected(BOUNDARY.check(write("Origin", 
"https://127.0.0.1:8790";)), ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void c07_originWithTrailingSlash_rejected() {
+               assertRejected(BOUNDARY.check(write("Origin", ORIGIN + "/")), 
ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void c08_originPrefixingOursOnAnAttackerDomain_rejected() {
+               // Guards against a substring/startsWith comparison creeping in.
+               assertRejected(BOUNDARY.check(write("Origin", ORIGIN + 
".evil.example")), ORIGIN_MISMATCH, 403);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // d) Sec-Fetch-Site — absent or same-origin
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_crossSiteFetchMetadata_rejected() {
+               assertRejected(BOUNDARY.check(write("Sec-Fetch-Site", 
"cross-site")), FETCH_SITE_NOT_SAME_ORIGIN, 403);
+       }
+
+       @Test void d02_sameSiteButNotSameOrigin_rejected() {
+               assertRejected(BOUNDARY.check(write("Sec-Fetch-Site", 
"same-site")), FETCH_SITE_NOT_SAME_ORIGIN, 403);
+       }
+
+       @Test void d03_userInitiatedNavigation_rejected() {
+               assertRejected(BOUNDARY.check(write("Sec-Fetch-Site", "none")), 
FETCH_SITE_NOT_SAME_ORIGIN, 403);
+       }
+
+       @Test void d04_rejectionMessageDoesNotEchoTheCallerSuppliedValue() {
+               var r = BOUNDARY.check(write("Sec-Fetch-Site", 
"<script>alert(1)</script>"));
+               assertFalse(r.message().contains("<script>"),
+                       () -> "a caller-controlled value must not be echoed 
into a rejection message: " + r.message());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // e) Content type — JSON only, which is what excludes the no-preflight 
cross-origin form POST
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void e01_formUrlEncoded_rejected() {
+               // The important one: this content type is what makes a plain 
cross-origin <form> POST possible with no
+               // preflight and no JavaScript at all.
+               assertRejected(BOUNDARY.check(req("POST", goodWriteHeaders(), 
"application/x-www-form-urlencoded")),
+                       CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       @Test void e02_multipartFormData_rejected() {
+               assertRejected(BOUNDARY.check(req("POST", goodWriteHeaders(), 
"multipart/form-data; boundary=x")),
+                       CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       @Test void e03_textPlain_rejected() {
+               assertRejected(BOUNDARY.check(req("POST", goodWriteHeaders(), 
"text/plain")), CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       @Test void e04_absentContentType_rejected() {
+               assertRejected(BOUNDARY.check(req("POST", goodWriteHeaders(), 
null)), CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       @Test void e05_jsonSuffixedType_rejected() {
+               assertRejected(BOUNDARY.check(req("POST", goodWriteHeaders(), 
"application/problem+json")),
+                       CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       @Test void e06_contentTypeCaseAndWhitespaceAreInsignificant() {
+               assertTrue(BOUNDARY.check(req("POST", goodWriteHeaders(), " 
APPLICATION/JSON ; charset=UTF-8")).isAllowed());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // f) CSRF token
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void f01_missingToken_rejected() {
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", null)), 
CSRF_TOKEN_MISSING, 403);
+       }
+
+       @Test void f02_blankToken_rejected() {
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", "")), 
CSRF_TOKEN_MISSING, 403);
+       }
+
+       @Test void f03_wrongToken_rejected() {
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", 
"not-the-token")), CSRF_TOKEN_MISMATCH, 403);
+       }
+
+       @Test void f04_tokenFromADifferentServerInstance_rejected() {
+               // "A token from a different session": each boundary instance 
holds an independent secret, so a token
+               // minted alongside another instance is not this one's.
+               var other = 
LoopbackBoundary.create().authority(AUTHORITY).token(SynchronizerToken.generate()).build();
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", 
other.token().value())), CSRF_TOKEN_MISMATCH, 403);
+       }
+
+       @Test void f05_staleTokenAfterARestart_rejected() {
+               // Simulates a page held open across a restart: the value it 
embedded was minted by the previous process.
+               var beforeRestart = SynchronizerToken.generate();
+               var afterRestart = 
LoopbackBoundary.create().authority(AUTHORITY).token(SynchronizerToken.generate()).build();
+               var h = goodWriteHeaders();
+               h.put("X-Csrf-Token", beforeRestart.value());
+               assertRejected(afterRestart.check(req("POST", h)), 
CSRF_TOKEN_MISMATCH, 403);
+       }
+
+       @Test void f06_tokenTruncatedByOneCharacter_rejected() {
+               var v = TOKEN.value();
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", 
v.substring(0, v.length() - 1))), CSRF_TOKEN_MISMATCH, 403);
+       }
+
+       @Test void f07_tokenWithTrailingWhitespace_rejected() {
+               assertRejected(BOUNDARY.check(write("X-Csrf-Token", 
TOKEN.value() + " ")), CSRF_TOKEN_MISMATCH, 403);
+       }
+
+       @Test void f08_rejectionMessageDoesNotRevealTheServersToken() {
+               var r = BOUNDARY.check(write("X-Csrf-Token", "not-the-token"));
+               assertFalse(r.message().contains(TOKEN.value()),
+                       () -> "a rejection message must not leak the server's 
token: " + r.message());
+       }
+
+       @Test void f09_customCsrfHeaderName_isTheOneRequired() {
+               var b = 
LoopbackBoundary.create().authority(AUTHORITY).token(TOKEN).csrfHeader("X-Console-Csrf").build();
+               var h = goodWriteHeaders();  // carries the default header 
name, not the configured one
+               assertRejected(b.check(req("POST", h)), CSRF_TOKEN_MISSING, 
403);
+               h.put("X-Console-Csrf", TOKEN.value());
+               assertTrue(b.check(req("POST", h)).isAllowed());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // g) Check ordering — the first failure reported is the one whose 
refusal is most informative
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void g01_hostIsCheckedBeforeAnyWriteRule() {
+               var h = goodWriteHeaders();
+               h.put("Host", "evil.example");
+               h.remove("Origin");
+               h.remove("X-Csrf-Token");
+               assertRejected(BOUNDARY.check(req("POST", h, "text/plain")), 
HOST_MISMATCH, 421);
+       }
+
+       @Test void g02_originIsCheckedBeforeContentTypeAndToken() {
+               var h = goodWriteHeaders();
+               h.put("Origin", "http://evil.example";);
+               h.remove("X-Csrf-Token");
+               assertRejected(BOUNDARY.check(req("POST", h, "text/plain")), 
ORIGIN_MISMATCH, 403);
+       }
+
+       @Test void g03_contentTypeIsCheckedBeforeToken() {
+               var h = goodWriteHeaders();
+               h.remove("X-Csrf-Token");
+               assertRejected(BOUNDARY.check(req("POST", h, "text/plain")), 
CONTENT_TYPE_NOT_JSON, 415);
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // h) Method classification — fails closed on anything unrecognized
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void h01_safeMethods() {
+               for (var m : List.of("GET", "HEAD", "OPTIONS", "TRACE", "get", 
"head"))
+                       assertFalse(LoopbackBoundary.isStateChanging(m), m);
+       }
+
+       @Test void h02_writeMethods() {
+               for (var m : List.of("POST", "PUT", "PATCH", "DELETE", "post", 
"LOCK", "PROPPATCH"))
+                       assertTrue(LoopbackBoundary.isStateChanging(m), m);
+       }
+
+       @Test void h03_unknownAndNullMethodsAreTreatedAsWrites() {
+               // Fail closed: a method this framework does not recognize must 
not skip the write checks.
+               assertTrue(LoopbackBoundary.isStateChanging(null));
+               assertTrue(LoopbackBoundary.isStateChanging("SOMETHING-NEW"));
+       }
+
+       @Test void h04_optionsPreflightTakesOnlyTheHostCheck() {
+               // A preflight carries a cross-origin Origin by definition and 
must not be refused for it; the browser
+               // blocks the real request because this application answers no 
CORS headers.
+               var r = mock(HttpServletRequest.class);
+               when(r.getMethod()).thenReturn("OPTIONS");
+               when(r.getHeader("Host")).thenReturn(AUTHORITY);
+               when(r.getHeader("Origin")).thenReturn("http://evil.example";);
+               assertTrue(BOUNDARY.check(r).isAllowed());
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // i) Configuration and accessors
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void i01_originIsDerivedFromAuthority() {
+               assertEquals(AUTHORITY, BOUNDARY.authority());
+               assertEquals(ORIGIN, BOUNDARY.origin());
+               assertEquals(LoopbackBoundary.DEFAULT_CSRF_HEADER, 
BOUNDARY.csrfHeader());
+               assertSame(TOKEN, BOUNDARY.token());
+       }
+
+       @Test void i02_hostAndPortConvenienceForm() {
+               var b = LoopbackBoundary.create().authority("127.0.0.1", 
8877).token(TOKEN).build();
+               assertEquals("127.0.0.1:8877", b.authority());
+               assertEquals("http://127.0.0.1:8877";, b.origin());
+       }
+
+       @Test void i03_authorityRejectsASchemeOrPath() {
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority("http://127.0.0.1:8790";));
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority("127.0.0.1:8790/rest"));
+       }
+
+       @Test void i04_authorityRejectsNullAndBlank() {
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority(null));
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority("  "));
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority(null, 8790));
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority("127.0.0.1", 0));
+       }
+
+       @Test void i05_csrfHeaderRejectsNullAndBlank() {
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().csrfHeader(null));
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().csrfHeader(" "));
+       }
+
+       @Test void i06_buildRequiresAnAuthorityAndAToken() {
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().token(TOKEN).build());
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().authority(AUTHORITY).build());
+               assertThrows(IllegalArgumentException.class, () -> 
LoopbackBoundary.create().token(null));
+       }
+
+       @Test void i07_checkRejectsANullRequest() {
+               assertThrows(IllegalArgumentException.class, () -> 
BOUNDARY.check(null));
+       }
+
+       @Test void i08_resultToString() {
+               assertEquals("ALLOWED", Result.ALLOWED.toString());
+               assertTrue(BOUNDARY.check(write("Host", 
"evil.example")).toString().startsWith("HOST_MISMATCH(421): "));
+       }
+
+       @Test void i09_allowedResultCarriesNoRejectionDetail() {
+               var r = BOUNDARY.check(write(null, null));
+               assertTrue(r.isAllowed());
+               assertNull(r.reason());
+               assertEquals(0, r.status());
+               assertNull(r.message());
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/SynchronizerToken_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/SynchronizerToken_Test.java
new file mode 100644
index 0000000000..46454b4b4e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/filter/SynchronizerToken_Test.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.filter;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for {@link SynchronizerToken}.
+ *
+ * @since 10.0.0
+ */
+class SynchronizerToken_Test extends TestBase {
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // a) Generation
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_generatedTokenIs256BitsOfHex() {
+               var t = SynchronizerToken.generate();
+               assertEquals(64, t.value().length(), "32 random bytes, 
hex-encoded");
+               assertTrue(t.value().matches("[0-9a-f]{64}"), t::value);
+       }
+
+       @Test void a02_generatedTokensAreDistinct() {
+               var seen = new HashSet<String>();
+               for (var i = 0; i < 200; i++)
+                       
assertTrue(seen.add(SynchronizerToken.generate().value()), "generate() produced 
a repeat");
+       }
+
+       @Test void a03_ofWrapsACallerSuppliedValue() {
+               assertEquals("abc", SynchronizerToken.of("abc").value());
+       }
+
+       @Test void a04_ofRejectsNullAndBlank() {
+               assertThrows(IllegalArgumentException.class, () -> 
SynchronizerToken.of(null));
+               assertThrows(IllegalArgumentException.class, () -> 
SynchronizerToken.of(""));
+               assertThrows(IllegalArgumentException.class, () -> 
SynchronizerToken.of("   "));
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // b) Matching
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_matchesItsOwnValue() {
+               var t = SynchronizerToken.generate();
+               assertTrue(t.matches(t.value()));
+       }
+
+       @Test void b02_doesNotMatchAnotherInstancesValue() {
+               
assertFalse(SynchronizerToken.generate().matches(SynchronizerToken.generate().value()));
+       }
+
+       @Test void b03_doesNotMatchNullOrEmpty() {
+               var t = SynchronizerToken.of("abc");
+               assertFalse(t.matches(null));
+               assertFalse(t.matches(""));
+       }
+
+       @Test void b04_doesNotMatchAPrefixOrSuffix() {
+               var t = SynchronizerToken.of("abcdef");
+               assertFalse(t.matches("abc"));
+               assertFalse(t.matches("abcdefg"));
+               assertFalse(t.matches(" abcdef"));
+               assertFalse(t.matches("abcdef "));
+       }
+
+       @Test void b05_matchingIsCaseSensitive() {
+               assertFalse(SynchronizerToken.of("abcdef").matches("ABCDEF"));
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // c) The value is a secret and must not leak through stringification
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_toStringDoesNotIncludeTheValue() {
+               var t = SynchronizerToken.of("super-secret-value");
+               assertFalse(t.toString().contains("super-secret-value"),
+                       () -> "toString() must not leak the token: " + t);
+               assertTrue(t.toString().contains("redacted"), t::toString);
+       }
+}

Reply via email to