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 — notably
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary#isStateChanging(String)}, which delegates here
+ * — 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)} — 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 — including one this framework does not recognize — 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
+ * — 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 — 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 — an interface for the process runner, a supplier for the
+ * credential, a method reference for the callback, an HTTP client whose URL is chosen at runtime — is a
+ * whole-program points-to analysis, and it would still be wrong on the two lines above.
+ * <p>
+ * A naming heuristic ({@code set*}, {@code save*}, {@code delete*}) fails in both directions
+ * — {@code getOrCreateSession} mutates, {@code applyFilter} does not — 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 — 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 — 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 — 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 — 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 — 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 — 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://<authority></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 — 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><form></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 — 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 —
+ * including a process calling back into its own loopback port — 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 — a mock of an external service mounted on its
+ * own port, a background task that drives its own API — 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"} — 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 — 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 —
+ * the application being reached at {@code localhost} when it accepts {@code 127.0.0.1}, say — 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<LoopbackBoundaryFilter> boundary(LoopbackBoundary <jv>b</jv>) {
+ * <jk>var</jk> <jv>reg</jv> = <jk>new</jk> FilterRegistrationBean<>(<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 — 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 — 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 — 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>"<meta name='csrf-token' content='"</js> + <jv>token</jv>.value() + <js>"'>"</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 — 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);
+ }
+}