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&amp;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();
+  }
+}

Reply via email to