This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit e1c1394eaa611cd061b8e953865c057b47726c25 Author: James Bognar <[email protected]> AuthorDate: Tue Aug 18 13:38:34 2026 -0400 TODO-419: Add a loopback write boundary (CSRF/DNS-rebinding defense) for loopback-bound REST apps A from-scratch defense for apps bound to 127.0.0.1-style dev/admin servers: Host is checked on every request (DNS-rebinding); Origin / Sec-Fetch-Site / JSON-content-type / CSRF token are checked on state-changing requests; a server-memory, never-cookie SynchronizerToken; a servlet Filter applying it at /* so no endpoint can opt out by omission; a @Mutating marker annotation plus a MethodSafety startup check (wired into the one RestOperations constructor every operation table passes through) that fails the app at boot if a mutating operation is bound to a safe HTTP method, which would bypass the boundary entirely. Javadoc explicitly scopes the threat model: this does not defend against a same-user local process, a malicious browser extension, or human intent. --- .../apache/juneau/rest/server/MethodSafety.java | 148 ++++++ .../org/apache/juneau/rest/server/Mutating.java | 150 ++++++ .../apache/juneau/rest/server/RestOperations.java | 6 + .../rest/server/filter/LoopbackBoundary.java | 535 +++++++++++++++++++++ .../rest/server/filter/LoopbackBoundaryFilter.java | 174 +++++++ .../rest/server/filter/SynchronizerToken.java | 153 ++++++ .../juneau/rest/server/filter/package-info.java | 4 +- .../juneau/rest/server/MethodSafety_Test.java | 257 ++++++++++ .../server/filter/LoopbackBoundaryFilter_Test.java | 224 +++++++++ .../rest/server/filter/LoopbackBoundary_Test.java | 481 ++++++++++++++++++ .../rest/server/filter/SynchronizerToken_Test.java | 100 ++++ 11 files changed, 2231 insertions(+), 1 deletion(-) diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java new file mode 100644 index 0000000000..e02a660423 --- /dev/null +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/MethodSafety.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server; + +import static org.apache.juneau.commons.utils.AssertionUtils.*; + +// Single-type import, not the usual wildcard: this package declares its own Method type, which would otherwise +// shadow java.lang.reflect.Method here. +import java.lang.reflect.Method; +import java.util.*; + +import org.apache.juneau.commons.reflect.*; +import org.apache.juneau.http.response.*; + +/** + * Which HTTP methods are safe, and the startup check that an operation declared {@link Mutating} is not bound to + * one of them. + * + * <p> + * This is the single definition of the safe-method set for this framework. Anything that needs to know whether a + * request may change state — 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); + } +}
