This is an automated email from the ASF dual-hosted git repository.
joerghoh pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-security.git
The following commit(s) were added to refs/heads/master by this push:
new d215a7b SLING-13312 harden request-origin validation (#15)
d215a7b is described below
commit d215a7bccb8538a0dba82f6ced13995828978205
Author: Jörg Hoh <[email protected]>
AuthorDate: Wed Aug 19 14:43:54 2026 +0200
SLING-13312 harden request-origin validation (#15)
* SLING-13312 harden request-origin validation
Co-authored-by: Konrad Windszus <[email protected]>
---
.../apache/sling/security/impl/ReferrerFilter.java | 85 ++++++++++++--
.../sling/security/impl/ReferrerFilterTest.java | 125 ++++++++++++++++++++-
2 files changed, 199 insertions(+), 11 deletions(-)
diff --git a/src/main/java/org/apache/sling/security/impl/ReferrerFilter.java
b/src/main/java/org/apache/sling/security/impl/ReferrerFilter.java
index a65dde5..b79e1d3 100644
--- a/src/main/java/org/apache/sling/security/impl/ReferrerFilter.java
+++ b/src/main/java/org/apache/sling/security/impl/ReferrerFilter.java
@@ -108,16 +108,33 @@ public class ReferrerFilter implements Preprocessor {
@AttributeDefinition(name = "Allow Empty", description = "Allow an
empty or missing referrer")
boolean allow_empty() default false;
+ /**
+ * Allow loopback and server NIC addresses (legacy behavior).
+ */
+ @AttributeDefinition(
+ name = "Allow Local Server Addresses (legacy)",
+ description =
+ "If enabled, any request whose Referer/Origin claims
to originate from this server itself "
+ + "— \"localhost\", \"127.0.0.1\", \"[::1]\",
or any IP bound to this server's own network "
+ + "interfaces — is trusted on ANY port, for
both \"http\" and \"https\". This also trusts every "
+ + "other application running on this machine
(or sharing its IP) on a different port, so leave "
+ + "disabled unless you specifically need that
trust. Well-behaved applications on \"localhost\" or "
+ + "a locally assigned IP send a Referer/Origin
matching their own origin (scheme, host AND port), "
+ + "which is always allowed regardless of this
setting — so they keep working correctly whether "
+ + "this option is enabled or not. It should
NOT be activated unless you have a specific, understood "
+ + "need for it: it exists only for backwards
compatibility with legacy setups.")
+ boolean allow_server_addresses() default false;
+
/**
* Allow referrer uri hosts property.
*/
@AttributeDefinition(
name = "Allow Hosts",
description =
- "List of allowed hosts for the referrer which are
added to the list of default hosts. "
+ "List of allowed hosts for the referrer. "
+ "It is matched against the full referrer URL
in the format \"<scheme>://<host>:<port>\". "
- + "If port is 0, it is not taken into
consideration. The default list contains all host names "
- + "and IPs bound to all NICs found in the
system plus \"localhost\", \"127.0.0.1\", \"[::1]\" for protocols \"http\" and
\"https\". "
+ + "If port is 0, it is not taken into
consideration. If \"Allow Server Addresses\" is enabled, all host names "
+ + "and IPs bound to all NICs found in the
system plus \"localhost\", \"127.0.0.1\", \"[::1]\" are added to this list for
protocols \"http\" and \"https\". "
+ "If given value does not have a \":\"
entries for both http and https are transparently generated.")
String[] allow_hosts() default {};
@@ -173,7 +190,11 @@ public class ReferrerFilter implements Preprocessor {
private final String[] excludedPaths;
/**
- * Create a default list of referrers
+ * Create the legacy list of default referrers (loopback plus every
NIC-bound
+ * address, any port, http and https). Only used when the operator
explicitly
+ * opts in via {@link Config#allow_server_addresses()}: these host names
describe
+ * the origin of the page in the requesting user's browser, not this
server, so
+ * they must not be trusted by default.
*/
private Set<String> getDefaultAllowedReferrers() {
final Set<String> referrers = new HashSet<>();
@@ -287,7 +308,8 @@ public class ReferrerFilter implements Preprocessor {
this.excludedPaths = mergeValues(config.exclude_paths(), amendments, a
-> a.excludePaths())
.toArray(new String[0]);
- final Set<String> allowUriReferrers = getDefaultAllowedReferrers();
+ final Set<String> allowUriReferrers =
+ config.allow_server_addresses() ? getDefaultAllowedReferrers()
: new HashSet<>();
if (config.allow_hosts() != null) {
allowUriReferrers.addAll(mergeValues(config.allow_hosts(),
amendments, a -> a.allowHosts()));
}
@@ -388,9 +410,11 @@ public class ReferrerFilter implements Preprocessor {
}
String referrer = request.getHeader("referer");
+ boolean fromOriginHeader = false;
// use the origin if the referrer is not set
if (referrer == null || referrer.trim().length() == 0) {
referrer = request.getHeader("origin");
+ fromOriginHeader = true;
}
// check for missing/empty referrer
@@ -403,9 +427,35 @@ public class ReferrerFilter implements Preprocessor {
}
return this.allowEmpty;
}
+ // The literal value "null" is the serialization of an opaque origin
+ // (RFC 6454): browsers send "Origin: null" for cross-site requests
+ // issued from sandboxed iframes, data: documents or pages with a
+ // no-referrer referrer policy. It explicitly marks an untrustworthy
+ // origin and must never be treated as a relative referrer, so it is
+ // handled exactly like a missing referrer (subject to allow.empty).
+ if ("null".equalsIgnoreCase(referrer.trim())) {
+ if (!this.allowEmpty) {
+ this.logger.info(
+ "Rejected 'null' (opaque) origin/referrer for {}
request to {}",
+ request.getMethod(),
+ request.getRequestURI());
+ }
+ return this.allowEmpty;
+ }
// check for relative referrer - which is always allowed
if (!referrer.contains(":/")) {
- return true;
+ // only the Referer header may legitimately carry a relative
+ // value; the Origin header is always an absolute origin (or
+ // "null", handled above), so a non-absolute Origin is illegal
+ if (!fromOriginHeader) {
+ return true;
+ }
+ this.logger.info(
+ "Rejected non-absolute origin header for {} request to {}
: {}",
+ request.getMethod(),
+ request.getRequestURI(),
+ referrer);
+ return false;
}
final HostInfo info = getHost(referrer);
@@ -419,9 +469,11 @@ public class ReferrerFilter implements Preprocessor {
return false;
}
- // allow the request if the host name of the referrer is
- // the same as the request's host name
- if (info.host.equals(request.getServerName())) {
+ // allow the request if the referrer designates the request's own
+ // origin: scheme, host AND port must match.
+ if (info.host.equals(request.getServerName())
+ && info.scheme.equals(request.getScheme())
+ && info.port == normalizePort(request.getScheme(),
request.getServerPort())) {
return true;
}
@@ -454,6 +506,21 @@ public class ReferrerFilter implements Preprocessor {
// nothing to do
}
+ /**
+ * Normalize a port number by replacing an unknown port with the default
+ * port of the given scheme.
+ *
+ * @param scheme The scheme the port is used with
+ * @param port The port number
+ * @return the given port, or the scheme's default port if the given port
is not positive
+ */
+ private static int normalizePort(final String scheme, final int port) {
+ if (port <= 0) {
+ return "https".equals(scheme) ? 443 : 80;
+ }
+ return port;
+ }
+
/**
* @param hostInfo The hostInfo to check for validity
* @return <code>true</code> if the hostInfo matches any of the allowed
URI referrer.
diff --git
a/src/test/java/org/apache/sling/security/impl/ReferrerFilterTest.java
b/src/test/java/org/apache/sling/security/impl/ReferrerFilterTest.java
index b4e46f0..6f66a85 100644
--- a/src/test/java/org/apache/sling/security/impl/ReferrerFilterTest.java
+++ b/src/test/java/org/apache/sling/security/impl/ReferrerFilterTest.java
@@ -54,6 +54,16 @@ public class ReferrerFilterTest {
String[] allowHostsRexexp,
String[] excludeAgentsRegexp,
String[] excludePaths) {
+ return createConfiguration(allowEmpty, false, allowHosts,
allowHostsRexexp, excludeAgentsRegexp, excludePaths);
+ }
+
+ private static ReferrerFilter.Config createConfiguration(
+ boolean allowEmpty,
+ boolean allowServerAddresses,
+ String[] allowHosts,
+ String[] allowHostsRexexp,
+ String[] excludeAgentsRegexp,
+ String[] excludePaths) {
return new ReferrerFilter.Config() {
@Override
public Class<? extends Annotation> annotationType() {
@@ -65,6 +75,11 @@ public class ReferrerFilterTest {
return allowEmpty;
}
+ @Override
+ public boolean allow_server_addresses() {
+ return allowServerAddresses;
+ }
+
@Override
public String[] allow_hosts() {
return allowHosts;
@@ -142,8 +157,10 @@ public class ReferrerFilterTest {
assertTrue(filter.isValidRequest(getRequest("/relative/too")));
assertTrue(filter.isValidRequest(getRequest("/relative/but/[illegal]")));
assertFalse(filter.isValidRequest(getRequest("http://somehost")));
- assertTrue(filter.isValidRequest(getRequest("http://localhost")));
- assertTrue(filter.isValidRequest(getRequest("http://127.0.0.1")));
+ // loopback referrers identify the user's machine, not this server:
+ // they are no longer trusted by default
+ assertFalse(filter.isValidRequest(getRequest("http://localhost")));
+ assertFalse(filter.isValidRequest(getRequest("http://127.0.0.1")));
assertFalse(filter.isValidRequest(getRequest("http://somehost/but/[illegal]")));
assertTrue(filter.isValidRequest(getRequest("http://relhost")));
assertTrue(filter.isValidRequest(getRequest("http://relhost:9001")));
@@ -221,6 +238,37 @@ public class ReferrerFilterTest {
assertTrue(rf.isValidRequest(getRequest("http://test2.com:80", null,
"/test_path")));
}
+ private static HttpServletRequest getSameHostRequest(final String
referrer, final String scheme, final int port) {
+ final HttpServletRequest request = getRequest(referrer);
+ when(request.getServerName()).thenReturn("myhost");
+ when(request.getScheme()).thenReturn(scheme);
+ when(request.getServerPort()).thenReturn(port);
+ return request;
+ }
+
+ @Test
+ public void testSameOriginReferrerAllowed() {
+
assertTrue(filter.isValidRequest(getSameHostRequest("https://myhost/page",
"https", 443)));
+
assertTrue(filter.isValidRequest(getSameHostRequest("https://myhost:443/page",
"https", 443)));
+
assertTrue(filter.isValidRequest(getSameHostRequest("http://myhost/page",
"http", 80)));
+
assertTrue(filter.isValidRequest(getSameHostRequest("http://myhost:8080/page",
"http", 8080)));
+ }
+
+ @Test
+ public void testSameHostDifferentSchemeRejected() {
+ // an http:// page (active-network attacker) must not vouch for the
+ // https deployment of the same host
+
assertFalse(filter.isValidRequest(getSameHostRequest("http://myhost/page",
"https", 443)));
+ }
+
+ @Test
+ public void testSameHostDifferentPortRejected() {
+ // another service on a different port of the same host is a
+ // different origin
+
assertFalse(filter.isValidRequest(getSameHostRequest("https://myhost:8443/page",
"https", 443)));
+
assertFalse(filter.isValidRequest(getSameHostRequest("http://myhost:8081/page",
"http", 80)));
+ }
+
@Test
public void testAllowsWithOrigin() {
HttpServletRequest request = getRequest(null);
@@ -228,6 +276,44 @@ public class ReferrerFilterTest {
Assert.assertEquals(true, filter.isValidRequest(request));
}
+ @Test
+ public void testRejectsNullOrigin() {
+ // browsers serialize an opaque origin (sandboxed iframe, data:
document,
+ // no-referrer policy) as the literal string "null" - it must not be
+ // treated as an allowed relative referrer
+ HttpServletRequest request = getRequest(null);
+ when(request.getHeader("origin")).thenReturn("null");
+ assertFalse(filter.isValidRequest(request));
+ }
+
+ @Test
+ public void testRejectsNullReferrer() {
+ // the literal string "null" in the Referer header must not be treated
+ // as an allowed relative referrer either
+ assertFalse(filter.isValidRequest(getRequest("null")));
+ assertFalse(filter.isValidRequest(getRequest("NULL")));
+ }
+
+ @Test
+ public void testNullOriginFollowsAllowEmpty() {
+ // "null" carries the same information as a missing referrer and thus
+ // follows the allow.empty configuration
+ ReferrerFilter rf =
+ new ReferrerFilter(createConfiguration(true, null, null, null,
null), Collections.emptyList());
+ HttpServletRequest request = getRequest(null);
+ when(request.getHeader("origin")).thenReturn("null");
+ assertTrue(rf.isValidRequest(request));
+ }
+
+ @Test
+ public void testRejectsNonAbsoluteOrigin() {
+ // the Origin header is always an absolute origin or "null"; a
+ // non-absolute value must not take the relative-referrer shortcut
+ HttpServletRequest request = getRequest(null);
+ when(request.getHeader("origin")).thenReturn("relative");
+ assertFalse(filter.isValidRequest(request));
+ }
+
@Test
public void testAllowEmpty() {
ReferrerFilter rf =
@@ -237,6 +323,41 @@ public class ReferrerFilterTest {
assertTrue(rf.isValidRequest(getRequest("", null, null)));
}
+ /**
+ * Regression: the Referer/Origin header describes the page in the
*user's* browser, so a
+ * page served by a local application on the victim's machine (e.g. a dev
server on
+ * 127.0.0.1:3000) must not be trusted by default to send state-changing
requests (CSRF).
+ */
+ @Test
+ public void testServerAddressesNotTrustedByDefault() {
+ final ReferrerFilter rf =
+ new ReferrerFilter(createConfiguration(false, null, null,
null, null), Collections.emptyList());
+
+ assertFalse(rf.isValidRequest(getRequest("http://localhost")));
+ assertFalse(rf.isValidRequest(getRequest("http://localhost:3000")));
+ assertFalse(rf.isValidRequest(getRequest("http://127.0.0.1:3000")));
+ assertFalse(rf.isValidRequest(getRequest("https://127.0.0.1")));
+
+ // same-host referrers are still allowed if the request originates
from there
+ final HttpServletRequest request = getRequest("http://localhost:3000");
+ when(request.getServerName()).thenReturn("localhost");
+ when(request.getScheme()).thenReturn("http");
+ when(request.getServerPort()).thenReturn(3000);
+ assertTrue(rf.isValidRequest(request));
+ }
+
+ @Test
+ public void testServerAddressesOptIn() {
+ final ReferrerFilter rf =
+ new ReferrerFilter(createConfiguration(false, true, null,
null, null, null), Collections.emptyList());
+
+ assertTrue(rf.isValidRequest(getRequest("http://localhost")));
+ assertTrue(rf.isValidRequest(getRequest("http://localhost:3000")));
+ assertTrue(rf.isValidRequest(getRequest("http://127.0.0.1:3000")));
+ assertTrue(rf.isValidRequest(getRequest("https://127.0.0.1")));
+ assertFalse(rf.isValidRequest(getRequest("http://somehost")));
+ }
+
@Test
public void testIsBrowserRequest() {
String userAgent =