This is an automated email from the ASF dual-hosted git repository. joerghoh pushed a commit to branch SLING-13312 in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-security.git
commit 5b9cf10919b5566a3fa53f6c92482307f42151c7 Author: Joerg Hoh <[email protected]> AuthorDate: Tue Aug 18 19:46:23 2026 +0200 SLING-13312 harden request-origin validation --- .../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..bc42c74 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 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 =
