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 <[email protected]>
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 @@ [email protected]
 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());
        }
 

Reply via email to