You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/18 18:31:36 UTC

(juneau) 08/08: TODO-419: Add a loopback write boundary (CSRF/DNS-rebinding defense) for loopback-bound REST apps

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 <ja...@apache.org>
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);
+	}
+}