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

(juneau) 04/05: TODO-419/434: Wire the loopback write boundary and a CSRF synchronizer token into Release Manager

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

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

commit 49fd9d4448ff64b529d8204fb02db09135a7f6c4
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 13:40:20 2026 -0400

    TODO-419/434: Wire the loopback write boundary and a CSRF synchronizer token into Release Manager
    
    Before this, the only thing gating a mutating request was per-run arming
    (rm.mode=live plus the typed confirm phrase) -- which answers "did a human mean
    it", never "did this request come from the page we served". The confirm phrase
    is derivable from the app's own page, so a hostile page in the operator's
    browser could POST it to /arm cross-origin with no preflight and no JavaScript
    (TODO-434), and rm.mode=live being the operational default meant that gap was
    open exactly during a real release.
    
    AppConfiguration mints one SynchronizerToken per boot and registers
    LoopbackBoundaryFilter at /* with highest precedence. ConsolePage (new) is a
    CSRF-token-seeded FreemarkerView.of() replacement, adopted across HomeRest /
    CredentialRest / ReleaseRest / ReleaseRunRest / AdminRest. base.ftlh carries the
    token in a <meta> tag with no FreeMarker default (a page that forgets
    ConsolePage fails to render rather than quietly shipping writes that all 403);
    csrf.js (new) wraps window.fetch so every non-GET call attaches it
    automatically.
    
    Every mutating endpoint on CredentialRest / ReleaseRunRest is now
    @Mutating-annotated. Both also set disableContentParam so a write payload
    cannot arrive via ?content= and land in browser history or access logs -- the
    boundary already refuses that shape from a hostile page; this closes the
    accidental, same-origin version of it. ReleaseEngine / NexusStagingClient gain a
    selfCallHeaders() plumbing path so the SAFE-mode Nexus mock's self-calls (same
    app, same port) carry the boundary's own headers too, since the boundary exempts
    nobody including this process.
    
    New CredentialWriteVectorTest (READY-435) is the forged cross-origin
    credential-overwrite regression test. ReleaseRestTest and AdminRest are updated
    for the page(HttpServletRequest) signature ConsolePage requires.
    
    Cross-repo build/bisect hazard: this commit depends on juneau master commit --
    
      e1c1394eaa  TODO-419: Add a loopback write boundary (CSRF/DNS-rebinding
                  defense) for loopback-bound REST apps
    
    Release Manager resolves juneau through versioned Maven coordinates
    (juneau.version=10.0.0-SNAPSHOT in pom.xml), not a reactor or parent build --
    there is no <parent> linking the two poms. LoopbackBoundary,
    LoopbackBoundaryFilter, SynchronizerToken, Mutating and MethodSafety do not
    exist before that commit, so this one will not compile against an older ~/.m2
    juneau install. Anyone bisecting release-manager across this point needs juneau
    mvn install-ed at or past it, not merely release-manager checked out.
---
 .../org/apache/juneau/releng/AppConfiguration.java |  91 ++++++++-
 .../apache/juneau/releng/engine/ReleaseEngine.java |  33 +++-
 .../juneau/releng/nexus/NexusStagingClient.java    |  22 ++-
 .../org/apache/juneau/releng/rest/AdminRest.java   |   9 +-
 .../org/apache/juneau/releng/rest/ConsolePage.java |  60 ++++++
 .../apache/juneau/releng/rest/CredentialRest.java  |  24 ++-
 .../org/apache/juneau/releng/rest/HomeRest.java    |   7 +-
 .../org/apache/juneau/releng/rest/ReleaseRest.java |  13 +-
 .../apache/juneau/releng/rest/ReleaseRunRest.java  |  37 +++-
 src/main/resources/application.properties          |  14 ++
 src/main/resources/static/js/csrf.js               | 102 ++++++++++
 src/main/resources/templates/base.ftlh             |  12 ++
 .../releng/rest/CredentialWriteVectorTest.java     | 211 +++++++++++++++++++++
 .../apache/juneau/releng/rest/ReleaseRestTest.java |  15 +-
 14 files changed, 615 insertions(+), 35 deletions(-)

diff --git a/src/main/java/org/apache/juneau/releng/AppConfiguration.java b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
index 495b99d0e5..865e9b2da7 100644
--- a/src/main/java/org/apache/juneau/releng/AppConfiguration.java
+++ b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
@@ -57,11 +57,16 @@ import org.apache.juneau.releng.rest.MilestoneRest;
 import org.apache.juneau.releng.rest.ReleaseRest;
 import org.apache.juneau.releng.rest.ReleaseRunRest;
 import org.apache.juneau.releng.util.ProcessRunner;
+import org.apache.juneau.rest.server.filter.LoopbackBoundary;
+import org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter;
+import org.apache.juneau.rest.server.filter.SynchronizerToken;
 import org.apache.juneau.secret.keychain.KeychainSecretStore;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.boot.web.servlet.ServletRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
 
 import jakarta.servlet.Servlet;
 
@@ -78,6 +83,74 @@ public class AppConfiguration {
 		return new ProcessRunner.Default();
 	}
 
+	// ==========================================================================================
+	// Loopback write boundary.
+	//
+	// Two independent gates protect the mutating endpoints, and they answer different questions:
+	//
+	//   - This boundary answers "did this request come from the page we served".
+	//   - The engine's per-run arming (see ReleaseEngine.arm / rm.mode) answers "did a human mean it".
+	//
+	// Before this boundary existed only the second gate was present, which meant a hostile page in the
+	// operator's browser could POST the derivable "<version> LIVE" confirm phrase to /arm as a plain
+	// cross-origin form submission -- no preflight, no CORS, no JavaScript needed -- and then trigger a
+	// mutating step. Arming establishes intent; it never established that the caller was us.
+	//
+	// Two framing points, because getting either wrong invites someone to "strengthen" the wrong gate:
+	//
+	//   - The confirm phrase is not a secret and never authenticated anything. It is derivable from a page the
+	//     attacker is already reading. Making it longer or hidden would not help -- see ReleaseEngine.arm. It is
+	//     typing friction against accident, which is a real thing to want and not this boundary's job.
+	//
+	//   - rm.mode defaulting to safe is an operational safety default, not a security control, even though it was
+	//     doing real security work until this boundary landed. Its protection disappears exactly when someone
+	//     starts the app in live mode -- during a real release, when a forged request costs the most. This
+	//     boundary applies in both modes. See application.properties.
+	// ==========================================================================================
+
+	/**
+	 * The process's CSRF secret: minted here, embedded in every page (see the page beans below), and required
+	 * back on every write. One per boot, so a restart invalidates whatever any still-open tab is holding and
+	 * that tab must reload.
+	 *
+	 * <p>Deliberately <b>not</b> a double-submit cookie: cookies are scoped by host and ignore the port, so any
+	 * page served from any other {@code localhost:*} could plant one this application would read back and
+	 * accept. {@link SynchronizerToken} documents that at length.
+	 */
+	@Bean
+	public SynchronizerToken csrfToken() {
+		return SynchronizerToken.generate();
+	}
+
+	/**
+	 * The boundary itself, pinned to the one authority this application is reached at.
+	 *
+	 * <p>Exactly one spelling is accepted: bound to {@code 127.0.0.1:8790}, the app is <b>not</b> reachable as
+	 * {@code localhost:8790}. That is intentional (see {@link LoopbackBoundary}'s canonical-origin section); a
+	 * 421 with a {@code Host}-naming message is the diagnostic when someone uses the other spelling.
+	 */
+	@Bean
+	public LoopbackBoundary loopbackBoundary(SynchronizerToken token,
+			@Value("${server.address:127.0.0.1}") String address, @Value("${server.port:8790}") int port) {
+		return LoopbackBoundary.create().authority(address, port).token(token).build();
+	}
+
+	/**
+	 * Registers the boundary as a servlet filter at {@code /*} with the highest precedence.
+	 *
+	 * <p>A filter rather than a Juneau guard or mixin hook because it must be impossible to omit: every REST
+	 * resource, the SSE endpoint, the Nexus mock, the static assets and the 404s all pass through it, so an
+	 * endpoint added later is covered without anyone remembering to cover it. A guard declared per-operation
+	 * would leave each new endpoint unprotected by default, and its absence would be invisible in review.
+	 */
+	@Bean
+	public FilterRegistrationBean<LoopbackBoundaryFilter> loopbackBoundaryFilter(LoopbackBoundary boundary) {
+		var reg = new FilterRegistrationBean<>(new LoopbackBoundaryFilter(boundary));
+		reg.addUrlPatterns("/*");
+		reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
+		return reg;
+	}
+
 	/**
 	 * FreeMarker engine configuration picked up by the Juneau view bridge (via BeanStore lookup).
 	 *
@@ -186,8 +259,7 @@ public class AppConfiguration {
 	/**
 	 * The shared console-ui chrome stylesheet + themeable logo/page-background assets, mounted independently of
 	 * {@code /rest/*} so the site-absolute {@code /juneau-console/*} URLs every tab's {@code base.ftlh} links
-	 * against resolve the same way regardless of which tab rendered the page. See {@link ConsoleAssetsRest}'s
-	 * class Javadoc for why the servlet itself corrects for the container's servlet-path handling.
+	 * against resolve the same way regardless of which tab rendered the page.
 	 */
 	@Bean
 	public ServletRegistrationBean<Servlet> consoleAssetsRegistration() {
@@ -239,7 +311,7 @@ public class AppConfiguration {
 	/** Resolves live secrets from CredentialService's stores per mutating action. */
 	@Bean
 	public ReleaseEngine.SecretResolver secretResolver(Map<CredentialSpec, SecretStore> stores, AccountStore accounts,
-			ExecutionMode mode, TargetProfile target) {
+			ExecutionMode mode, TargetProfile target, LoopbackBoundary boundary) {
 		return new ReleaseEngine.SecretResolver() {
 			private String read(CredentialSpec spec, String account) {
 				return stores.get(spec).find(account).map(String::new).orElse("");
@@ -279,11 +351,14 @@ public class AppConfiguration {
 			// cannot be used directly. Falls back to ~/.m2/settings.xml only when that Keychain entry
 			// hasn't been stored yet. Under SAFE the base is the loopback mock and the credential is a
 			// throwaway placeholder — the real Keychain secret is never read or sent (OQ-C).
+			//
+			// The SAFE client also carries the boundary's self-call headers: its target is the mock on this
+			// application's own port, which sits behind the same filter as everything else and exempts nobody.
 			@Override
 			public NexusStagingClient nexus() {
 				if (mode == ExecutionMode.SAFE)
 					return NexusStagingClient.create(target.nexusBaseUrl(), target.nexusProfileId(), SAFE_PLACEHOLDER,
-							SAFE_PLACEHOLDER);
+							SAFE_PLACEHOLDER, boundary.selfCallHeaders());
 				return nexusClient(target, availid(), ldapPassword(), () -> NexusStagingClient
 						.create(target.nexusBaseUrl(), target.nexusProfileId(), "apache.releases.https"));
 			}
@@ -313,13 +388,14 @@ public class AppConfiguration {
 	})
 	public ReleaseEngine releaseEngine(RunStateStore store, StepRegistry registry, ProcessRunner runner,
 			BranchResolver branches, EmailService email, MilestoneService milestone,
-			ReleaseEngine.SecretResolver secrets, ExecutionMode mode, TargetProfile target,
+			ReleaseEngine.SecretResolver secrets, ExecutionMode mode, TargetProfile target, LoopbackBoundary boundary,
 			@Value("${rm.state.dir}") String stateDir, @Value("${rm.staging.dir}") String stagingDir,
 			@Value("${rm.repo.dir}") String repoDir, @Value("${rm.git.committer.email}") String committerEmail,
 			@Value("${server.address:127.0.0.1}") String address, @Value("${server.port:8790}") int port) {
 		var engine = new ReleaseEngine(store, registry, runner, branches, Path.of(stateDir), Path.of(stagingDir),
 				repoDir, committerEmail, email, milestone, secrets, mode, target);
 		engine.setMockNexusBaseUrl("http://" + address + ":" + port + "/mock/nexus");
+		engine.setLoopbackHeaders(boundary.selfCallHeaders());
 		engine.recoverOnBoot(); // Demote runs left mid-flight by a previous process on restart.
 		return engine;
 	}
@@ -327,12 +403,13 @@ public class AppConfiguration {
 	@Bean
 	public DropRcService dropRcService(RunStateStore store, StepRegistry registry, ProcessRunner runner,
 			ReleaseEngine.SecretResolver secrets, ReleaseEngine engine, ExecutionMode mode, TargetProfile target,
-			@Value("${rm.staging.dir}") String stagingDir, @Value("${rm.state.dir}") String stateDir) {
+			LoopbackBoundary boundary, @Value("${rm.staging.dir}") String stagingDir,
+			@Value("${rm.state.dir}") String stateDir) {
 		var svc = new DropRcService(store, registry, runner, Path.of(stagingDir).resolve("git/juneau"), Path.of(stateDir),
 				secrets.nexus(), mode, engine::isArmed, target, engine::broadcaster);
 		if (engine.mockNexusBaseUrl() != null)
 			svc.setSafeNexus(NexusStagingClient.create(engine.mockNexusBaseUrl(), target.nexusProfileId(),
-					SAFE_PLACEHOLDER, SAFE_PLACEHOLDER));
+					SAFE_PLACEHOLDER, SAFE_PLACEHOLDER, boundary.selfCallHeaders()));
 		return svc;
 	}
 
diff --git a/src/main/java/org/apache/juneau/releng/engine/ReleaseEngine.java b/src/main/java/org/apache/juneau/releng/engine/ReleaseEngine.java
index f01e343360..457eadc113 100644
--- a/src/main/java/org/apache/juneau/releng/engine/ReleaseEngine.java
+++ b/src/main/java/org/apache/juneau/releng/engine/ReleaseEngine.java
@@ -75,6 +75,7 @@ public class ReleaseEngine {
 
 	// Loopback mock base (http://host:port/mock/nexus). Null in forTests so those keep secrets.nexus().
 	private String mockNexusBaseUrl;
+	private Map<String, String> loopbackHeaders = Map.of();
 
 	/** Everything the REST layer must provide to build a mutating StepContext. */
 	public interface SecretResolver {
@@ -269,6 +270,17 @@ public class ReleaseEngine {
 		return mockNexusBaseUrl;
 	}
 
+	/**
+	 * Headers the SAFE-mode Nexus client must present to get past the loopback write boundary, since the mock it
+	 * talks to is mounted on this application's own port. Empty in {@link #forTests}, where no boundary is
+	 * installed and the transport is a stub anyway.
+	 *
+	 * @see org.apache.juneau.rest.server.filter.LoopbackBoundary#selfCallHeaders()
+	 */
+	public void setLoopbackHeaders(Map<String, String> headers) {
+		this.loopbackHeaders = headers == null ? Map.of() : Map.copyOf(headers);
+	}
+
 	/**
 	 * The run's effective mode: persisted {@code rs.mode} (null → SAFE), capped so a SAFE box can never
 	 * execute LIVE even if on-disk state claims it.
@@ -296,6 +308,25 @@ public class ReleaseEngine {
 	 * Arms {@code version} for LIVE mutation. Rejected unless the box is LIVE, this run is Actual (LIVE),
 	 * and {@code confirm} equals the required phrase {@code "<version> LIVE"}. Returns the outcome
 	 * message-bearing result.
+	 *
+	 * <p><b>Arming is an intent gate, and only that.</b> It establishes that a human meant to do something
+	 * irreversible; it establishes nothing about who or what sent the request. The two questions are separate, and
+	 * the second one is answered by
+	 * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} — see {@code AppConfiguration}.
+	 *
+	 * <p>Specifically, <b>the confirm phrase is not a secret and carries no authenticity.</b> It is
+	 * {@code "<version> LIVE"}, and the version is displayed on the very page an attacker would be reading, so any
+	 * page in the operator's browser could derive it. Before the boundary existed, a hostile page could POST that
+	 * phrase to {@code /arm} as a plain cross-origin form submission and then trigger a mutating step. What the
+	 * phrase does buy is real but narrower than it looks: it makes an irreversible action require deliberate typing
+	 * rather than a misplaced click, which is worth having, and it is worth being clear that this is all it is.
+	 *
+	 * <p><b>Do not respond to that by making the phrase harder to guess.</b> A longer or hidden phrase would not
+	 * help. Anything the page must display so the operator can type it is readable by any script running in that
+	 * browser, and anything the operator memorises instead gets written down. A secret shared with the attacker is
+	 * not a secret, and dressing this gate up as authentication would obscure the fact that authentication is a
+	 * separate control that has to exist on its own — which is what the boundary is. Keep this phrase exactly as
+	 * hard to type as it needs to be to prevent an accident, and no harder.
 	 */
 	public synchronized StepResult arm(String version, String confirm) {
 		if (mode != ExecutionMode.LIVE)
@@ -516,7 +547,7 @@ public class ReleaseEngine {
 		if (runMode == ExecutionMode.SAFE && mockNexusBaseUrl != null) {
 			ctx.target = target.withNexusBaseUrl(mockNexusBaseUrl);
 			ctx.nexus = NexusStagingClient.create(mockNexusBaseUrl, target.nexusProfileId(), "safe-placeholder",
-					"safe-placeholder");
+					"safe-placeholder", loopbackHeaders);
 		} else {
 			ctx.target = target;
 			ctx.nexus = secrets.nexus();
diff --git a/src/main/java/org/apache/juneau/releng/nexus/NexusStagingClient.java b/src/main/java/org/apache/juneau/releng/nexus/NexusStagingClient.java
index 6885feaa1d..301ad96082 100644
--- a/src/main/java/org/apache/juneau/releng/nexus/NexusStagingClient.java
+++ b/src/main/java/org/apache/juneau/releng/nexus/NexusStagingClient.java
@@ -64,10 +64,26 @@ public class NexusStagingClient {
 	 * here. Under SAFE mode {@code baseUrl} is the in-app loopback mock and the credential is a placeholder.
 	 */
 	public static NexusStagingClient create(String baseUrl, String profileId, String username, String password) {
+		return create(baseUrl, profileId, username, password, Map.of());
+	}
+
+	/**
+	 * As {@link #create(String, String, String, String)}, plus {@code extraHeaders} added to every request.
+	 *
+	 * <p>
+	 * Exists for the SAFE-mode loopback mock. That mock is mounted on this application's own port, behind the
+	 * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary LoopbackBoundary}, which grants no exemption
+	 * to a caller merely because it happens to be this process — so the close/drop/promote {@code POST}s below
+	 * must present the same {@code Origin} and CSRF token the browser does. Pass
+	 * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary#selfCallHeaders() selfCallHeaders()} here.
+	 * The real Nexus needs none of this and is given an empty map.
+	 */
+	public static NexusStagingClient create(String baseUrl, String profileId, String username, String password,
+			Map<String, String> extraHeaders) {
 		var http = HttpClient.newHttpClient();
 		var basic = "Basic "
 				+ Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
-		return new NexusStagingClient(transport(http, basic, baseUrl), profileId);
+		return new NexusStagingClient(transport(http, basic, baseUrl, extraHeaders), profileId);
 	}
 
 	/**
@@ -84,11 +100,13 @@ public class NexusStagingClient {
 		return create(baseUrl, profileId, creds.username(), creds.password());
 	}
 
-	private static Transport transport(HttpClient http, String basic, String baseUrl) {
+	private static Transport transport(HttpClient http, String basic, String baseUrl,
+			Map<String, String> extraHeaders) {
 		return (method, path, body) -> {
 			try {
 				var b = HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Authorization", basic)
 						.header("Accept", "application/json").header("Content-Type", "application/json");
+				extraHeaders.forEach(b::header);
 				var req = (body == null) ? b.method(method, HttpRequest.BodyPublishers.noBody()).build()
 						: b.method(method, HttpRequest.BodyPublishers.ofString(body)).build();
 				var resp = http.send(req, HttpResponse.BodyHandlers.ofString());
diff --git a/src/main/java/org/apache/juneau/releng/rest/AdminRest.java b/src/main/java/org/apache/juneau/releng/rest/AdminRest.java
index 6b0c439c69..e0754e238f 100644
--- a/src/main/java/org/apache/juneau/releng/rest/AdminRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/AdminRest.java
@@ -24,7 +24,6 @@ import org.apache.juneau.rest.server.RestGet;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
-import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
 import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 import org.apache.juneau.rest.server.views.PageDef;
@@ -32,6 +31,8 @@ import org.apache.juneau.rest.server.views.PageTable;
 import org.apache.juneau.rest.server.views.Tab;
 import org.apache.juneau.rest.server.views.ViewsMixin;
 
+import jakarta.servlet.http.HttpServletRequest;
+
 /**
  * Admin tab (TODO-399 Phase C dogfood): a single multi-tab page composing the app's existing
  * {@link ReleaseRest#releasesView() Releases} and {@link CredentialRest#credentialsView() Credentials} rich views
@@ -43,7 +44,7 @@ import org.apache.juneau.rest.server.views.ViewsMixin;
  * definitions, and each child view's {@code dataUrl} stays absolute (its owning resource's own mount), so the
  * ajax data draws still hit {@link ReleaseRest#data()} / {@link CredentialRest#status()} exactly as they do from
  * the standalone Releases/Credentials pages. Per {@link PageTable}'s contract, the emitted per-view markup (marker
- * table + VIEW_META sidecar) is byte-for-byte identical to what {@link ReleaseRest#page()} /
+ * table + VIEW_META sidecar) is byte-for-byte identical to what {@link ReleaseRest#page(HttpServletRequest)} /
  * {@code CredentialRest}'s own view would emit standalone &mdash; this resource only adds the tab-bar/panel shell
  * and the PAGE_META sidecar around them.
  */
@@ -76,9 +77,9 @@ public class AdminRest extends BasicRestResource {
 
 	/** Human page &mdash; the composed tab/sub-tab page shell (emitted as trusted markup) + PAGE_META sidecar. */
 	@RestGet("/")
-	public View page() {
+	public View page(HttpServletRequest req) {
 		var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(adminPage()));
-		return FreemarkerView.of("admin")
+		return ConsolePage.of("admin", req)
 			.attr("pageTable", markup)
 			.attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH))
 			.attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH))
diff --git a/src/main/java/org/apache/juneau/releng/rest/ConsolePage.java b/src/main/java/org/apache/juneau/releng/rest/ConsolePage.java
new file mode 100644
index 0000000000..ddf708fec3
--- /dev/null
+++ b/src/main/java/org/apache/juneau/releng/rest/ConsolePage.java
@@ -0,0 +1,60 @@
+/*
+ * 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.releng.rest;
+
+import org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter;
+import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * Starts every console page's view with the CSRF token {@code base.ftlh} embeds.
+ *
+ * <p>The token is read from the request attribute {@link LoopbackBoundaryFilter} sets on requests it allowed
+ * through, rather than from an injected bean, so that a page can only carry a token if the boundary is actually
+ * installed in front of it. Wiring the two together this way means "we serve tokens" and "we check tokens" cannot
+ * drift apart into the worst combination — a page that hands out a token nothing validates.
+ *
+ * <p>Forgetting to route a new page through here is safe rather than dangerous, and loud rather than quiet:
+ * {@code base.ftlh} references {@code ${csrfToken}} with no FreeMarker default, so the page fails to render at
+ * once instead of silently shipping without a token. Even if it did render, its writes would be refused by the
+ * filter. The security decision belongs to the filter; this class only supplies the UI half.
+ */
+final class ConsolePage {
+
+	private ConsolePage() {}
+
+	/**
+	 * The named template, seeded with {@code csrfToken}.
+	 *
+	 * <p>When the attribute is absent — no boundary filter ran in front of this request, as in a unit test that
+	 * dispatches straight at the resource — the token renders empty rather than failing the render. An empty
+	 * token is never less safe than no token: the only thing that reads it is the boundary's check, which
+	 * refuses an empty value like any other wrong one. If no boundary is installed, there is nothing for a token
+	 * to have protected in the first place. The page-side complaint lives in {@code csrf.js}, which logs when it
+	 * finds the meta tag empty, so the condition is still visible where somebody would notice it.
+	 *
+	 * @param template The template name, relative to the configured base path.
+	 * @param req The current request, carrying the boundary's token attribute.
+	 * @return A view the caller adds its own page attributes to.
+	 */
+	static FreemarkerView of(String template, HttpServletRequest req) {
+		var token = req.getAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE);
+		return FreemarkerView.of(template).attr("csrfToken", token == null ? "" : token);
+	}
+}
diff --git a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
index 848c546d20..290a4b45a8 100644
--- a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
@@ -21,6 +21,7 @@ import java.util.List;
 import org.apache.juneau.commons.inject.Bean;
 import org.apache.juneau.http.Content;
 import org.apache.juneau.http.Path;
+import org.apache.juneau.rest.server.Mutating;
 import org.apache.juneau.rest.server.Rest;
 import org.apache.juneau.rest.server.RestDelete;
 import org.apache.juneau.rest.server.RestGet;
@@ -28,7 +29,6 @@ import org.apache.juneau.rest.server.RestPost;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
-import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
 import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 import org.apache.juneau.rest.server.views.Column;
@@ -40,8 +40,19 @@ import org.apache.juneau.releng.credential.CredentialService;
 import org.apache.juneau.releng.credential.CredentialStatus;
 import org.apache.juneau.releng.credential.Validator.ValidationResult;
 
-/** Credentials tab: store + live-validate Apache/GPG/GitHub secrets. Never returns secret values. */
-@Rest(path = "/credentials", title = "Credentials", responseProcessors = FreemarkerViewRenderer.class)
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * Credentials tab: store + live-validate Apache/GPG/GitHub secrets. Never returns secret values.
+ *
+ * <p>{@code disableContentParam} is set because Juneau's default allows a {@code POST} body to arrive in a
+ * {@code &content=} query parameter instead, and a secret that travels in a URL lands in browser history, in any
+ * access log, and in the {@code Referer} of the next request. {@code LoopbackBoundary} already refuses that shape
+ * from a hostile page (it carries no JSON content type), so this is not the attack control — it closes the accident
+ * of a developer, a curl line or a copied URL doing it. See {@code CredentialWriteVectorTest}.
+ */
+@Rest(path = "/credentials", title = "Credentials", responseProcessors = FreemarkerViewRenderer.class,
+	disableContentParam = "true")
 public class CredentialRest extends BasicRestResource {
 
 	/** This resource's absolute mount (RootRest {@code /rest/*} + {@code /credentials}), used by {@link #credentialsView()}. */
@@ -85,8 +96,8 @@ public class CredentialRest extends BasicRestResource {
 
 	/** Human page. */
 	@RestGet("/")
-	public View page() {
-		return FreemarkerView.of("credentials").attr("credentials", service.status());
+	public View page(HttpServletRequest req) {
+		return ConsolePage.of("credentials", req).attr("credentials", service.status());
 	}
 
 	/** JSON status for all credentials (no secrets). */
@@ -96,6 +107,7 @@ public class CredentialRest extends BasicRestResource {
 	}
 
 	/** Store/update a credential. Body: {account?, secret}. Apache/GPG send account (availid/keyId). */
+	@Mutating("replaces a stored credential in the Keychain")
 	@RestPost("/{name}")
 	public CredentialStatus set(@Path("name") String name, @Content StoreRequest body) {
 		service.store(name, body.account, body.secret);
@@ -103,12 +115,14 @@ public class CredentialRest extends BasicRestResource {
 	}
 
 	/** Run the live validation. */
+	@Mutating("caches a new validation verdict, and makes an authenticated call as the user")
 	@RestPost("/{name}/validate")
 	public ValidationResult validate(@Path("name") String name) {
 		return service.validate(name);
 	}
 
 	/** Remove a credential from the Keychain. */
+	@Mutating("deletes a stored credential from the Keychain")
 	@RestDelete("/{name}")
 	public CredentialStatus remove(@Path("name") String name) {
 		service.delete(name);
diff --git a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
index eb6c115be6..d2a88198f7 100644
--- a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
@@ -23,10 +23,11 @@ import org.apache.juneau.rest.server.RestGet;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
-import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
 import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 
+import jakarta.servlet.http.HttpServletRequest;
+
 /** Home tab: default landing page with basic usage instructions. */
 @Rest(path = "/home", title = "Home", responseProcessors = FreemarkerViewRenderer.class)
 public class HomeRest extends BasicRestResource {
@@ -40,7 +41,7 @@ public class HomeRest extends BasicRestResource {
 
 	/** Human page. */
 	@RestGet("/")
-	public View page() {
-		return FreemarkerView.of("home");
+	public View page(HttpServletRequest req) {
+		return ConsolePage.of("home", req);
 	}
 }
diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
index 6b8879d7e8..33a9b58cb6 100644
--- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
@@ -29,7 +29,6 @@ import org.apache.juneau.rest.server.converter.QueryableSettings;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
-import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
 import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 import org.apache.juneau.rest.server.views.Column;
@@ -42,12 +41,14 @@ import org.apache.juneau.rest.server.views.ViewsMixin;
 import org.apache.juneau.releng.release.Release;
 import org.apache.juneau.releng.release.ReleaseListService;
 
+import jakarta.servlet.http.HttpServletRequest;
+
 /**
  * Releases tab: server-rendered HTML page + DataTables server-side-processing data endpoint.
  *
  * <p>
  * Built on the {@code juneau-rest-server-views} rich-view toolkit: {@link #releasesView()} declares the typed
- * {@link ViewDef} (columns + renderers + ribbon), {@link #page()} emits its {@link ViewTable} shell as trusted markup
+ * {@link ViewDef} (columns + renderers + ribbon), {@link #page(HttpServletRequest)} emits its {@link ViewTable} shell as trusted markup
  * into the FreeMarker template, and {@link #data()} serves the {@code DataTablesResults} envelope via
  * {@link ProtocolQueryable} + the view's {@link ViewDef#queryableSettings() queryable settings}. The four runtime
  * assets are served by the composed {@link ViewsMixin} at this resource's mount.
@@ -118,9 +119,9 @@ public class ReleaseRest extends BasicRestResource {
 
 	/** Human page — the rich-view table shell (emitted as trusted markup) + JSON sidecar, hydrated by the toolkit JS. */
 	@RestGet("/")
-	public View page() {
+	public View page(HttpServletRequest req) {
 		var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(releasesView()));
-		return FreemarkerView.of("releases")
+		return ConsolePage.of("releases", req)
 			.attr("viewTable", markup)
 			.attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH))
 			.attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH))
@@ -155,9 +156,9 @@ public class ReleaseRest extends BasicRestResource {
 	 * part of the path so a future multi-RC history view doesn't need a URL-breaking change.
 	 */
 	@RestGet("/{version}/{rc}")
-	public View detail(@Path("version") String version, @Path("rc") String rc) {
+	public View detail(@Path("version") String version, @Path("rc") String rc, HttpServletRequest req) {
 		var release = findByVersion(version);
-		return FreemarkerView.of("release-detail").attr("release", release).attr("rc", rc);
+		return ConsolePage.of("release-detail", req).attr("release", release).attr("rc", rc);
 	}
 
 	private Release findByVersion(String version) {
diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
index fd5deb6a12..366337ac64 100644
--- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
@@ -23,13 +23,13 @@ import org.apache.juneau.http.Content;
 import org.apache.juneau.http.Path;
 import org.apache.juneau.http.response.Conflict;
 import org.apache.juneau.http.response.NotFound;
+import org.apache.juneau.rest.server.Mutating;
 import org.apache.juneau.rest.server.Rest;
 import org.apache.juneau.rest.server.RestGet;
 import org.apache.juneau.rest.server.RestPost;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
-import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
 import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 import org.apache.juneau.releng.engine.DropRcService;
@@ -39,8 +39,18 @@ import org.apache.juneau.releng.engine.ReleaseEngine;
 import org.apache.juneau.releng.engine.RunState;
 import org.apache.juneau.releng.engine.StepResult;
 
-/** New Release tab: pipeline control panel (View) plus JSON run/step/vote/drop-RC actions. */
-@Rest(path = "/runs", title = "New Release", responseProcessors = FreemarkerViewRenderer.class)
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * New Release tab: pipeline control panel (View) plus JSON run/step/vote/drop-RC actions.
+ *
+ * <p>{@code disableContentParam} is set for the reason given on {@code CredentialRest}: Juneau's default lets a
+ * {@code POST} body arrive in a {@code &content=} query parameter, which puts the arm confirmation phrase and every
+ * other action payload into browser history and access logs. The boundary refuses that shape from a hostile page;
+ * this closes the accidental use of it.
+ */
+@Rest(path = "/runs", title = "New Release", responseProcessors = FreemarkerViewRenderer.class,
+	disableContentParam = "true")
 public class ReleaseRunRest extends BasicRestResource {
 
 	private final ReleaseEngine engine;
@@ -60,11 +70,11 @@ public class ReleaseRunRest extends BasicRestResource {
 
 	/** Human page — the pipeline control panel for the active run (or an empty start form). */
 	@RestGet("/")
-	public View page() {
+	public View page(HttpServletRequest req) {
 		var liveCapable = engine.mode() == ExecutionMode.LIVE;
 		var active = engine.displayRun().orElse(null);
 		var runMode = active == null ? ExecutionMode.SAFE : engine.effectiveMode(active);
-		var view = FreemarkerView.of("new-release").attr("steps", engine.registry().steps())
+		var view = ConsolePage.of("new-release", req).attr("steps", engine.registry().steps())
 				.attr("mode", runMode.name()).attr("appMode", engine.mode().name())
 				.attr("liveCapable", Boolean.valueOf(liveCapable));
 		// FreemarkerView.attr() rejects null values by design; the template only checks run??
@@ -86,6 +96,7 @@ public class ReleaseRunRest extends BasicRestResource {
 	 * capped to SAFE unless the box was started with {@code rm.mode=live}. Rejects a second concurrent run
 	 * with 409.
 	 */
+	@Mutating("creates a run and writes its state to disk")
 	@RestPost("/")
 	public RunState start(@Content StartRequest body) {
 		try {
@@ -103,6 +114,7 @@ public class ReleaseRunRest extends BasicRestResource {
 	 * {@code knownIssues}, {@code acknowledgements}) so they can be edited before each email is composed.
 	 * Returns the updated run.
 	 */
+	@Mutating("updates the run's persisted narrative fields")
 	@RestPost("/{version}/details")
 	public RunState details(@Path("version") String version, @Content DetailsRequest body) {
 		requireRun(version);
@@ -110,6 +122,9 @@ public class ReleaseRunRest extends BasicRestResource {
 		return engine.updateDetails(version, b.releaseSummary, b.highlights, b.knownIssues, b.acknowledgements);
 	}
 
+	// No @Mutating: a preview is a dry run by construction and writes nothing. The annotation is a claim about
+	// effects, so putting it here to be "safe" would be a false one -- and would make the two preview endpoints
+	// indistinguishable from the apply endpoints they exist to be safer than.
 	@RestPost("/{version}/steps/{stepId}/preview")
 	public Preview preview(@Path("version") String version, @Path("stepId") String stepId,
 			@Content Map<String, String> form) {
@@ -117,6 +132,7 @@ public class ReleaseRunRest extends BasicRestResource {
 		return engine.preview(version, stepId, form == null ? Map.of() : form);
 	}
 
+	@Mutating("executes a release step; in LIVE mode this mutates git, SVN, Nexus, GitHub or mailing lists")
 	@RestPost("/{version}/steps/{stepId}/apply")
 	public StepResult apply(@Path("version") String version, @Path("stepId") String stepId,
 			@Content Map<String, String> form) {
@@ -136,6 +152,7 @@ public class ReleaseRunRest extends BasicRestResource {
 	 * history. The UI picks the button label from the step's current status; the engine doesn't care which
 	 * label was clicked.
 	 */
+	@Mutating("re-executes a release step, overwriting its status and log in place")
 	@RestPost("/{version}/steps/{stepId}/resume")
 	public StepResult resume(@Path("version") String version, @Path("stepId") String stepId,
 			@Content Map<String, String> form) {
@@ -143,6 +160,7 @@ public class ReleaseRunRest extends BasicRestResource {
 		return engine.apply(version, stepId, form == null ? Map.of() : form);
 	}
 
+	@Mutating("marks a step skipped in the persisted run state")
 	@RestPost("/{version}/steps/{stepId}/skip")
 	public StepResult skip(@Path("version") String version, @Path("stepId") String stepId) {
 		requireRun(version);
@@ -153,7 +171,13 @@ public class ReleaseRunRest extends BasicRestResource {
 	 * Arm this run for LIVE mutation. Requires a typed confirm phrase ({@code "<version> LIVE"}) and is
 	 * rejected unless the box is LIVE and this run is Actual (LIVE). Arming is in-memory on the engine and
 	 * drops on any restart.
+	 *
+	 * <p>This is an <b>intent</b> gate: the confirm phrase shows a human deliberately typed something, and is not
+	 * a secret and not authentication — it is derivable from the page it is typed on. Whether the request came from
+	 * a page this application served is a separate question, answered by the loopback boundary in front of every
+	 * endpoint. See {@link org.apache.juneau.releng.engine.ReleaseEngine#arm(String, String)}.
 	 */
+	@Mutating("arms the run for irreversible LIVE mutation")
 	@RestPost("/{version}/arm")
 	public StepResult arm(@Path("version") String version, @Content ArmRequest body) {
 		requireRun(version);
@@ -161,6 +185,7 @@ public class ReleaseRunRest extends BasicRestResource {
 	}
 
 	/** Advance a review-gate step held in {@code AWAITING_REVIEW} once the human has confirmed the read-only work. */
+	@Mutating("advances a held review-gate step")
 	@RestPost("/{version}/steps/{stepId}/confirm-review")
 	public StepResult confirmReview(@Path("version") String version, @Path("stepId") String stepId) {
 		requireRun(version);
@@ -168,6 +193,7 @@ public class ReleaseRunRest extends BasicRestResource {
 	}
 
 	/** Record the vote outcome; 'rejected' triggers Drop-RC. */
+	@Mutating("records the vote outcome and runs the tally step")
 	@RestPost("/{version}/vote-result")
 	public StepResult voteResult(@Path("version") String version, @Content VoteResultRequest body) {
 		requireRun(version);
@@ -182,6 +208,7 @@ public class ReleaseRunRest extends BasicRestResource {
 		return dropRc.preview(version);
 	}
 
+	@Mutating("drops the release candidate from Nexus and dist SVN, and bumps the RC number")
 	@RestPost("/{version}/drop-rc/apply")
 	public StepResult dropRcApply(@Path("version") String version, @Content DropRcRequest body) {
 		var rs = requireRun(version);
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index b23e2978ff..b7bc9003a1 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -24,4 +24,18 @@ rm.git.committer.email=jamesbognar@apache.org
 rm.slack.webhook=
 # Execution mode: safe (default) simulates all mutations + routes Nexus to the in-app loopback mock;
 # live executes real mutations (and still requires per-run arming at the guard chokepoint).
+#
+# safe being the default is an OPERATIONAL SAFETY default, not a security control. It exists so that starting the
+# app, exploring it, or running it in a test cannot mutate anything public by accident.
+#
+# It is worth recording that until the loopback write boundary landed (see AppConfiguration), this default was in
+# fact doing real security work, and was the only thing standing between a hostile page in the operator's browser
+# and a published release: that page could derive the "<version> LIVE" confirm phrase from the app's own page, POST
+# it to /arm cross-origin, and trigger a mutating step. Nothing rejected the request; the box simply had to have
+# been started in live mode.
+#
+# That is exactly why it was never adequate as a security control. Its protection evaporates the moment someone
+# starts the app with rm.mode=live -- which is to say, during an actual release, when the consequences of a forged
+# request are at their worst. A control that is present only when it does not matter is not a control. The
+# boundary is what closes that gap, and it applies in both modes.
 rm.mode=safe
diff --git a/src/main/resources/static/js/csrf.js b/src/main/resources/static/js/csrf.js
new file mode 100644
index 0000000000..8a4c858c07
--- /dev/null
+++ b/src/main/resources/static/js/csrf.js
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+/*
+ * Attaches this page's CSRF token to every state-changing fetch.
+ *
+ * The server side of this is LoopbackBoundary, which refuses any non-GET request that does not present the
+ * token. Rather than adding a header at each of the app's fetch() call sites -- where the next one added would
+ * omit it, and would fail at runtime in whichever corner of the UI nobody clicked before shipping -- this wraps
+ * window.fetch once, so carrying the token is the default and opting out is not expressible.
+ *
+ * Loaded first in base.ftlh's <head>, before any script that might issue a request.
+ *
+ * The token is read from the <meta> tag the server rendered. It is deliberately never written to
+ * document.cookie: cookies are scoped by host and ignore the port, so a cookie-borne token could be planted by
+ * any page served from any other port on this host. See SynchronizerToken's javadoc.
+ */
+(function () {
+  'use strict';
+
+  var meta = document.querySelector('meta[name="csrf-token"]');
+  var token = meta && meta.getAttribute('content');
+  if (!token) {
+    // No token means the page was not served through ConsolePage, or the boundary filter is not installed.
+    // Say so once, loudly: every write from this page is about to be refused, and a console message is far
+    // easier to act on than a screenful of unexplained 403s.
+    if (window.console && console.error) {
+      console.error('No csrf-token meta tag; state-changing requests from this page will be refused.');
+    }
+    return;
+  }
+
+  // 'simple' request methods per fetch: these never need the token, and adding it to a cross-origin GET would
+  // hand it to whatever was fetched. Anything else -- including a method this list does not know -- gets it.
+  var SAFE = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1 };
+
+  // Only same-origin requests get the token. A relative URL is same-origin by construction; an absolute one is
+  // compared against location.origin. This keeps the secret from leaking to a third-party endpoint if some
+  // future code fetches one.
+  function isSameOrigin(url) {
+    try {
+      return new URL(url, window.location.href).origin === window.location.origin;
+    } catch (e) {
+      return false;
+    }
+  }
+
+  var nativeFetch = window.fetch.bind(window);
+
+  window.fetch = function (resource, init) {
+    var opts = init || {};
+    var method = (opts.method || (resource && resource.method) || 'GET').toUpperCase();
+    var url = (resource && resource.url) || resource;
+
+    if (SAFE[method] || !isSameOrigin(url)) {
+      return nativeFetch(resource, init);
+    }
+
+    // Headers may arrive as a Headers instance, an array of pairs, or a plain object; normalizing through
+    // Headers handles all three without caring which the caller used.
+    var headers = new Headers((opts.headers) || (resource && resource.headers) || undefined);
+    headers.set('X-Csrf-Token', token);
+
+    // The boundary also requires a JSON content type on writes, which is what rules out the form-encoded
+    // shapes a cross-origin <form> can submit with no preflight. It is required on every write, including a
+    // bodiless one: a POST with no body and no Content-Type is itself a no-preflight shape, so the server
+    // cannot exempt it, and the client must therefore supply the header even when it has nothing to send.
+    if (!headers.has('Content-Type')) {
+      headers.set('Content-Type', 'application/json');
+    }
+
+    var merged = {};
+    for (var k in opts) {
+      if (Object.prototype.hasOwnProperty.call(opts, k)) {
+        merged[k] = opts[k];
+      }
+    }
+    merged.headers = headers;
+    return nativeFetch(resource, merged);
+  };
+
+  // Exposed for any code that must build a request by hand (e.g. XMLHttpRequest, or a fetch it deliberately
+  // routes around the wrapper).
+  window.RmCsrf = {
+    token: token,
+    header: 'X-Csrf-Token'
+  };
+})();
diff --git a/src/main/resources/templates/base.ftlh b/src/main/resources/templates/base.ftlh
index e59bac0ac7..13b587e22f 100644
--- a/src/main/resources/templates/base.ftlh
+++ b/src/main/resources/templates/base.ftlh
@@ -27,6 +27,18 @@
 <head>
     <meta charset="utf-8">
     <title>Apache Juneau · Release Manager</title>
+    <#-- The server-held CSRF token for this boot, required back on every state-changing request by the loopback
+         write boundary (LoopbackBoundary). Supplied by ConsolePage.of(...) from the request attribute the
+         boundary's filter sets; referenced with no FreeMarker default on purpose, so a page that forgets to go
+         through ConsolePage fails to render instead of quietly shipping a UI whose writes all 403.
+
+         This is a synchronizer token, NOT a double-submit cookie. Do not "simplify" it into one: cookies ignore
+         the port, so any page on any other localhost:* could plant a value this app would read back and accept
+         as its own. csrf.js reads it from here; nothing writes it to document.cookie. -->
+    <meta name="csrf-token" content="${csrfToken}">
+    <#-- Must load before any other script: it wraps window.fetch so every non-GET request carries the token
+         above, rather than leaving each call site to remember. -->
+    <script src="/js/csrf.js"></script>
     <link rel="icon" type="image/svg+xml" href="/img/oakleaf.svg">
     <#if activeTab?? && (activeTab == 'releases' || activeTab == 'admin')>
     <link rel="stylesheet" href="/datatables/dataTables.dataTables.min.css">
diff --git a/src/test/java/org/apache/juneau/releng/rest/CredentialWriteVectorTest.java b/src/test/java/org/apache/juneau/releng/rest/CredentialWriteVectorTest.java
new file mode 100644
index 0000000000..79b710413e
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/rest/CredentialWriteVectorTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.releng.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+import java.util.*;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.commons.secret.*;
+import org.apache.juneau.releng.credential.*;
+import org.apache.juneau.rest.mock.MockRestClient;
+import org.apache.juneau.rest.server.filter.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
+
+/**
+ * The credential-overwrite vector, reproduced and then closed.
+ *
+ * <p>
+ * The finding this pins down (recorded as F1 in {@code .work/specs/2026-08-18-console-credentials-surface.md}) was
+ * established by reading framework defaults rather than by issuing a request: {@code CredentialRest} extends
+ * {@code BasicRestResource}, whose default parser list includes {@code UrlEncodingParser} and
+ * {@code PlainTextParser}, and {@code disableContentParam} defaults to {@code false}. If that reading is right,
+ * then a plain cross-origin {@code <form method="POST">} in any page the operator has open can replace a stored
+ * credential &mdash; no JavaScript, and no CORS preflight, because form-encoded is one of the three content types a
+ * browser may send cross-origin without one.
+ *
+ * <p>
+ * Group a reproduces it. The store is an {@link InMemorySecretStore} and never the Keychain, so running these tests
+ * cannot touch a real credential &mdash; but everything between the request and the store is the real thing: the
+ * real resource class, its real default parser list, real content negotiation and real {@code @Content} binding.
+ * The vector is the framework's defaults doing exactly what they are configured to do, which is why it holds for
+ * any application on {@code BasicUniversalConfig} and not only this one.
+ *
+ * <p>
+ * Group b closes it, at the boundary rather than at the resource. {@link MockRestClient} dispatches into a
+ * {@code RestContext} directly and so does not run the servlet filter chain &mdash; which is the point: group a's
+ * requests reach the handler precisely because nothing stands in front of it, and the fix is to put something
+ * there. Group b therefore asserts the refusal at {@link LoopbackBoundary}, where the decision is actually made.
+ *
+ * @see LoopbackBoundary
+ */
+class CredentialWriteVectorTest {
+
+	private static final String KEYCHAIN_FREE_ACCOUNT = "jdoe";
+
+	private InMemorySecretStore apacheStore;
+	private CredentialService service;
+
+	@BeforeEach
+	void setUp(@TempDir Path stateDir) {
+		apacheStore = new InMemorySecretStore();
+		var stores = new EnumMap<CredentialSpec,SecretStore>(CredentialSpec.class);
+		for (var spec : CredentialSpec.values())
+			stores.put(spec, spec == CredentialSpec.APACHE_LDAP ? apacheStore : new InMemorySecretStore());
+		service = new CredentialService(stores, new EnumMap<>(CredentialSpec.class), new AccountStore(stateDir));
+		service.store("apache", KEYCHAIN_FREE_ACCOUNT, "the-real-password");
+	}
+
+	@SuppressWarnings("resource") // Caller closes via try-with-resources; MockRestClient caches RestContext per class, so opt out with a fresh StackOverlay.
+	private MockRestClient client() {
+		return MockRestClient.builder(new CredentialRest(service)).overridingBeanStore(new StackOverlay()).build();
+	}
+
+	private String storedSecret() {
+		return apacheStore.find(KEYCHAIN_FREE_ACCOUNT).map(String::new).orElse(null);
+	}
+
+	// -----------------------------------------------------------------------------------------------------------
+	// a - the vector, reproduced through the real resource
+	// -----------------------------------------------------------------------------------------------------------
+
+	@Test
+	void formEncodedPostOverwritesAStoredCredential() throws Exception {
+		// The whole finding in one assertion. This is the body a cross-origin <form> submits, with the content
+		// type a browser sets for it, and it lands in the Keychain slot in a real deployment.
+		try (var c = client();
+			var res = c.post("/apache").header("Content-Type", "application/x-www-form-urlencoded")
+				.bodyString("account=" + KEYCHAIN_FREE_ACCOUNT + "&secret=hijacked").run()) {
+			assertEquals(200, res.getStatusCode());
+		}
+		assertEquals("hijacked", storedSecret(), "form-encoded POST did not reach the store");
+	}
+
+	@Test
+	void plainTextPostOverwritesAStoredCredential() throws Exception {
+		// text/plain is the second of the three no-preflight content types, and PlainTextParser is in the same
+		// default list. Included because closing only the urlencoded shape would leave the vector open.
+		try (var c = client();
+			var res = c.post("/apache").header("Content-Type", "text/plain")
+				.bodyString("{\"account\":\"" + KEYCHAIN_FREE_ACCOUNT + "\",\"secret\":\"hijacked-plain\"}").run()) {
+			assertEquals(200, res.getStatusCode());
+		}
+		assertEquals("hijacked-plain", storedSecret(), "text/plain POST did not reach the store");
+	}
+
+	@Test
+	void contentQueryParameterIsRefusedByTheResource() throws Exception {
+		// The second half of F1, asserted closed rather than reproduced. disableContentParam defaults to false, so
+		// the body can travel in the URL instead; CredentialRest now sets it to "true", and this fails if that is
+		// removed.
+		//
+		// Worth a test of its own rather than folding into the form vector, for two reasons. The attacker sends no
+		// body and therefore no content type at all, so a check that only inspected the content type of a present
+		// body would wave it through. And a URL-borne secret has a second life the form shape does not: browser
+		// history, access logs, and the Referer of whatever the page loads next.
+		//
+		// The payload is UON, not JSON, because that is what the server would actually parse -- RestRequest's
+		// constructor rewrites the content type to UON's own and hands the parameter to UonParser. JSON here would
+		// be refused as malformed and this test would pass while proving nothing about disableContentParam. It is
+		// asserted on the store, not only the status, for the same reason.
+		//
+		// This is defence in depth and not the primary control: the boundary refuses this shape from a hostile
+		// page anyway (group b). What it buys is that a developer or a copied URL cannot use it either.
+		try (var c = client();
+			var res = c.post("/apache?content=" + java.net.URLEncoder.encode(
+				"(account=" + KEYCHAIN_FREE_ACCOUNT + ",secret=hijacked-via-url)",
+				java.nio.charset.StandardCharsets.UTF_8)).run()) {
+			assertNotEquals(200, res.getStatusCode(), "content= parameter was still honoured");
+		}
+		assertEquals("the-real-password", storedSecret(), "content= parameter still reached the store");
+	}
+
+	// -----------------------------------------------------------------------------------------------------------
+	// b - closed by the boundary
+	// -----------------------------------------------------------------------------------------------------------
+
+	private static LoopbackBoundary boundary() {
+		return LoopbackBoundary.create().authority("127.0.0.1:8790").token(SynchronizerToken.of("t0ken")).build();
+	}
+
+	/**
+	 * The content type is stubbed on {@code getContentType()} and not merely as a header, because that is what the
+	 * boundary reads. Stubbing only the header leaves it {@code null}, and every write then fails the content-type
+	 * check for the wrong reason -- which would make the refusals below pass vacuously.
+	 */
+	private static jakarta.servlet.http.HttpServletRequest req(String method, String contentType, Map<String,String> headers) {
+		var r = org.mockito.Mockito.mock(jakarta.servlet.http.HttpServletRequest.class);
+		org.mockito.Mockito.when(r.getMethod()).thenReturn(method);
+		org.mockito.Mockito.when(r.getContentType()).thenReturn(contentType);
+		headers.forEach((k, v) -> org.mockito.Mockito.when(r.getHeader(k)).thenReturn(v));
+		return r;
+	}
+
+	@Test
+	void theBoundaryRefusesTheFormEncodedPost() {
+		// The exact request from group a, as it would arrive from a hostile page: refused on content type, so it
+		// never reaches CredentialRest and the store is never opened.
+		var res = boundary().check(req("POST", "application/x-www-form-urlencoded", Map.of(
+			"Host", "127.0.0.1:8790",
+			"Origin", "http://evil.example")));
+		assertFalse(res.isAllowed());
+		assertEquals(403, res.status(), "foreign Origin is caught before the content type");
+	}
+
+	@Test
+	void theBoundaryRefusesTheFormEncodedPostEvenFromOurOwnOrigin() {
+		// With the origin corrected, the content-type check is what stops it. This is the assertion that the
+		// no-preflight form shape is unreachable, rather than merely that this particular attacker got the Origin
+		// wrong.
+		var res = boundary().check(req("POST", "application/x-www-form-urlencoded", Map.of(
+			"Host", "127.0.0.1:8790",
+			"Origin", "http://127.0.0.1:8790",
+			"Sec-Fetch-Site", "same-origin",
+			"X-Csrf-Token", "t0ken")));
+		assertFalse(res.isAllowed());
+		assertEquals(415, res.status());
+	}
+
+	@Test
+	void theBoundaryRefusesTheContentQueryParameterPost() {
+		// The content= variant carries no body and therefore no content type, which is itself a no-preflight
+		// shape. It has to be refused on that absence rather than exempted for it -- and note the server would
+		// otherwise rewrite the content type to UON itself, so there is nothing downstream to catch it either.
+		var res = boundary().check(req("POST", null, Map.of(
+			"Host", "127.0.0.1:8790",
+			"Origin", "http://127.0.0.1:8790",
+			"Sec-Fetch-Site", "same-origin",
+			"X-Csrf-Token", "t0ken")));
+		assertFalse(res.isAllowed());
+		assertEquals(415, res.status());
+	}
+
+	@Test
+	void theBoundaryAllowsTheLegitimateJsonPost() {
+		// The other direction, so this class cannot pass by refusing everything.
+		var res = boundary().check(req("POST", "application/json", Map.of(
+			"Host", "127.0.0.1:8790",
+			"Origin", "http://127.0.0.1:8790",
+			"Sec-Fetch-Site", "same-origin",
+			"X-Csrf-Token", "t0ken")));
+		assertTrue(res.isAllowed());
+	}
+}
diff --git a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
index 3a32235872..3b40717201 100644
--- a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
@@ -18,6 +18,7 @@
 package org.apache.juneau.releng.rest;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
 
 import java.util.List;
 import org.apache.juneau.commons.inject.StackOverlay;
@@ -27,6 +28,8 @@ import org.apache.juneau.releng.release.ReleaseListService;
 import org.apache.juneau.rest.mock.MockRestClient;
 import org.junit.jupiter.api.Test;
 
+import jakarta.servlet.http.HttpServletRequest;
+
 class ReleaseRestTest {
 
 	private ReleaseRest rest(List<Release> releases) {
@@ -50,17 +53,25 @@ class ReleaseRestTest {
 		return MockRestClient.builder(rest).overridingBeanStore(new StackOverlay()).build();
 	}
 
+	/**
+	 * A request with no loopback-boundary token attribute, which is what a direct call (no servlet filter in the
+	 * path) sees. {@code ConsolePage} renders the token empty in that case rather than failing.
+	 */
+	private static HttpServletRequest req() {
+		return mock(HttpServletRequest.class);
+	}
+
 	@Test
 	void detailReturnsAViewCarryingTheMatchingRelease() {
 		var rest = rest(List.of(release("9.2.1", "RELEASED")));
-		var view = rest.detail("9.2.1", "1");
+		var view = rest.detail("9.2.1", "1", req());
 		assertNotNull(view);
 	}
 
 	@Test
 	void detailForAnUnknownVersionIs404() {
 		var rest = rest(List.of(release("9.2.1", "RELEASED")));
-		var ex = assertThrows(NotFound.class, () -> rest.detail("9.9.9", "1"));
+		var ex = assertThrows(NotFound.class, () -> rest.detail("9.9.9", "1", req()));
 		assertEquals(404, ex.getStatusCode());
 	}