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 — 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 — 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 — 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 — 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 — 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());
}