This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 88cabb91e8 Reject cross-site requests to the Hop Server servlets,
fixes #8371 (#8390)
88cabb91e8 is described below
commit 88cabb91e8079e8903e8f65d277c01ab8ec71a8a
Author: Bart Maertens <[email protected]>
AuthorDate: Wed Sep 16 10:40:16 2026 +0200
Reject cross-site requests to the Hop Server servlets, fixes #8371 (#8390)
The servlets answer state-changing operations on GET, so a page an operator
is
visiting could drive them with the operator's browser and credentials.
Origin is
no defence: browsers omit it on cross-site GET.
A CrossSitePolicy keyed on Sec-Fetch-Site is applied in both places the
servlets
are deployed: a Jetty handler wrapped around every context in WebServer
(ahead
of authentication, covering both the auth-enabled and auth-disabled
branches),
and a servlet filter mapped on /hop/* in Hop Web, which co-deploys the same
servlets outside WebServer. Cross-site top-level navigations are rejected
too;
the usual carve-out assumes a GET navigation is safe, which is what does not
hold here.
Requests carrying no Sec-Fetch-* headers at all (hop-run, the Hop GUI, curl,
automation) are always allowed, so existing integrations are unaffected. The
default same-site policy also allows another host of the same registrable
domain; same-origin is stricter and off disables the check. Configured with
--cross-site-policy / HOP_SERVER_CROSS_SITE_POLICY on hop-server, and in
Hop Web
via Configuration -> Security -> General, security-config.json, or the same
environment variable.
Constraining the ExecPipelineServlet VFS path is tracked separately in
#8373.
---
THREAT_MODEL.md | 23 ++
assemblies/web/src/main/resources/WEB-INF/web.xml | 17 +
core/src/main/java/org/apache/hop/core/Const.java | 13 +
.../apache/hop/core/security/CrossSitePolicy.java | 141 +++++++
.../hop/core/security/HopSecurityConfig.java | 69 ++++
.../security/CrossSitePolicyResolutionTest.java | 120 ++++++
.../hop/core/security/CrossSitePolicyTest.java | 122 ++++++
.../integration-tests-hop_server.yaml | 24 ++
docker/local-auth-config/web.xml | 17 +
.../modules/ROOT/pages/hop-gui/hop-web.adoc | 35 +-
.../modules/ROOT/pages/hop-server/index.adoc | 57 +++
.../modules/ROOT/pages/variables.adoc | 3 +
.../org/apache/hop/www/CrossSiteRequestFilter.java | 137 +++++++
.../apache/hop/www/CrossSiteRequestHandler.java | 116 ++++++
.../main/java/org/apache/hop/www/HopServer.java | 15 +
.../main/java/org/apache/hop/www/WebServer.java | 18 +-
.../hop/www/messages/messages_en_US.properties | 4 +
.../www/CrossSiteRequestFilterContainerTest.java | 180 +++++++++
.../apache/hop/www/CrossSiteRequestFilterTest.java | 160 ++++++++
.../hop/www/CrossSiteRequestHandlerTest.java | 188 +++++++++
.../main-0017-test-cross-site-requests.hwf | 418 +++++++++++++++++++++
.../main-0018-test-cross-site-policy-off.hwf | 196 ++++++++++
.../tabs/security/ConfigSecurityGeneralTab.java | 35 +-
.../tabs/security/SecurityConfigUi.java | 6 +
.../tabs/messages/messages_en_US.properties | 3 +
.../hop/web/it/HopServerApiCrossSiteTest.java | 192 ++++++++++
26 files changed, 2306 insertions(+), 3 deletions(-)
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index ab02d7e34f..5ae04a397a 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -151,6 +151,29 @@ where strict outbound TLS verification is required (see
§9).
Changing the credential and restricting exposure is an operator
responsibility
(§10). — violation symptom: remote code execution using the unchanged default
credential on an exposed deployment; severity: critical.
+- **Cross-site browser requests to the Hop Server are rejected.** The servlets
+ answer state-changing operations on `GET`, so a page the operator is visiting
+ could otherwise drive them with the operator's browser and credentials
+ (`Origin` is no defence: browsers omit it on cross-site `GET`). A single
+ handler wrapped around every servlet context, the static resources and the
+ JSON API
([`CrossSiteRequestHandler.java`](engine/src/main/java/org/apache/hop/www/CrossSiteRequestHandler.java),
installed in
[`WebServer.java`](engine/src/main/java/org/apache/hop/www/WebServer.java)
ahead of
+ authentication) rejects a request whose `Sec-Fetch-Site` the configured
+
[`CrossSitePolicy`](core/src/main/java/org/apache/hop/core/security/CrossSitePolicy.java)
disallows — by default `cross-site`, and under
+ `--cross-site-policy same-origin` also `same-site`. **The same check covers
Hop
+ Web**, which co-deploys these servlets on `/hop/*` outside `WebServer`, via a
+ servlet filter mapped ahead of the authentication filters
+
([`CrossSiteRequestFilter.java`](engine/src/main/java/org/apache/hop/www/CrossSiteRequestFilter.java),
[`web.xml`](assemblies/web/src/main/resources/WEB-INF/web.xml)); there it is
configured by
+ `crossSitePolicy` in `security-config.json` (Configuration → Security →
+ General) and matters more, because Hop Web's authenticated modes are
+ session-backed and a browser attaches the session cookie to a cross-site
+ request by itself. The Hop Web **UI** (`/ui`) is not covered. Requests
carrying no
+ `Sec-Fetch-*` headers (hop-run, the Hop GUI, curl, automation) are allowed,
so
+ non-browser clients are unaffected; `--cross-site-policy off` disables the
+ check entirely. This narrows drive-by use of an authenticated operator's
+ browser only — it is not per-endpoint authorization, and does nothing about a
+ client that reaches the server directly (§9). — violation symptom: a visited
+ web page starts or stops pipelines on a server the operator is logged in to;
+ severity: high.
- **XML parsing is hardened against XXE.** Pipeline/workflow/metadata files are
parsed through a secure `DocumentBuilderFactory`
([`XmlParserFactoryProducer.java`](core/src/main/java/org/apache/hop/core/xml/XmlParserFactoryProducer.java))
with external general/parameter entities and
diff --git a/assemblies/web/src/main/resources/WEB-INF/web.xml
b/assemblies/web/src/main/resources/WEB-INF/web.xml
index dd8977da7d..e3e539aede 100644
--- a/assemblies/web/src/main/resources/WEB-INF/web.xml
+++ b/assemblies/web/src/main/resources/WEB-INF/web.xml
@@ -31,6 +31,23 @@
<listener-class>org.apache.hop.ui.hopgui.HopWebServletContextListener</listener-class>
</listener>
+ <!-- Cross-site request rejection for the embedded Hop Server API (issue
#8371). Declared first
+ so a request started by another site is refused before the session or
credentials are
+ looked at. Browsers attach a session cookie to a cross-site request
by themselves, and the
+ /hop/* servlets answer state-changing operations on GET, so without
this a page the
+ operator happens to be visiting could run pipelines as them.
+ Policy comes from security-config.json (crossSitePolicy) or the
+ HOP_SERVER_CROSS_SITE_POLICY environment variable; add a
crossSitePolicy init-param here
+ to pin it for this deployment. -->
+ <filter>
+ <filter-name>CrossSiteRequest</filter-name>
+ <filter-class>org.apache.hop.www.CrossSiteRequestFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>CrossSiteRequest</filter-name>
+ <url-pattern>/hop/*</url-pattern>
+ </filter-mapping>
+
<!-- Hop-managed BASIC auth (active only when mode is BASIC) -->
<filter>
<filter-name>HopBasicAuth</filter-name>
diff --git a/core/src/main/java/org/apache/hop/core/Const.java
b/core/src/main/java/org/apache/hop/core/Const.java
index ad9c22fadb..33fe4cbf5e 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -923,6 +923,19 @@ public class Const {
"Defines the default encoding for servlets, leave it empty to use
Java default encoding")
public static final String HOP_DEFAULT_SERVLET_ENCODING =
"HOP_DEFAULT_SERVLET_ENCODING";
+ /** A variable to configure which browser requests the Hop server accepts */
+ // No `value` default on purpose: HopEnvironment copies every declared
default into a system
+ // property without consulting the environment, so declaring one here would
shadow both
+ // HOP_SERVER_CROSS_SITE_POLICY and the Hop Web security config. See
+ // HopSecurityConfig.resolveCrossSitePolicy.
+ @Variable(
+ description =
+ "Which browser requests the Hop server accepts, based on the
Sec-Fetch-Site header: "
+ + "'same-site' (the default) rejects cross-site requests,
'same-origin' also rejects "
+ + "same-site ones, and 'off' disables the check. Clients that
send no Sec-Fetch-* "
+ + "headers at all, such as hop-run or the Hop GUI, are never
affected.")
+ public static final String HOP_SERVER_CROSS_SITE_POLICY =
"HOP_SERVER_CROSS_SITE_POLICY";
+
/** A variable to configure refresh for Hop server workflow/pipeline status
page */
@Variable(
description = "A variable to configure refresh for Hop server
workflow/pipeline status page")
diff --git
a/core/src/main/java/org/apache/hop/core/security/CrossSitePolicy.java
b/core/src/main/java/org/apache/hop/core/security/CrossSitePolicy.java
new file mode 100644
index 0000000000..c786bf8419
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/security/CrossSitePolicy.java
@@ -0,0 +1,141 @@
+/*
+ * 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.hop.core.security;
+
+import java.util.List;
+import java.util.Locale;
+import lombok.Getter;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * Which browser requests the Hop server accepts, expressed in terms of the <a
+ *
href="https://developer.mozilla.org/docs/Web/HTTP/Headers/Sec-Fetch-Site">{@code
+ * Sec-Fetch-Site}</a> request header.
+ *
+ * <p>The Hop server servlets answer state-changing operations on {@code GET},
so a page the
+ * operator happens to be visiting can drive them simply by pointing an image,
a form or a link at
+ * the server. {@code Origin} is no help against that shape of request —
browsers omit it on
+ * cross-site {@code GET} — but {@code Sec-Fetch-Site} is always sent, and
non-browser clients
+ * (hop-run, the Hop GUI, curl, customer automation) send no {@code
Sec-Fetch-*} headers at all.
+ * Treating an absent header as "allow" therefore leaves existing integrations
untouched while
+ * closing the browser-driven path.
+ *
+ * <p>Unrecognised header values are rejected rather than allowed: a client
that cannot send the
+ * header correctly can always send nothing at all.
+ */
+public enum CrossSitePolicy {
+ /**
+ * Reject {@code cross-site} requests only. A request from another host
under the same registrable
+ * domain — a second Hop server, an intranet portal, the same host on
another port — is still
+ * accepted. This is the default.
+ */
+ SAME_SITE("same-site"),
+
+ /**
+ * Reject {@code same-site} requests as well, so only the server's own pages
may drive it. Use
+ * this when nothing on a neighbouring host of the same domain is trusted to
link to the server.
+ */
+ SAME_ORIGIN("same-origin"),
+
+ /**
+ * Do not inspect {@code Sec-Fetch-Site} at all. An escape hatch for a
deployment that turns out
+ * to need cross-site browser access; it leaves the server open to the
requests described above.
+ */
+ OFF("off");
+
+ /**
+ * System property and environment variable that selects the policy, shared
by hop-server and the
+ * Hop Server API embedded in Hop Web so operators have one knob to learn.
+ */
+ public static final String CONFIG_KEY = Const.HOP_SERVER_CROSS_SITE_POLICY;
+
+ /** Value of the {@code Sec-Fetch-Site} header for a request with no
initiating site. */
+ private static final String SITE_NONE = "none";
+
+ /** Value of the {@code Sec-Fetch-Site} header for a request from the
server's own origin. */
+ private static final String SITE_SAME_ORIGIN = "same-origin";
+
+ /** Value of the {@code Sec-Fetch-Site} header for a request from the same
registrable domain. */
+ private static final String SITE_SAME_SITE = "same-site";
+
+ /** How the policy is written on the command line, in the environment and in
the log. */
+ @Getter private final String code;
+
+ CrossSitePolicy(String code) {
+ this.code = code;
+ }
+
+ /**
+ * Decide whether a request carrying these {@code Sec-Fetch-Site} values is
accepted.
+ *
+ * @param secFetchSiteValues every {@code Sec-Fetch-Site} value on the
request, in order. Empty or
+ * null for a client that did not send the header.
+ * @return true if the request may proceed
+ */
+ public boolean allows(List<String> secFetchSiteValues) {
+ if (this == OFF) {
+ return true;
+ }
+ if (secFetchSiteValues == null) {
+ return true;
+ }
+ List<String> values =
+ secFetchSiteValues.stream().filter(value -> value != null &&
!value.isBlank()).toList();
+ if (values.isEmpty()) {
+ // A non-browser client: hop-run, the Hop GUI, curl, automation.
+ return true;
+ }
+ if (values.size() > 1) {
+ // Contradictory headers, most likely added along the way. There is no
safe reading.
+ return false;
+ }
+ String value = values.get(0).trim().toLowerCase(Locale.ROOT);
+ return switch (value) {
+ case SITE_NONE, SITE_SAME_ORIGIN -> true;
+ case SITE_SAME_SITE -> this == SAME_SITE;
+ // "cross-site", and anything this version does not recognise.
+ default -> false;
+ };
+ }
+
+ /**
+ * Read a policy from its {@code code}, as written on the command line or in
the environment.
+ *
+ * @param value the code to read, case-insensitive. Null or blank selects
{@link #SAME_SITE}.
+ * @return the matching policy
+ * @throws HopException if the value is not a policy code
+ */
+ public static CrossSitePolicy parse(String value) throws HopException {
+ if (value == null || value.isBlank()) {
+ return SAME_SITE;
+ }
+ String wanted = value.trim().toLowerCase(Locale.ROOT);
+ for (CrossSitePolicy policy : values()) {
+ if (policy.code.equals(wanted)) {
+ return policy;
+ }
+ }
+ throw new HopException(
+ "Unknown cross-site policy '"
+ + value
+ + "'. Valid values are: "
+ + String.join(", ", SAME_SITE.code, SAME_ORIGIN.code, OFF.code)
+ + ".");
+ }
+}
diff --git
a/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
b/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
index 6924c7bbfd..2b25046680 100644
--- a/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
+++ b/core/src/main/java/org/apache/hop/core/security/HopSecurityConfig.java
@@ -25,9 +25,13 @@ import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
import lombok.Getter;
import lombok.Setter;
import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.json.HopJson;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.vfs.HopVfs;
@@ -87,6 +91,19 @@ public class HopSecurityConfig {
*/
private boolean allowUnauthenticatedServerApi = false;
+ /**
+ * Which browser requests the embedded Hop Server API ({@code /hop/*})
accepts, as a {@link
+ * CrossSitePolicy} code.
+ *
+ * <p>The Hop Server servlets answer state-changing operations on {@code
GET}, so without this a
+ * page the operator happens to be visiting can drive them using the
operator's own session. The
+ * default {@code same-site} rejects requests started by another site;
{@code same-origin} also
+ * rejects other hosts of the same domain; {@code off} disables the check.
Overridden by the
+ * {@code HOP_SERVER_CROSS_SITE_POLICY} system property or environment
variable, the same knob
+ * hop-server uses.
+ */
+ private String crossSitePolicy = CrossSitePolicy.SAME_SITE.getCode();
+
/**
* Optional explicit mapping from container role name → Hop role id ({@code
admin}, {@code user},
* {@code operator}, {@code readonly}). When empty, built-in aliases in
{@link HopRole} are used.
@@ -145,6 +162,9 @@ public class HopSecurityConfig {
private static volatile HopSecurityConfig cached;
+ /** Last cross-site policy value warned about, so a bad one is not logged
per request. */
+ private static volatile String warnedCrossSitePolicy;
+
/** Effective client secret: env {@code HOP_WEB_OAUTH_CLIENT_SECRET}
overrides config file. */
public String resolveOauthClientSecret() {
String env = System.getenv("HOP_WEB_OAUTH_CLIENT_SECRET");
@@ -158,6 +178,55 @@ public class HopSecurityConfig {
return oauthClientSecret == null ? "" : oauthClientSecret;
}
+ /**
+ * Effective cross-site policy, resolved as {@code -D} → environment →
config file → {@link
+ * CrossSitePolicy#SAME_SITE}, so a container can set it without editing
{@code
+ * security-config.json}.
+ *
+ * <p>A <em>blank</em> system property is treated as absent on purpose.
{@code
+ * HopEnvironment.init()} copies every {@code @Variable}-declared setting
into a system property
+ * and does not look at the environment, so the property is present but
empty here unless an
+ * operator really passed {@code -D}. Honouring a blank one would shadow
both the environment
+ * variable and the config file, which is exactly what it did before this
was fixed.
+ *
+ * <p>An unreadable value falls back to the safe default rather than leaving
the check off.
+ *
+ * @return the policy to apply, never null
+ */
+ public CrossSitePolicy resolveCrossSitePolicy() {
+ String prop = System.getProperty(CrossSitePolicy.CONFIG_KEY);
+ String env = System.getenv(CrossSitePolicy.CONFIG_KEY);
+ String value = prop != null && !prop.isBlank() ? prop : env;
+ if (value == null || value.isBlank()) {
+ value = crossSitePolicy;
+ }
+ try {
+ return CrossSitePolicy.parse(value);
+ } catch (HopException e) {
+ warnUnusableCrossSitePolicy(value);
+ return CrossSitePolicy.SAME_SITE;
+ }
+ }
+
+ /**
+ * Complain about an unusable policy value once per distinct value.
+ *
+ * <p>Deliberately not {@code LogChannel}: this runs on every request
through the Hop Web
+ * cross-site filter, where the Hop log store is not guaranteed to be
initialised and a throwing
+ * logger would turn a configuration typo into a failed request. The
de-duplication keeps a bad
+ * value from filling the log one line per request.
+ */
+ private static void warnUnusableCrossSitePolicy(String value) {
+ if (!Objects.equals(value, warnedCrossSitePolicy)) {
+ warnedCrossSitePolicy = value;
+ Logger.getLogger(HopSecurityConfig.class.getName())
+ .log(
+ Level.WARNING,
+ "Unusable cross-site policy ''{0}'', falling back to ''{1}''",
+ new Object[] {value, CrossSitePolicy.SAME_SITE.getCode()});
+ }
+ }
+
public boolean isOauthConfigured() {
return oauthIssuerUrl != null
&& !oauthIssuerUrl.isBlank()
diff --git
a/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyResolutionTest.java
b/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyResolutionTest.java
new file mode 100644
index 0000000000..55dfc35202
--- /dev/null
+++
b/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyResolutionTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.hop.core.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * How Hop Web decides its cross-site policy. The promise to operators is that
{@code
+ * HOP_SERVER_CROSS_SITE_POLICY} is the same knob here as on the standalone
hop-server, so it is
+ * worth pinning down.
+ */
+class CrossSitePolicyResolutionTest {
+
+ @AfterEach
+ void clearOverride() {
+ System.clearProperty(CrossSitePolicy.CONFIG_KEY);
+ }
+
+ @Test
+ void defaultsToSameSiteWhenNothingIsConfigured() {
+ assertEquals(CrossSitePolicy.SAME_SITE, new
HopSecurityConfig().resolveCrossSitePolicy());
+ }
+
+ @Test
+ void configFileValueIsUsed() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.SAME_ORIGIN.getCode());
+
+ assertEquals(CrossSitePolicy.SAME_ORIGIN, config.resolveCrossSitePolicy());
+ }
+
+ /** The escape hatch: an operator must be able to turn the check off without
editing the JSON. */
+ @Test
+ void overrideWinsOverTheConfigFile() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.SAME_ORIGIN.getCode());
+ System.setProperty(CrossSitePolicy.CONFIG_KEY,
CrossSitePolicy.OFF.getCode());
+
+ assertEquals(CrossSitePolicy.OFF, config.resolveCrossSitePolicy());
+ }
+
+ @Test
+ void blankOverrideLeavesTheConfigFileInCharge() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.OFF.getCode());
+ System.setProperty(CrossSitePolicy.CONFIG_KEY, " ");
+
+ assertEquals(CrossSitePolicy.OFF, config.resolveCrossSitePolicy());
+ }
+
+ /** A typo must not silently leave the check off; fall back to the safe
default instead. */
+ /**
+ * Regression for the Hop Web failure this was first shipped with: {@code
HopEnvironment.init()}
+ * copies every {@code @Variable} setting into a system property without
consulting the
+ * environment, so the property is present but blank. Honouring it shadowed
the config file (and
+ * the environment variable) and silently pinned every deployment to the
default.
+ */
+ @Test
+ void blankInjectedPropertyDoesNotShadowTheConfigFile() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.OFF.getCode());
+ System.setProperty(CrossSitePolicy.CONFIG_KEY, "");
+
+ assertEquals(CrossSitePolicy.OFF, config.resolveCrossSitePolicy());
+ }
+
+ /** An operator who really passes -D still wins; HopEnvironment leaves such
a value alone. */
+ @Test
+ void explicitSystemPropertyStillWins() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.SAME_SITE.getCode());
+ System.setProperty(CrossSitePolicy.CONFIG_KEY,
CrossSitePolicy.OFF.getCode());
+
+ assertEquals(CrossSitePolicy.OFF, config.resolveCrossSitePolicy());
+ }
+
+ @Test
+ void unusableValueFallsBackToTheSafeDefault() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy("disabled-please");
+
+ assertEquals(CrossSitePolicy.SAME_SITE, config.resolveCrossSitePolicy());
+ }
+
+ @Test
+ void unusableOverrideFallsBackToTheSafeDefault() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(CrossSitePolicy.OFF.getCode());
+ System.setProperty(CrossSitePolicy.CONFIG_KEY, "nonsense");
+
+ assertEquals(CrossSitePolicy.SAME_SITE, config.resolveCrossSitePolicy());
+ }
+
+ /** An older security-config.json has no such field; it must read as the
default, not as null. */
+ @Test
+ void missingFieldReadsAsTheDefault() {
+ HopSecurityConfig config = new HopSecurityConfig();
+ config.setCrossSitePolicy(null);
+
+ assertEquals(CrossSitePolicy.SAME_SITE, config.resolveCrossSitePolicy());
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyTest.java
b/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyTest.java
new file mode 100644
index 0000000000..fcd96cd216
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/security/CrossSitePolicyTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.hop.core.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Tests the decision {@link CrossSitePolicy} makes about a {@code
Sec-Fetch-Site} value. */
+class CrossSitePolicyTest {
+
+ /**
+ * hop-run, the Hop GUI, curl and customer automation send no Sec-Fetch-*
headers. Every policy
+ * has to let them through or the change breaks every existing integration.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"same-site", "same-origin", "off"})
+ void clientWithoutTheHeaderIsAlwaysAllowed(String code) throws Exception {
+ CrossSitePolicy policy = CrossSitePolicy.parse(code);
+
+ assertTrue(policy.allows(null));
+ assertTrue(policy.allows(List.of()));
+ // A header that arrived with no value at all reads the same as one that
never arrived.
+ assertTrue(policy.allows(List.of("")));
+ assertTrue(policy.allows(List.of(" ")));
+ }
+
+ @Test
+ void defaultPolicyRejectsOnlyCrossSite() {
+ CrossSitePolicy policy = CrossSitePolicy.SAME_SITE;
+
+ assertTrue(policy.allows(List.of("none")));
+ assertTrue(policy.allows(List.of("same-origin")));
+ assertTrue(policy.allows(List.of("same-site")));
+ assertFalse(policy.allows(List.of("cross-site")));
+ }
+
+ @Test
+ void sameOriginPolicyRejectsSameSiteAsWell() {
+ CrossSitePolicy policy = CrossSitePolicy.SAME_ORIGIN;
+
+ assertTrue(policy.allows(List.of("none")));
+ assertTrue(policy.allows(List.of("same-origin")));
+ assertFalse(policy.allows(List.of("same-site")));
+ assertFalse(policy.allows(List.of("cross-site")));
+ }
+
+ @Test
+ void offPolicyAllowsEverything() {
+ CrossSitePolicy policy = CrossSitePolicy.OFF;
+
+ assertTrue(policy.allows(List.of("cross-site")));
+ assertTrue(policy.allows(List.of("same-site")));
+ assertTrue(policy.allows(List.of("anything at all")));
+ assertTrue(policy.allows(List.of("same-origin", "cross-site")));
+ }
+
+ @Test
+ void headerValueIsReadCaseInsensitivelyAndTrimmed() {
+ assertTrue(CrossSitePolicy.SAME_SITE.allows(List.of(" Same-Origin ")));
+ assertFalse(CrossSitePolicy.SAME_SITE.allows(List.of(" Cross-Site ")));
+ }
+
+ /** A client that cannot send the header correctly can always send nothing
at all. */
+ @Test
+ void unrecognisedHeaderValueIsRejected() {
+ assertFalse(CrossSitePolicy.SAME_SITE.allows(List.of("nonsense")));
+ assertFalse(CrossSitePolicy.SAME_SITE.allows(List.of("same-origin,
cross-site")));
+ }
+
+ /** Contradictory headers, most likely added on the way in. There is no safe
reading. */
+ @Test
+ void severalHeaderValuesAreRejected() {
+ assertFalse(CrossSitePolicy.SAME_SITE.allows(List.of("same-origin",
"cross-site")));
+ assertFalse(CrossSitePolicy.SAME_SITE.allows(List.of("same-origin",
"same-origin")));
+ // Blank values do not count towards the ambiguity.
+ assertTrue(CrossSitePolicy.SAME_SITE.allows(List.of("same-origin", "")));
+ }
+
+ @Test
+ void policyIsParsedFromItsCode() throws Exception {
+ assertEquals(CrossSitePolicy.SAME_SITE,
CrossSitePolicy.parse("same-site"));
+ assertEquals(CrossSitePolicy.SAME_ORIGIN,
CrossSitePolicy.parse("same-origin"));
+ assertEquals(CrossSitePolicy.OFF, CrossSitePolicy.parse("off"));
+ assertEquals(CrossSitePolicy.SAME_ORIGIN, CrossSitePolicy.parse("
SAME-Origin "));
+ }
+
+ @Test
+ void missingPolicyFallsBackToSameSite() throws Exception {
+ assertEquals(CrossSitePolicy.SAME_SITE, CrossSitePolicy.parse(null));
+ assertEquals(CrossSitePolicy.SAME_SITE, CrossSitePolicy.parse(""));
+ assertEquals(CrossSitePolicy.SAME_SITE, CrossSitePolicy.parse(" "));
+ }
+
+ @Test
+ void unknownPolicyIsRefused() {
+ HopException e = assertThrows(HopException.class, () ->
CrossSitePolicy.parse("strict"));
+ assertTrue(e.getMessage().contains("same-origin"), e.getMessage());
+ }
+}
diff --git a/docker/integration-tests/integration-tests-hop_server.yaml
b/docker/integration-tests/integration-tests-hop_server.yaml
index f887d0fd89..aa9a65003a 100644
--- a/docker/integration-tests/integration-tests-hop_server.yaml
+++ b/docker/integration-tests/integration-tests-hop_server.yaml
@@ -23,10 +23,13 @@ services:
depends_on:
hop_server:
condition: service_healthy
+ hop_server_no_cross_site_check:
+ condition: service_healthy
minio:
condition: service_healthy
links:
- hop_server
+ - hop_server_no_cross_site_check
- minio
hop_server:
@@ -68,6 +71,27 @@ services:
retries: 6
start_period: 120s
+ # A second server with the cross-site check turned off (main-0018). The
escape hatch for a
+ # deployment that needs cross-site browser access is only worth anything if
the setting actually
+ # reaches the server process, which is what this container proves. No
project or metadata folder:
+ # the test only calls the status and stopPipeline endpoints.
+ hop_server_no_cross_site_check:
+ image: apache/hop:Development
+ pull_policy: never
+ hostname: hop-server-nocheck
+ ports:
+ - "8182"
+ environment:
+ - HOP_SERVER_PORT=8182
+ - HOP_SERVER_HOSTNAME=hop-server-nocheck
+ - HOP_SERVER_CROSS_SITE_POLICY=off
+ healthcheck:
+ test: curl -u cluster:cluster --fail http://hop-server-nocheck:8182 ||
exit 1
+ interval: 20s
+ timeout: 10s
+ retries: 6
+ start_period: 120s
+
# Storage behind the "export-only-storage" VFS connection of main-0014. That
connection lives in
# the client project's metadata-client-only folder, so it only ever reaches
the server inside the
# export ZIP - which is what the test is about.
diff --git a/docker/local-auth-config/web.xml b/docker/local-auth-config/web.xml
index 3f113a4417..de9ae283aa 100644
--- a/docker/local-auth-config/web.xml
+++ b/docker/local-auth-config/web.xml
@@ -35,6 +35,23 @@
<listener-class>org.apache.hop.ui.hopgui.HopWebServletContextListener</listener-class>
</listener>
+ <!-- Cross-site request rejection for the embedded Hop Server API (issue
#8371). Declared first
+ so a request started by another site is refused before the session or
credentials are
+ looked at. Browsers attach a session cookie to a cross-site request
by themselves, and the
+ /hop/* servlets answer state-changing operations on GET, so without
this a page the
+ operator happens to be visiting could run pipelines as them.
+ Policy comes from security-config.json (crossSitePolicy) or the
+ HOP_SERVER_CROSS_SITE_POLICY environment variable; add a
crossSitePolicy init-param here
+ to pin it for this deployment. -->
+ <filter>
+ <filter-name>CrossSiteRequest</filter-name>
+ <filter-class>org.apache.hop.www.CrossSiteRequestFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>CrossSiteRequest</filter-name>
+ <url-pattern>/hop/*</url-pattern>
+ </filter-mapping>
+
<!-- No-op unless security mode is BASIC (Hop-managed). Safe alongside
Tomcat EXTERNAL auth. -->
<filter>
<filter-name>HopBasicAuth</filter-name>
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
index 9ff02f1210..280eff2360 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
@@ -400,7 +400,7 @@ The *Security* tab appears only in **Hop Web**, and only
for administrators (rol
It is not shown in the desktop Hop GUI.
The page is split into tabs:
-* *General* — authentication mode (`NONE` / `EXTERNAL` / `BASIC` / `OAUTH2`)
and login welcome message
+* *General* — authentication mode (`NONE` / `EXTERNAL` / `BASIC` / `OAUTH2`),
login welcome message, and the <<Cross-site requests to the Hop Server
API,cross-site policy>> for `/hop/*`
* *OAuth* — OIDC issuer, client id/secret, redirect, scopes, role/username
claims, PKCE
* *External* — container / IdP / LDAP group → Hop role mappings (also used for
OAuth claim groups)
* *Basic* — Hop-managed users (`users.json`: username, role, enabled, password)
@@ -510,6 +510,39 @@ After sign-in, your session should include the *Admin*
role (`security.manage`)
Other users without a mapping fall back to the *User* role.
====
+=== Cross-site requests to the Hop Server API
+
+Hop Web co-deploys the Hop Server API on `/hop/*`.
+Those servlets answer state-changing operations on `GET` — a single URL starts
a pipeline, stops a workflow or removes one — so without a check, a web page an
operator happens to be visiting can drive them in the operator's browser, using
the session cookie the browser attaches by itself.
+
+Hop Web therefore rejects requests whose
https://developer.mozilla.org/docs/Web/HTTP/Headers/Sec-Fetch-Site[`Sec-Fetch-Site`]
header says they were started by another site, answering `403 Forbidden`
before authentication and before the RBAC check run.
+This is the same policy the standalone xref:hop-server/index.adoc[Hop Server]
applies, and it is set the same way:
+
+[options="header"]
+|===
+
+|Value|Behaviour
+
+|`same-site`
+|The default.
+Rejects requests started by a site on a different registrable domain.
+
+|`same-origin`
+|Also rejects requests started by another host of the same domain, so only Hop
Web's own pages may drive the API.
+
+|`off`
+|Does not look at `Sec-Fetch-Site` at all.
+
+|===
+
+Set it in *Configuration → Security → General*, as `crossSitePolicy` in
`security-config.json`, or with the `HOP_SERVER_CROSS_SITE_POLICY` environment
variable (which overrides the config file).
+A `crossSitePolicy` init-param on the `CrossSiteRequest` filter in `web.xml`
pins it for a single deployment and wins over both.
+
+Only browsers send `Sec-Fetch-*` headers, and requests without them are always
accepted, so hop-run, the Hop GUI, `curl` and your own automation calling
`/hop/*` are unaffected.
+Following a link to `/hop/*` **from another site** does land on a `403`; Hop
Web's own UI is unaffected because its calls are same-origin.
+
+NOTE: This guards the Hop Server API only. The Hop Web UI itself (`/ui`) is
not covered by this filter.
+
=== JDBC and API Bearer tokens
JDBC drivers and `curl` cannot complete an OIDC browser login. After you sign
in to Hop Web (BASIC or OAUTH2), use *File → Copy JDBC token* or `GET
/hop/jdbcToken` (`file.view`) to mint a short-lived HMAC JWT (`aud=hop-jdbc`).
Send it as `Authorization: Bearer <token>`.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
index 87ae417406..56bb8ecf59 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-server/index.adoc
@@ -77,6 +77,13 @@ Run a Hop server
-ws, --workflow-status=<workflowName>
List the status of the workflow with this name (also
specify the -id option)
+ -xs, --cross-site-policy=<crossSitePolicy>
+ Which browser requests this server accepts, based on
+ the Sec-Fetch-Site header: 'same-site' (the
+ default) rejects cross-site requests, 'same-origin'
+ also rejects same-site ones, and 'off' disables the
+ check. Clients that send no Sec-Fetch-* headers,
+ such as hop-run or the Hop GUI, are never affected.
----
The available Hop Server options are:
@@ -147,6 +154,11 @@ This is provided for by the 'projects' plugin.
|Default: 0 +
The maximum number of seconds to wait for running pipelines and workflows to
finish when the server shuts down (0 shuts down immediately). Can also be set
with the `HOP_SERVER_SHUTDOWN_TIMEOUT` environment variable.
+|-xs
+|--cross-site-policy
+|Default: same-site +
+Which browser requests the server accepts: `same-site`, `same-origin` or
`off`. Can also be set with the `HOP_SERVER_CROSS_SITE_POLICY` environment
variable. See <<Cross-site requests>>.
+
|===
=====
@@ -551,6 +563,51 @@ Linux, macOS::
--
====
+== Cross-site requests
+
+The Hop Server servlets answer state-changing operations on `GET`: a single
URL starts a pipeline, stops a workflow or removes one from the server.
+That is convenient for automation, but it also means a web page an operator
happens to be visiting can drive the server simply by pointing an image, a form
or a link at it, using the operator's browser and their credentials.
+
+To close that path, Hop Server rejects requests whose
https://developer.mozilla.org/docs/Web/HTTP/Headers/Sec-Fetch-Site[`Sec-Fetch-Site`]
header says they were started by another site, answering them with an HTTP
`403 Forbidden` before authentication or any servlet runs.
+
+Only browsers send `Sec-Fetch-*` headers.
+hop-run, the Hop GUI, `curl` and your own automation send none at all, and
requests without the header are always accepted, so **existing integrations are
not affected**.
+
+The `--cross-site-policy` option (or the `HOP_SERVER_CROSS_SITE_POLICY`
environment variable) picks how strict the server is:
+
+[options="header"]
+|===
+
+|Value|Behaviour
+
+|`same-site`
+|The default.
+Rejects requests started by a site on a different registrable domain.
+A link or a form on another host of the same domain — a second Hop server, an
intranet portal, the same host on another port — is still accepted.
+
+|`same-origin`
+|Also rejects requests started by another host of the same domain, so only the
server's own pages may drive it.
+
+|`off`
+|Does not look at `Sec-Fetch-Site` at all.
+An escape hatch for a deployment that turns out to need cross-site browser
access; it leaves the server open to the requests described above.
+
+|===
+
+[source,shell]
+ ./hop-server.sh 127.0.0.1 8080 --cross-site-policy same-origin
+
+Two consequences worth knowing about:
+
+* Following a link to the server **from another site** lands on a `403`,
including a plain click-through to the status page.
+Typing the address or using a bookmark sends `Sec-Fetch-Site: none` and keeps
working, as does every link on the server's own pages.
+* A `Sec-Fetch-Site` value the server does not recognise, or more than one of
them on the same request, is rejected as well.
+A client that cannot send the header correctly can always send nothing at all.
+
+This is a defence against one specific attack, not a substitute for the
basics: keep authentication enabled, change the default `cluster`/`cluster`
credentials and do not expose the server to an untrusted network.
+
+Hop Web applies the same policy to the Hop Server API it co-deploys on
`/hop/*`, configured in its own security settings — see
xref:hop-gui/hop-web.adoc[Hop Web].
+
== Verify startup
Starting a Hop Server on the local machine e.g. on port 8081 will only take 1
or 2 seconds.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index 93f27dce03..e8ac3c38ef 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -290,6 +290,9 @@ This only makes a difference for extremely short lived
pipelines.
|HOP_ROWSET_PUT_TIMEOUT|50|The name of the variable that optionally contains
an alternative rowset put timeout (in ms).
This only makes a difference for extremely short lived pipelines.
|HOP_S3_VFS_PART_SIZE|5MB|The default part size for multi-part uploads of new
files to S3 (added and used by by the xref:vfs/aws-s3-vfs.adoc[AWS S3] VFS
plugin)
+|HOP_SERVER_CROSS_SITE_POLICY|same-site|Which browser requests Hop Server
accepts, based on the Sec-Fetch-Site header: `same-site` (the default) rejects
cross-site requests, `same-origin` also rejects same-site ones, and `off`
disables the check.
+Clients that send no Sec-Fetch-* headers at all, such as hop-run or the Hop
GUI, are never affected.
+See xref:hop-server/index.adoc[Hop Server].
|HOP_SERVER_DETECTION_TIMER|-|The name of the variable that defines the timer
used for detecting server nodes
|HOP_SERVER_JETTY_ACCEPTORS||A variable to configure jetty option: acceptors
for Hop Server
|HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE||A variable to configure jetty option:
acceptQueueSize for Hop Server
diff --git
a/engine/src/main/java/org/apache/hop/www/CrossSiteRequestFilter.java
b/engine/src/main/java/org/apache/hop/www/CrossSiteRequestFilter.java
new file mode 100644
index 0000000000..e60d90db47
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/www/CrossSiteRequestFilter.java
@@ -0,0 +1,137 @@
+/*
+ * 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.hop.www;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.security.CrossSitePolicy;
+import org.apache.hop.core.security.HopSecurityConfig;
+
+/**
+ * Rejects browser requests to the Hop Server API that a {@link
CrossSitePolicy} does not allow.
+ *
+ * <p>This is the servlet-container counterpart of {@link
CrossSiteRequestHandler}, which does the
+ * same job inside the standalone hop-server. It exists because {@link
HopServerServlet} is also
+ * deployed on {@code /hop/*} inside Hop Web, where there is no {@link
WebServer} to wrap — the same
+ * state-changing {@code GET} endpoints, reached through a plain {@code
web.xml} filter chain.
+ *
+ * <p>Map it ahead of the authentication filters so a cross-site request is
refused without the
+ * session or credentials being consulted at all. In Hop Web that matters more
than in hop-server:
+ * the authenticated modes are session-backed, and a browser attaches a
session cookie to a
+ * cross-site request by itself.
+ *
+ * <p>The policy comes from the {@code crossSitePolicy} init parameter when
present, otherwise from
+ * {@link HopSecurityConfig#resolveCrossSitePolicy()} — which in turn honours
the {@code
+ * HOP_SERVER_CROSS_SITE_POLICY} system property or environment variable.
+ */
+public class CrossSiteRequestFilter implements Filter {
+
+ private static final Logger LOG =
Logger.getLogger(CrossSiteRequestFilter.class.getName());
+
+ /** Init parameter that pins the policy for this deployment, overriding the
security config. */
+ public static final String INIT_PARAM_POLICY = "crossSitePolicy";
+
+ private static final String MESSAGE = "This Hop server rejects cross-site
requests.";
+
+ /** Set only when the deployment pinned a policy; otherwise the security
config decides. */
+ private CrossSitePolicy configuredPolicy;
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ String value = filterConfig == null ? null :
filterConfig.getInitParameter(INIT_PARAM_POLICY);
+ if (value != null && !value.isBlank()) {
+ try {
+ configuredPolicy = CrossSitePolicy.parse(value);
+ } catch (HopException e) {
+ // Refuse to start rather than run with a security control the
operator believes is set.
+ throw new ServletException(e.getMessage(), e);
+ }
+ }
+ LOG.log(
+ Level.INFO,
+ "CrossSiteRequestFilter initialized (cross-site policy: {0})",
+ configuredPolicy == null ? "from security config" :
configuredPolicy.getCode());
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, ServletException {
+
+ if (!(request instanceof HttpServletRequest httpRequest)
+ || !(response instanceof HttpServletResponse httpResponse)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ CrossSitePolicy policy = policy();
+ List<String> values = secFetchSiteValues(httpRequest);
+
+ if (policy.allows(values)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ LOG.log(
+ Level.FINE,
+ "Rejected cross-site request {0} {1} (Sec-Fetch-Site: {2}), cross-site
policy is [{3}]",
+ new Object[] {
+ httpRequest.getMethod(),
+ httpRequest.getRequestURI(),
+ CrossSiteRequestHandler.forLog(values),
+ policy.getCode()
+ });
+
+ if (httpResponse.isCommitted()) {
+ return;
+ }
+ httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ httpResponse.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ httpResponse.setContentType("text/plain; charset=UTF-8");
+ httpResponse.setHeader("Cache-Control", "no-store");
+ httpResponse.getWriter().write(MESSAGE);
+ }
+
+ private CrossSitePolicy policy() {
+ if (configuredPolicy != null) {
+ return configuredPolicy;
+ }
+ return HopSecurityConfig.load().resolveCrossSitePolicy();
+ }
+
+ /** Every Sec-Fetch-Site value on the request, in order, so duplicates can
be spotted. */
+ private static List<String> secFetchSiteValues(HttpServletRequest request) {
+ var headers = request.getHeaders(CrossSiteRequestHandler.SEC_FETCH_SITE);
+ if (headers == null) {
+ return List.of();
+ }
+ return Collections.list(headers);
+ }
+}
diff --git
a/engine/src/main/java/org/apache/hop/www/CrossSiteRequestHandler.java
b/engine/src/main/java/org/apache/hop/www/CrossSiteRequestHandler.java
new file mode 100644
index 0000000000..b34e29764b
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/www/CrossSiteRequestHandler.java
@@ -0,0 +1,116 @@
+/*
+ * 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.hop.www;
+
+import java.util.List;
+import lombok.Getter;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.security.CrossSitePolicy;
+import org.apache.hop.i18n.BaseMessages;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.util.Callback;
+
+/**
+ * Rejects browser requests to the Hop server that a {@link CrossSitePolicy}
does not allow, before
+ * they reach authentication or any servlet.
+ *
+ * <p>Installed once, around the handler that carries every servlet context,
the static resources
+ * and the JSON API, so no endpoint can be added later that forgets to check.
See {@link
+ * CrossSitePolicy} for why {@code Sec-Fetch-Site} is the header this keys on.
+ *
+ * <p>A cross-site top-level navigation is rejected along with everything
else: the usual carve-out
+ * for one assumes a {@code GET} navigation is safe, which is exactly what
does not hold here. An
+ * operator following a link to the server from another site therefore lands
on a 403; typing the
+ * address or using a bookmark sends {@code Sec-Fetch-Site: none} and still
works.
+ */
+public class CrossSiteRequestHandler extends Handler.Wrapper {
+
+ private static final Class<?> PKG = CrossSiteRequestHandler.class;
+
+ /** The request header the policy is keyed on. */
+ public static final String SEC_FETCH_SITE = "Sec-Fetch-Site";
+
+ /** Longest header value echoed into the log, to keep a hostile client from
flooding it. */
+ private static final int MAX_LOGGED_VALUE_LENGTH = 50;
+
+ @Getter private final CrossSitePolicy policy;
+
+ private final ILogChannel log;
+
+ public CrossSiteRequestHandler(Handler handler, CrossSitePolicy policy,
ILogChannel log) {
+ super(handler);
+ this.policy = policy;
+ this.log = log;
+ }
+
+ @Override
+ public boolean handle(Request request, Response response, Callback callback)
throws Exception {
+ List<String> values =
+
request.getHeaders().getFields(SEC_FETCH_SITE).stream().map(HttpField::getValue).toList();
+
+ if (policy.allows(values)) {
+ return super.handle(request, response, callback);
+ }
+
+ if (log != null && log.isDetailed()) {
+ log.logDetailed(
+ BaseMessages.getString(
+ PKG,
+ "CrossSiteRequestHandler.Log.Rejected",
+ request.getMethod(),
+ request.getHttpURI().getPath(),
+ forLog(values),
+ policy.getCode()));
+ }
+
+ Response.writeError(
+ request,
+ response,
+ callback,
+ HttpStatus.FORBIDDEN_403,
+ BaseMessages.getString(PKG, "CrossSiteRequestHandler.Error.Rejected"));
+ return true;
+ }
+
+ /**
+ * Render the rejected header values for a log line. Shared with {@link
CrossSiteRequestFilter}.
+ * The values come from an untrusted client, so they are truncated and
stripped of anything that
+ * could forge a log entry.
+ */
+ static String forLog(List<String> values) {
+ if (values.isEmpty()) {
+ return "<absent>";
+ }
+ StringBuilder text = new StringBuilder();
+ for (String value : values) {
+ if (!text.isEmpty()) {
+ text.append(", ");
+ }
+ String trimmed =
+ value.length() > MAX_LOGGED_VALUE_LENGTH
+ ? value.substring(0, MAX_LOGGED_VALUE_LENGTH) + "..."
+ : value;
+ text.append(trimmed.replaceAll("[^A-Za-z0-9_.:/ -]", "?"));
+ }
+ return text.toString();
+ }
+}
diff --git a/engine/src/main/java/org/apache/hop/www/HopServer.java
b/engine/src/main/java/org/apache/hop/www/HopServer.java
index f8b1498d30..b3edd91734 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServer.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServer.java
@@ -41,6 +41,7 @@ import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.logging.LogLevel;
import org.apache.hop.core.plugins.JarCache;
+import org.apache.hop.core.security.CrossSitePolicy;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
@@ -139,6 +140,17 @@ public class HopServer implements Runnable,
IHasHopMetadataProvider, IHopCommand
description = "Does the Hop web server have authentication enabled")
private Boolean enableAuth;
+ @CommandLine.Option(
+ names = {"-xs", "--cross-site-policy"},
+ description =
+ "Which browser requests this server accepts, based on the
Sec-Fetch-Site header: "
+ + "'same-site' (the default) rejects cross-site requests,
'same-origin' also rejects "
+ + "same-site ones, and 'off' disables the check. Clients that
send no Sec-Fetch-* "
+ + "headers, such as hop-run or the Hop GUI, are never affected.
Can also be set with "
+ + "the HOP_SERVER_CROSS_SITE_POLICY environment variable.",
+ defaultValue = "${env:HOP_SERVER_CROSS_SITE_POLICY:-same-site}")
+ private String crossSitePolicy;
+
@CommandLine.Option(
names = {"-swt", "--shutdown-timeout"},
description =
@@ -236,6 +248,9 @@ public class HopServer implements Runnable,
IHasHopMetadataProvider, IHopCommand
port,
config.getPasswordFile(),
hopServer.getSslConfig());
+ // Fail before listening rather than starting with a security control
the operator
+ // believes is configured but is not.
+ webServer.setCrossSitePolicy(CrossSitePolicy.parse(crossSitePolicy));
// Start the web server
webServer.start();
diff --git a/engine/src/main/java/org/apache/hop/www/WebServer.java
b/engine/src/main/java/org/apache/hop/www/WebServer.java
index 5af4787a8a..56c667a8eb 100644
--- a/engine/src/main/java/org/apache/hop/www/WebServer.java
+++ b/engine/src/main/java/org/apache/hop/www/WebServer.java
@@ -30,6 +30,7 @@ import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.plugins.HopServerPluginType;
import org.apache.hop.core.plugins.IPlugin;
import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.security.CrossSitePolicy;
import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.variables.Variables;
@@ -91,6 +92,12 @@ public class WebServer {
private final SslConfiguration sslConfig;
+ /**
+ * Which browser requests are accepted. Applied centrally in {@link
#start()} by a {@link
+ * CrossSiteRequestHandler} wrapped around every context.
+ */
+ @Setter @Getter private CrossSitePolicy crossSitePolicy =
CrossSitePolicy.SAME_SITE;
+
public WebServer(
ILogChannel log,
PipelineMap pipelineMap,
@@ -206,7 +213,16 @@ public class WebServer {
log.logBasic("Hop Server: Basic authentication is DISABLED
(enableAuth=false)");
}
- server.setHandler(innerHandler);
+ // Keep cross-site browser requests away from the servlets, the static
resources and the JSON
+ // API alike, whether or not authentication was enabled above.
+ if (crossSitePolicy == CrossSitePolicy.OFF) {
+ log.logBasic(BaseMessages.getString(PKG,
"WebServer.Log.CrossSiteCheckDisabled"));
+ server.setHandler(innerHandler);
+ } else {
+ log.logBasic(
+ BaseMessages.getString(PKG, "WebServer.Log.CrossSitePolicy",
crossSitePolicy.getCode()));
+ server.setHandler(new CrossSiteRequestHandler(innerHandler,
crossSitePolicy, log));
+ }
// Setup timeout to allow graceful timeout of server components
server.setStopTimeout(1000L);
diff --git
a/engine/src/main/resources/org/apache/hop/www/messages/messages_en_US.properties
b/engine/src/main/resources/org/apache/hop/www/messages/messages_en_US.properties
index 633991a2b7..ef2c5d1bc0 100644
---
a/engine/src/main/resources/org/apache/hop/www/messages/messages_en_US.properties
+++
b/engine/src/main/resources/org/apache/hop/www/messages/messages_en_US.properties
@@ -17,6 +17,8 @@
AsyncRunServlet.Log.AsyncRunRequested=Asynchronous execution of a workflow
requested
AsyncStatusServlet.Log.AsyncStatusRequested=Status of an asynchronously
executing workflow requested
+CrossSiteRequestHandler.Error.Rejected=This Hop server rejects cross-site
requests.
+CrossSiteRequestHandler.Log.Rejected=Rejected cross-site request {0} {1}
(Sec-Fetch-Site\: {2}), cross-site policy is [{3}]
GetPipelineImageServlet.Log.PipelineImageRequested=Image of pipeline requested
GetRootServlet.HopHopServer.Title=Hop hop server
GetRootServlet.HopServerMenu=Hop server menu
@@ -171,6 +173,8 @@ WebServer.Error.FailedToStop.Title=web server error
WebServer.Error.IllegalSslParameter={0} cannot be {1}.
WebServer.Log.ConfigOptions=Configuration option [{0}] set up in {1}
WebServer.Log.ConfigOptionsInvalid=Invalid configuration option [{0}]\: {1}.
Using default server value.
+WebServer.Log.CrossSiteCheckDisabled=Hop Server\: cross-site request checking
is DISABLED (cross-site policy is [off])
+WebServer.Log.CrossSitePolicy=Hop Server\: cross-site policy is [{0}]
WebServer.Log.CreateListener=Created listener for webserver @ address \:
{0}\:{1}
WebServer.Log.CreateShutDownListener=Created Shutdown listener for webserver @
address \: {0}\:{1}
WebServer.Log.HopHTTPListener=Hop HTTP listener for [{0}]
diff --git
a/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterContainerTest.java
b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterContainerTest.java
new file mode 100644
index 0000000000..bb0b80754b
--- /dev/null
+++
b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterContainerTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.servlet.DispatcherType;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.hop.core.security.CrossSitePolicy;
+import org.eclipse.jetty.ee11.servlet.FilterHolder;
+import org.eclipse.jetty.ee11.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee11.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Runs {@link CrossSiteRequestFilter} in a real servlet container, wired the
way the Hop Web {@code
+ * web.xml} wires it: mapped on {@code /hop/*} ahead of the filters that
authenticate. The
+ * mock-based test cannot see container header handling or filter ordering,
which is where a
+ * deployment actually goes wrong.
+ */
+class CrossSiteRequestFilterContainerTest {
+
+ private Server server;
+ private int port;
+
+ /** Set when the request got past the cross-site filter, i.e. as far as auth
and the servlet. */
+ private final AtomicBoolean downstreamReached = new AtomicBoolean();
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (server != null) {
+ server.stop();
+ server = null;
+ }
+ }
+
+ @Test
+ void crossSiteRequestNeverReachesAuthenticationOrTheServlet() throws
Exception {
+ start(CrossSitePolicy.SAME_SITE);
+
+ assertEquals(403, statusFor("cross-site"));
+ assertFalse(
+ downstreamReached.get(), "the request must be refused before
authentication runs at all");
+ }
+
+ @Test
+ void allowedRequestsReachTheServlet() throws Exception {
+ start(CrossSitePolicy.SAME_SITE);
+
+ for (String site : new String[] {null, "none", "same-origin",
"same-site"}) {
+ downstreamReached.set(false);
+ assertEquals(200, statusFor(site), "Sec-Fetch-Site: " + site);
+ assertTrue(downstreamReached.get(), "Sec-Fetch-Site: " + site);
+ }
+ }
+
+ @Test
+ void duplicatedHeaderIsRejected() throws Exception {
+ start(CrossSitePolicy.SAME_SITE);
+
+ // Two contradictory values, as a proxy on the way in might produce. There
is no safe reading.
+ HttpRequest request =
+ HttpRequest.newBuilder(URI.create("http://localhost:" + port +
"/hop/status"))
+ .timeout(Duration.ofSeconds(30))
+ .header(CrossSiteRequestHandler.SEC_FETCH_SITE, "same-origin")
+ .header(CrossSiteRequestHandler.SEC_FETCH_SITE, "cross-site")
+ .GET()
+ .build();
+ try (HttpClient client = HttpClient.newHttpClient()) {
+ assertEquals(403, client.send(request,
HttpResponse.BodyHandlers.discarding()).statusCode());
+ }
+ assertFalse(downstreamReached.get());
+ }
+
+ @Test
+ void offPolicyLetsCrossSiteThrough() throws Exception {
+ start(CrossSitePolicy.OFF);
+
+ assertEquals(200, statusFor("cross-site"));
+ assertTrue(downstreamReached.get());
+ }
+
+ private void start(CrossSitePolicy policy) throws Exception {
+ port = freePort();
+ server = new Server();
+ ServerConnector connector = new ServerConnector(server);
+ connector.setPort(port);
+ connector.setHost("localhost");
+ server.setConnectors(new org.eclipse.jetty.server.Connector[] {connector});
+
+ ServletContextHandler context = new ServletContextHandler("/",
ServletContextHandler.SESSIONS);
+
+ FilterHolder crossSite = new FilterHolder(CrossSiteRequestFilter.class);
+ crossSite.setInitParameter(CrossSiteRequestFilter.INIT_PARAM_POLICY,
policy.getCode());
+ context.addFilter(crossSite, "/hop/*", EnumSet.of(DispatcherType.REQUEST));
+
+ // Stands in for HopBasicAuthFilter / HopOidcAuthFilter, which web.xml
maps after this one.
+ context.addFilter(
+ new FilterHolder(markerFilter()), "/hop/*",
EnumSet.of(DispatcherType.REQUEST));
+
+ context.addServlet(new ServletHolder(okServlet()), "/hop/*");
+ server.setHandler(context);
+ server.start();
+ }
+
+ private Filter markerFilter() {
+ return new Filter() {
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, jakarta.servlet.ServletException {
+ downstreamReached.set(true);
+ chain.doFilter(request, response);
+ }
+ };
+ }
+
+ private static HttpServlet okServlet() {
+ return new HttpServlet() {
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
+ resp.setStatus(200);
+ resp.getWriter().write("ok");
+ }
+ };
+ }
+
+ private int statusFor(String secFetchSite) throws Exception {
+ HttpRequest.Builder builder =
+ HttpRequest.newBuilder(URI.create("http://localhost:" + port +
"/hop/status"))
+ .timeout(Duration.ofSeconds(30))
+ .GET();
+ if (secFetchSite != null) {
+ builder.header(CrossSiteRequestHandler.SEC_FETCH_SITE, secFetchSite);
+ }
+ try (HttpClient client = HttpClient.newHttpClient()) {
+ return client.send(builder.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+ }
+
+ private static int freePort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+}
diff --git
a/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterTest.java
b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterTest.java
new file mode 100644
index 0000000000..f1095a5192
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestFilterTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import org.apache.hop.core.security.CrossSitePolicy;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The servlet-container half of the cross-site check, used where {@link
HopServerServlet} is
+ * deployed on {@code /hop/*} outside the standalone hop-server - notably
inside Hop Web.
+ */
+class CrossSiteRequestFilterTest {
+
+ private static CrossSiteRequestFilter filterWithPolicy(String policy) throws
ServletException {
+ FilterConfig config = mock(FilterConfig.class);
+
when(config.getInitParameter(CrossSiteRequestFilter.INIT_PARAM_POLICY)).thenReturn(policy);
+ CrossSiteRequestFilter filter = new CrossSiteRequestFilter();
+ filter.init(config);
+ return filter;
+ }
+
+ private static HttpServletRequest requestWith(String... secFetchSiteValues) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRequestURI()).thenReturn("/hop/execPipeline");
+ Enumeration<String> values =
Collections.enumeration(List.of(secFetchSiteValues));
+
when(request.getHeaders(CrossSiteRequestHandler.SEC_FETCH_SITE)).thenReturn(values);
+ return request;
+ }
+
+ @Test
+ void crossSiteRequestIsRejectedAndNeverReachesTheChain() throws Exception {
+ CrossSiteRequestFilter filter =
filterWithPolicy(CrossSitePolicy.SAME_SITE.getCode());
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter body = new StringWriter();
+ when(response.getWriter()).thenReturn(new PrintWriter(body, true));
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(requestWith("cross-site"), response, chain);
+
+ // Never reaching the chain is the point: the auth filters and the servlet
are both downstream.
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ assertTrue(body.toString().contains("cross-site"), body.toString());
+ }
+
+ @Test
+ void allowedRequestsReachTheChain() throws Exception {
+ CrossSiteRequestFilter filter =
filterWithPolicy(CrossSitePolicy.SAME_SITE.getCode());
+ FilterChain chain = mock(FilterChain.class);
+
+ // The server's own pages, a bookmark, a sibling host, and a non-browser
client.
+ filter.doFilter(requestWith("same-origin"),
mock(HttpServletResponse.class), chain);
+ filter.doFilter(requestWith("none"), mock(HttpServletResponse.class),
chain);
+ filter.doFilter(requestWith("same-site"), mock(HttpServletResponse.class),
chain);
+ filter.doFilter(requestWith(), mock(HttpServletResponse.class), chain);
+
+ verify(chain, times(4)).doFilter(any(), any());
+ }
+
+ @Test
+ void sameOriginPolicyAlsoRejectsSameSite() throws Exception {
+ CrossSiteRequestFilter filter =
filterWithPolicy(CrossSitePolicy.SAME_ORIGIN.getCode());
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter(),
true));
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(requestWith("same-site"), response, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void offPolicyLetsEverythingThrough() throws Exception {
+ CrossSiteRequestFilter filter =
filterWithPolicy(CrossSitePolicy.OFF.getCode());
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(requestWith("cross-site"),
mock(HttpServletResponse.class), chain);
+
+ verify(chain).doFilter(any(), any());
+ }
+
+ /** A missing header enumeration is what some containers hand back; it must
not blow up. */
+ @Test
+ void absentHeaderEnumerationIsTreatedAsNoHeader() throws Exception {
+ CrossSiteRequestFilter filter =
filterWithPolicy(CrossSitePolicy.SAME_ORIGIN.getCode());
+ HttpServletRequest request = mock(HttpServletRequest.class);
+
when(request.getHeaders(CrossSiteRequestHandler.SEC_FETCH_SITE)).thenReturn(null);
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(request, mock(HttpServletResponse.class), chain);
+
+ verify(chain).doFilter(any(), any());
+ }
+
+ /** Starting with a policy the operator mistyped would silently leave the
check wide open. */
+ @Test
+ void unusableInitParameterFailsDeployment() {
+ ServletException e =
+ assertThrows(ServletException.class, () ->
filterWithPolicy("strict-please"));
+ assertTrue(e.getMessage().contains("strict-please"), e.getMessage());
+ }
+
+ /** No init parameter means the Hop Web security config decides, which
defaults to same-site. */
+ @Test
+ void withoutAnInitParameterTheSecurityConfigDecides() throws Exception {
+ CrossSiteRequestFilter filter = filterWithPolicy(null);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter(),
true));
+ FilterChain chain = mock(FilterChain.class);
+
+ filter.doFilter(requestWith("cross-site"), response, chain);
+
+ verify(chain, never()).doFilter(any(), any());
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ void policyCodesAreTheOnesTheGuiOffers() {
+ assertEquals("same-site", CrossSitePolicy.SAME_SITE.getCode());
+ assertEquals("same-origin", CrossSitePolicy.SAME_ORIGIN.getCode());
+ assertEquals("off", CrossSitePolicy.OFF.getCode());
+ }
+}
diff --git
a/engine/src/test/java/org/apache/hop/www/CrossSiteRequestHandlerTest.java
b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestHandlerTest.java
new file mode 100644
index 0000000000..1c6ba3370f
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/www/CrossSiteRequestHandlerTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.hop.www;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.security.CrossSitePolicy;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.server.HopServerMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Exercises the cross-site check against a running {@link WebServer} with the
real servlets
+ * registered, so it covers the wiring as well as the policy: the handler has
to sit in front of
+ * every context, and in front of authentication, whether or not
authentication is enabled.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class CrossSiteRequestHandlerTest {
+
+ private static final String HOST_NAME = "localhost";
+
+ private static final int OK = 200;
+ private static final int BAD_REQUEST = 400;
+ private static final int UNAUTHORIZED = 401;
+ private static final int FORBIDDEN = 403;
+
+ /** A read-only servlet: answers 200 once the request gets past the check. */
+ private static final String STATUS_PATH = GetStatusServlet.CONTEXT_PATH;
+
+ /**
+ * A state-changing servlet, the kind this whole check exists for. There is
no such pipeline, so
+ * reaching the servlet shows up as a 400 — which is exactly what proves the
request got through.
+ */
+ private static final String STOP_PATH = StopPipelineServlet.CONTEXT_PATH +
"?name=no-such";
+
+ private WebServer webServer;
+ private int port;
+
+ @BeforeAll
+ static void initHop() throws Exception {
+ // Registers the servlet plugins that WebServer mounts.
+ HopEnvironment.init();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (webServer != null) {
+ webServer.stop();
+ webServer = null;
+ }
+ }
+
+ @Test
+ void crossSiteRequestIsRejectedWithoutAuthentication() throws Exception {
+ startServer(CrossSitePolicy.SAME_SITE, false);
+
+ assertEquals(FORBIDDEN, statusFor(STATUS_PATH, "cross-site"));
+ assertEquals(FORBIDDEN, statusFor(STOP_PATH, "cross-site"));
+ }
+
+ /** The check must not be reachable only through the authenticated branch of
WebServer.start(). */
+ @Test
+ void crossSiteRequestIsRejectedBeforeAuthentication() throws Exception {
+ startServer(CrossSitePolicy.SAME_SITE, true);
+
+ // Without credentials an allowed request gets as far as the 401...
+ assertEquals(UNAUTHORIZED, statusFor(STATUS_PATH, "same-origin"));
+ // ...so a 403 for the cross-site one shows the check ran ahead of
authentication rather than
+ // riding on it.
+ assertEquals(FORBIDDEN, statusFor(STATUS_PATH, "cross-site"));
+ assertEquals(FORBIDDEN, statusFor(STOP_PATH, "cross-site"));
+ }
+
+ @Test
+ void sameSiteRequestIsAllowedByDefaultButNotUnderSameOrigin() throws
Exception {
+ startServer(CrossSitePolicy.SAME_SITE, false);
+ assertEquals(OK, statusFor(STATUS_PATH, "same-site"));
+ tearDown();
+
+ startServer(CrossSitePolicy.SAME_ORIGIN, false);
+ assertEquals(FORBIDDEN, statusFor(STATUS_PATH, "same-site"));
+ }
+
+ @Test
+ void ownPagesAndNonBrowserClientsAreUntouched() throws Exception {
+ startServer(CrossSitePolicy.SAME_ORIGIN, false);
+
+ // A typed address or a bookmark.
+ assertEquals(OK, statusFor(STATUS_PATH, "none"));
+ // A link or a form on the server's own status page.
+ assertEquals(OK, statusFor(STATUS_PATH, "same-origin"));
+ // hop-run, the Hop GUI, curl, automation: no Sec-Fetch-* headers at all.
+ assertEquals(OK, statusFor(STATUS_PATH, null));
+ assertEquals(OK, statusFor(GetRootServlet.CONTEXT_PATH, null));
+ // Including on the state-changing servlets, which have to keep working.
+ assertEquals(BAD_REQUEST, statusFor(STOP_PATH, null));
+ assertEquals(BAD_REQUEST, statusFor(STOP_PATH, "same-origin"));
+ }
+
+ /** A client that cannot send the header correctly can always send nothing
at all. */
+ @Test
+ void unrecognisedHeaderValueIsRejected() throws Exception {
+ startServer(CrossSitePolicy.SAME_SITE, false);
+
+ assertEquals(FORBIDDEN, statusFor(STATUS_PATH, "nonsense"));
+ }
+
+ @Test
+ void nothingIsRejectedWhenTheCheckIsOff() throws Exception {
+ startServer(CrossSitePolicy.OFF, false);
+
+ assertEquals(OK, statusFor(STATUS_PATH, "cross-site"));
+ assertEquals(BAD_REQUEST, statusFor(STOP_PATH, "cross-site"));
+ }
+
+ private void startServer(CrossSitePolicy policy, boolean enableAuth) throws
Exception {
+ HopServerMeta hopServer =
+ new HopServerMeta("test", HOST_NAME, "0", "cluster",
"cluster-password");
+ hopServer.setEnableAuth(enableAuth);
+
+ HopServerConfig serverConfig = new HopServerConfig(hopServer);
+
+ PipelineMap pipelineMap = new PipelineMap();
+ pipelineMap.setHopServerConfig(serverConfig);
+ WorkflowMap workflowMap = new WorkflowMap();
+ workflowMap.setHopServerConfig(serverConfig);
+
+ port = freePort();
+ webServer =
+ new WebServer(
+ new LogChannel("cross-site-test"), pipelineMap, workflowMap,
HOST_NAME, port, null);
+ webServer.setCrossSitePolicy(policy);
+ webServer.start();
+ }
+
+ /**
+ * Issue a GET against the running server.
+ *
+ * @param path the path to request
+ * @param secFetchSite the Sec-Fetch-Site value to send, or null to send no
such header
+ * @return the HTTP status code
+ */
+ private int statusFor(String path, String secFetchSite) throws Exception {
+ HttpRequest.Builder builder =
+ HttpRequest.newBuilder(URI.create("http://" + HOST_NAME + ":" + port +
path))
+ .timeout(Duration.ofSeconds(30))
+ .GET();
+ if (secFetchSite != null) {
+ builder.header(CrossSiteRequestHandler.SEC_FETCH_SITE, secFetchSite);
+ }
+ try (HttpClient client = HttpClient.newHttpClient()) {
+ return client.send(builder.build(),
HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+ }
+
+ private static int freePort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+}
diff --git
a/integration-tests/hop_server/main-0017-test-cross-site-requests.hwf
b/integration-tests/hop_server/main-0017-test-cross-site-requests.hwf
new file mode 100644
index 0000000000..4bd9b05861
--- /dev/null
+++ b/integration-tests/hop_server/main-0017-test-cross-site-requests.hwf
@@ -0,0 +1,418 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0017-test-cross-site-requests</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Proves hop-server rejects cross-site browser requests and
leaves everything else alone (issue #8371). Runs against the default
--cross-site-policy same-site.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/14 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/14 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Non-browser client is allowed</name>
+ <description>hop-run, the Hop GUI, curl and customer automation send no
Sec-Fetch-* headers at all. They have to keep working exactly as before, or the
change breaks every existing integration.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}'
"http://hop-server:8181/hop/status?xml=Y" | grep -q '^200$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Bookmark or typed address is allowed</name>
+ <description>A browser sends Sec-Fetch-Site: none when the operator
types the address or uses a bookmark.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: none' "http://hop-server:8181/hop/status?xml=Y" | grep -q
'^200$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>112</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>The server's own pages are allowed</name>
+ <description>Every link and form on the status page is a same-origin
request.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: same-origin' "http://hop-server:8181/hop/status?xml=Y" | grep
-q '^200$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>176</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Same-site is allowed under the default policy</name>
+ <description>Another host on the same registrable domain - a second Hop
server, an intranet portal, the same host on another port. Rejected only under
--cross-site-policy same-origin.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: same-site' "http://hop-server:8181/hop/status?xml=Y" | grep -q
'^200$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>240</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cross-site read is rejected</name>
+ <description>A page on another site reading the server status through
the operator's browser.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: cross-site' "http://hop-server:8181/hop/status?xml=Y" | grep
-q '^403$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>304</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Unrecognised Sec-Fetch-Site value is rejected</name>
+ <description>A client that cannot send the header correctly can always
send nothing at all, so an unknown value fails closed rather than
open.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: nonsense' "http://hop-server:8181/hop/status?xml=Y" | grep -q
'^403$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>368</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>State-changing endpoint is reachable when allowed</name>
+ <description>stopPipeline answers 400 with its own page for a pipeline
that does not exist. That the servlet answered at all is what makes the
cross-site 403 below meaningful.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -H 'Sec-Fetch-Site: same-origin'
"http://hop-server:8181/hop/stopPipeline?name=no-such-pipeline" | grep -q 'Stop
pipeline' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>432</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cross-site stop is rejected</name>
+ <description>The same state-changing endpoint, driven from another site,
never reaches the servlet.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: cross-site'
"http://hop-server:8181/hop/stopPipeline?name=no-such-pipeline" | grep -q
'^403$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>496</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cross-site pipeline execution is rejected</name>
+ <description>The case issue #8371 is really about: a visited web page
starting a pipeline on a server the operator is logged in to. Rejected before
routing, so the pipeline never runs.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: cross-site'
"http://hop-server:8181/hop/execPipeline?pipeline=/hop_server_volume/0016-echo-server-variables.hpl&level=BASIC"
| grep -q '^403$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>560</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>624</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <parallel>N</parallel>
+ <xloc>544</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Non-browser client is allowed</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Non-browser client is allowed</from>
+ <to>Bookmark or typed address is allowed</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Bookmark or typed address is allowed</from>
+ <to>The server's own pages are allowed</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>The server's own pages are allowed</from>
+ <to>Same-site is allowed under the default policy</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Same-site is allowed under the default policy</from>
+ <to>Cross-site read is rejected</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site read is rejected</from>
+ <to>Unrecognised Sec-Fetch-Site value is rejected</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Unrecognised Sec-Fetch-Site value is rejected</from>
+ <to>State-changing endpoint is reachable when allowed</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>State-changing endpoint is reachable when allowed</from>
+ <to>Cross-site stop is rejected</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site stop is rejected</from>
+ <to>Cross-site pipeline execution is rejected</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site pipeline execution is rejected</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Non-browser client is allowed</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Bookmark or typed address is allowed</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>The server's own pages are allowed</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Same-site is allowed under the default policy</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site read is rejected</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Unrecognised Sec-Fetch-Site value is rejected</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>State-changing endpoint is reachable when allowed</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site stop is rejected</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site pipeline execution is rejected</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/hop_server/main-0018-test-cross-site-policy-off.hwf
b/integration-tests/hop_server/main-0018-test-cross-site-policy-off.hwf
new file mode 100644
index 0000000000..0c099b623b
--- /dev/null
+++ b/integration-tests/hop_server/main-0018-test-cross-site-policy-off.hwf
@@ -0,0 +1,196 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0018-test-cross-site-policy-off</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Proves HOP_SERVER_CROSS_SITE_POLICY=off reaches the server and
disables the cross-site check, so an operator who needs cross-site browser
access has a way out (issue #8371).</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/14 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/14 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Non-browser client is still allowed</name>
+ <description>Turning the check off must not change anything for clients
that never sent the header.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}'
"http://hop-server-nocheck:8182/hop/status?xml=Y" | grep -q '^200$' || exit
1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cross-site read is allowed when the check is off</name>
+ <description>The escape hatch has to actually work: this is a 403 on a
server using the default policy.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -o /dev/null -w '%{http_code}' -H
'Sec-Fetch-Site: cross-site' "http://hop-server-nocheck:8182/hop/status?xml=Y"
| grep -q '^200$' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>112</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Cross-site state change is allowed when the check is off</name>
+ <description>Proves HOP_SERVER_CROSS_SITE_POLICY reached the server
process rather than being ignored.</description>
+ <type>SHELL</type>
+ <attributes/>
+ <filename/>
+ <work_directory/>
+ <arg_from_previous>N</arg_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <set_append_logfile>N</set_append_logfile>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <insertScript>Y</insertScript>
+ <script>curl -u cluster:cluster -s -H 'Sec-Fetch-Site: cross-site'
"http://hop-server-nocheck:8182/hop/stopPipeline?name=no-such-pipeline" | grep
-q 'Stop pipeline' || exit 1</script>
+ <loglevel>Basic</loglevel>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>176</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>240</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <parallel>N</parallel>
+ <xloc>544</xloc>
+ <yloc>48</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Non-browser client is still allowed</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>Non-browser client is still allowed</from>
+ <to>Cross-site read is allowed when the check is off</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site read is allowed when the check is off</from>
+ <to>Cross-site state change is allowed when the check is off</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site state change is allowed when the check is off</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Non-browser client is still allowed</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site read is allowed when the check is off</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ <hop>
+ <from>Cross-site state change is allowed when the check is off</from>
+ <to>Abort workflow</to>
+ <enabled>Y</enabled>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
index c71f745a2b..891feccd63 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/ConfigSecurityGeneralTab.java
@@ -20,6 +20,7 @@ package
org.apache.hop.ui.hopgui.perspective.configuration.tabs.security;
import org.apache.hop.core.Const;
import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.core.gui.plugin.tab.GuiTab;
+import org.apache.hop.core.security.CrossSitePolicy;
import org.apache.hop.core.security.HopSecurityConfig;
import org.apache.hop.core.security.HopUserStore;
import org.apache.hop.i18n.BaseMessages;
@@ -45,6 +46,7 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
private Combo wMode;
private Text wWelcome;
private Button wAllowServerApi;
+ private Combo wCrossSitePolicy;
public ConfigSecurityGeneralTab() {
// Instantiated by ConfigSecurityTab / @GuiTab system
@@ -115,7 +117,31 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
wAllowServerApi.setLayoutData(fdAllowServerApi);
last = wAllowServerApi;
- SecurityConfigUi.addHint(content, last,
"ConfigSecurityTab.AllowServerApi.Hint", margin);
+ last = SecurityConfigUi.addHint(content, last,
"ConfigSecurityTab.AllowServerApi.Hint", margin);
+
+ Label wlCrossSitePolicy = new Label(content, SWT.RIGHT);
+ wlCrossSitePolicy.setText(
+ BaseMessages.getString(PKG,
"ConfigSecurityTab.CrossSitePolicy.Label"));
+ PropsUi.setLook(wlCrossSitePolicy);
+ FormData fdlCrossSitePolicy = new FormData();
+ fdlCrossSitePolicy.left = new FormAttachment(0, 0);
+ fdlCrossSitePolicy.top = new FormAttachment(last, margin * 2);
+ fdlCrossSitePolicy.right = new FormAttachment(mid, 0);
+ wlCrossSitePolicy.setLayoutData(fdlCrossSitePolicy);
+
+ wCrossSitePolicy = new Combo(content, SWT.BORDER | SWT.READ_ONLY);
+ PropsUi.setLook(wCrossSitePolicy);
+ wCrossSitePolicy.setItems(SecurityConfigUi.CROSS_SITE_POLICIES);
+ wCrossSitePolicy.setToolTipText(
+ BaseMessages.getString(PKG,
"ConfigSecurityTab.CrossSitePolicy.Tooltip"));
+ FormData fdCrossSitePolicy = new FormData();
+ fdCrossSitePolicy.left = new FormAttachment(mid, margin);
+ fdCrossSitePolicy.top = new FormAttachment(last, margin * 2);
+ fdCrossSitePolicy.right = new FormAttachment(100, 0);
+ wCrossSitePolicy.setLayoutData(fdCrossSitePolicy);
+ last = wCrossSitePolicy;
+
+ SecurityConfigUi.addHint(content, last,
"ConfigSecurityTab.CrossSitePolicy.Hint", margin);
SecurityConfigUi.finishTabLayout(content);
}
@@ -131,6 +157,10 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
if (wAllowServerApi != null && !wAllowServerApi.isDisposed()) {
wAllowServerApi.setSelection(config.isAllowUnauthenticatedServerApi());
}
+ if (wCrossSitePolicy != null && !wCrossSitePolicy.isDisposed()) {
+ wCrossSitePolicy.setText(
+ Const.NVL(config.getCrossSitePolicy(),
CrossSitePolicy.SAME_SITE.getCode()));
+ }
}
@Override
@@ -144,6 +174,9 @@ public class ConfigSecurityGeneralTab implements
ISecurityConfigSection {
if (wAllowServerApi != null && !wAllowServerApi.isDisposed()) {
config.setAllowUnauthenticatedServerApi(wAllowServerApi.getSelection());
}
+ if (wCrossSitePolicy != null && !wCrossSitePolicy.isDisposed()) {
+ config.setCrossSitePolicy(wCrossSitePolicy.getText());
+ }
}
public String getSelectedMode() {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/SecurityConfigUi.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/SecurityConfigUi.java
index e4ae5e200d..b8b9cabc73 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/SecurityConfigUi.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/security/SecurityConfigUi.java
@@ -17,6 +17,7 @@
package org.apache.hop.ui.hopgui.perspective.configuration.tabs.security;
+import org.apache.hop.core.security.CrossSitePolicy;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.ui.core.PropsUi;
import
org.apache.hop.ui.hopgui.perspective.configuration.tabs.ConfigSecurityTab;
@@ -37,6 +38,11 @@ import org.eclipse.swt.widgets.Text;
public final class SecurityConfigUi {
public static final String[] AUTH_MODES = {"NONE", "EXTERNAL", "BASIC",
"OAUTH2"};
+ public static final String[] CROSS_SITE_POLICIES = {
+ CrossSitePolicy.SAME_SITE.getCode(),
+ CrossSitePolicy.SAME_ORIGIN.getCode(),
+ CrossSitePolicy.OFF.getCode()
+ };
public static final String[] HOP_ROLE_IDS = {"admin", "user", "operator",
"readonly"};
public static final String[] YES_NO = {"Y", "N"};
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
index cd7b20019b..d0cddc3fad 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/configuration/tabs/messages/messages_en_US.properties
@@ -28,6 +28,9 @@ ConfigSecurityTab.Mode.Hint=NONE = open access. EXTERNAL =
trust servlet contain
ConfigSecurityTab.Welcome.Label=Login welcome message
ConfigSecurityTab.Welcome.Tooltip=Shown on the Hop Web sign-in page (BASIC and
OAUTH2). Leave empty to use the default.
ConfigSecurityTab.Welcome.Hint=Optional custom text under the Apache Hop Web
title on the login page.
+ConfigSecurityTab.CrossSitePolicy.Hint=Browsers send Sec-Fetch-Site on every
request; clients that send no Sec-Fetch-* headers at all, such as hop-run or
the Hop GUI, are never affected. Overridden by the HOP_SERVER_CROSS_SITE_POLICY
environment variable.
+ConfigSecurityTab.CrossSitePolicy.Label=Cross-site requests to the Hop Server
API
+ConfigSecurityTab.CrossSitePolicy.Tooltip=same-site rejects requests started
by another site, same-origin also rejects other hosts of the same domain, off
disables the check.
ConfigSecurityTab.AllowServerApi.Label=Expose the Hop Server API without
authentication
ConfigSecurityTab.AllowServerApi.Tooltip=Only applies to mode NONE. The
authenticated modes always require the matching role permission on /hop/*.
ConfigSecurityTab.AllowServerApi.Hint=Off by default: the embedded Hop Server
API (/hop/*) is closed in mode NONE so it cannot run pipelines or workflows
without authentication. Enable only to use Hop Web as an execution server
behind your own network controls.
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiCrossSiteTest.java
b/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiCrossSiteTest.java
new file mode 100644
index 0000000000..2eb8f4cd5a
--- /dev/null
+++
b/web-tests/src/test/java/org/apache/hop/web/it/HopServerApiCrossSiteTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.hop.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Base64;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.PullPolicy;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * API-level integration test for the cross-site guard on the embedded Hop
Server API ({@code
+ * /hop/*}) in Hop Web (issue #8371).
+ *
+ * <p>Hop Web co-deploys the Hop Server servlets outside the standalone
hop-server, so the guard
+ * there is a {@code web.xml} filter rather than the Jetty handler. This test
drives the deployed
+ * war over HTTP, which is the only way to prove the filter is actually mapped
and ordered ahead of
+ * authentication rather than merely present on the classpath.
+ *
+ * <p>The state-changing endpoints answer on {@code GET}, so the attack this
defends against is a
+ * page the operator is visiting issuing the request in their browser, with
the session cookie
+ * attached automatically. {@code Sec-Fetch-Site: cross-site} is what such a
request carries.
+ *
+ * <p><b>Opt-in</b>, like {@link HopServerApiRbacTest}: the image under test
must contain the
+ * filter, so this only runs with {@code -Dhopweb.crosssite.it=true}. Point it
at a fixed image with
+ * {@code -Dhopweb.image=<image>} (default {@code hop-web:local}).
+ */
+@EnabledIfSystemProperty(named = "hopweb.crosssite.it", matches =
"(?i)true|1|yes")
+class HopServerApiCrossSiteTest {
+
+ private static final int HOP_WEB_PORT = 8080;
+ private static final Duration TIMEOUT = Duration.ofSeconds(20);
+ private static final String SEC_FETCH_SITE = "Sec-Fetch-Site";
+
+ /** Read-only endpoint: answers 200 for an authorized caller that gets past
the filter. */
+ private static final String STATUS = "/hop/status/?xml=Y";
+
+ /** State-changing endpoint, the kind the guard exists for. */
+ private static final String EXEC = "/hop/execPipeline/?xml=Y";
+
+ /** Default policy (same-site). */
+ private static GenericContainer<?> guarded;
+
+ /**
+ * The documented escape hatch, so we know an operator can actually get the
old behaviour back.
+ */
+ private static GenericContainer<?> unguarded;
+
+ private static String guardedUrl;
+ private static String unguardedUrl;
+
+ @BeforeAll
+ static void startContainers() {
+ guarded = hopWeb(null);
+ unguarded = hopWeb("off");
+ guarded.start();
+ unguarded.start();
+ guardedUrl = baseUrl(guarded);
+ unguardedUrl = baseUrl(unguarded);
+ }
+
+ @AfterAll
+ static void stopContainers() {
+ if (guarded != null) {
+ guarded.stop();
+ }
+ if (unguarded != null) {
+ unguarded.stop();
+ }
+ }
+
+ private static GenericContainer<?> hopWeb(String crossSitePolicy) {
+ String image = System.getProperty("hopweb.image", "hop-web:local");
+ GenericContainer<?> container =
+ new GenericContainer<>(DockerImageName.parse(image))
+ .withExposedPorts(HOP_WEB_PORT)
+ .withEnv("HOP_WEB_SECURITY_MODE", "BASIC")
+ .withEnv("HOP_WEB_SEED_DEMO_USERS", "true")
+ .withImagePullPolicy(
+ image.endsWith(":local") ? PullPolicy.defaultPolicy() :
PullPolicy.alwaysPull())
+ .waitingFor(Wait.forHttp("/login").forStatusCode(200))
+ .withStartupTimeout(Duration.ofSeconds(120));
+ if (crossSitePolicy != null) {
+ // The same environment variable the standalone hop-server uses.
+ container = container.withEnv("HOP_SERVER_CROSS_SITE_POLICY",
crossSitePolicy);
+ }
+ return container;
+ }
+
+ private static String baseUrl(GenericContainer<?> container) {
+ return "http://" + container.getHost() + ":" +
container.getMappedPort(HOP_WEB_PORT);
+ }
+
+ // --- tests ---------------------------------------------------------------
+
+ /**
+ * The admin has every permission, so a 403 here can only come from the
cross-site filter — which
+ * also shows it runs ahead of authentication and RBAC rather than depending
on them.
+ */
+ @Test
+ void crossSiteRequestIsRejectedEvenForAnAdmin() throws Exception {
+ assertEquals(403, get(guardedUrl, STATUS, "admin", "cross-site"));
+ assertEquals(403, get(guardedUrl, EXEC, "admin", "cross-site"));
+ }
+
+ @Test
+ void crossSiteRequestIsRejectedWithoutCredentialsToo() throws Exception {
+ assertEquals(403, get(guardedUrl, EXEC, null, "cross-site"));
+ }
+
+ /** hop-run, the Hop GUI, curl and customer automation send no Sec-Fetch-*
headers at all. */
+ @Test
+ void nonBrowserClientsAreUnaffected() throws Exception {
+ assertEquals(200, get(guardedUrl, STATUS, "admin", null));
+ assertNotEquals(403, get(guardedUrl, EXEC, "admin", null));
+ }
+
+ /** A bookmark, Hop Web's own pages, and a sibling host on the same domain.
*/
+ @Test
+ void browserRequestsThatAreNotCrossSiteStillWork() throws Exception {
+ assertEquals(200, get(guardedUrl, STATUS, "admin", "none"));
+ assertEquals(200, get(guardedUrl, STATUS, "admin", "same-origin"));
+ assertEquals(200, get(guardedUrl, STATUS, "admin", "same-site"));
+ }
+
+ /** A client that cannot send the header correctly can always send nothing
at all. */
+ @Test
+ void unrecognisedHeaderValueIsRejected() throws Exception {
+ assertEquals(403, get(guardedUrl, STATUS, "admin", "nonsense"));
+ }
+
+ /**
+ * HOP_SERVER_CROSS_SITE_POLICY=off restores the previous behaviour, in Hop
Web exactly as on the
+ * standalone hop-server.
+ */
+ @Test
+ void offEnvironmentVariableRestoresTheOldBehaviour() throws Exception {
+ assertEquals(200, get(unguardedUrl, STATUS, "admin", "cross-site"));
+ assertNotEquals(403, get(unguardedUrl, EXEC, "admin", "cross-site"));
+ }
+
+ // --- helper --------------------------------------------------------------
+
+ private int get(String baseUrl, String path, String user, String
secFetchSite) throws Exception {
+ HttpRequest.Builder builder =
+ HttpRequest.newBuilder(URI.create(baseUrl +
path)).timeout(TIMEOUT).GET();
+ if (user != null) {
+ String creds =
+ Base64.getEncoder().encodeToString((user + ":" +
user).getBytes(StandardCharsets.UTF_8));
+ builder.header("Authorization", "Basic " + creds);
+ }
+ if (secFetchSite != null) {
+ builder.header(SEC_FETCH_SITE, secFetchSite);
+ }
+ // Do not follow the login redirect: a 3xx to /login is itself the
"unauthenticated" signal.
+ HttpResponse<String> response =
+ HttpClient.newBuilder()
+ .connectTimeout(TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build()
+ .send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ return response.statusCode();
+ }
+}