From 8bf147354fb70e12fcde2b69479cad9339d8508e Mon Sep 17 00:00:00 2001 From: Richard Wheeldon Date: Wed, 1 Feb 2017 11:21:42 +0000 Subject: [PATCH] Add IpFilter for restricting access to resources from those coming from without (or outside) specific IP ranges. Add IpAddressMatcher taken from Spring Security used for range tests IpFilter fixes based on code review comments Add ip to DefaultFilter --- NOTICE | 8 +- .../shiro/guice/web/ShiroWebModule.java | 3 + .../web/filter/authz/IpAddressMatcher.java | 97 ++++++++++++ .../shiro/web/filter/authz/IpFilter.java | 142 ++++++++++++++++++ .../shiro/web/filter/authz/IpSource.java | 43 ++++++ .../shiro/web/filter/mgt/DefaultFilter.java | 1 + .../filter/authz/IpAddressMatcherTests.java | 77 ++++++++++ .../shiro/web/filter/authz/IpFilterTest.java | 103 +++++++++++++ 8 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 web/src/main/java/org/apache/shiro/web/filter/authz/IpAddressMatcher.java create mode 100644 web/src/main/java/org/apache/shiro/web/filter/authz/IpFilter.java create mode 100644 web/src/main/java/org/apache/shiro/web/filter/authz/IpSource.java create mode 100644 web/src/test/java/org/apache/shiro/web/filter/authz/IpAddressMatcherTests.java create mode 100644 web/src/test/java/org/apache/shiro/web/filter/authz/IpFilterTest.java diff --git a/NOTICE b/NOTICE index afda18636c..9d26a95ffb 100644 --- a/NOTICE +++ b/NOTICE @@ -9,7 +9,7 @@ on initial ideas from Dr. Heinz Kabutz's publicly posted version available at http://www.javaspecialists.eu/archive/Issue015.html, with continued modifications. -Certain parts (StringUtils etc.) of the source code for this -product was copied for simplicity and to reduce dependencies -from the source code developed by the Spring Framework Project -(http://www.springframework.org). +Certain parts (StringUtils, IpAddressMatcher, etc.) of the source +code for this product was copied for simplicity and to reduce +dependencies from the source code developed by the Spring Framework +Project (http://www.springframework.org). diff --git a/support/guice/src/main/java/org/apache/shiro/guice/web/ShiroWebModule.java b/support/guice/src/main/java/org/apache/shiro/guice/web/ShiroWebModule.java index df95665f40..0bda76555d 100644 --- a/support/guice/src/main/java/org/apache/shiro/guice/web/ShiroWebModule.java +++ b/support/guice/src/main/java/org/apache/shiro/guice/web/ShiroWebModule.java @@ -38,6 +38,7 @@ import org.apache.shiro.web.filter.authc.LogoutFilter; import org.apache.shiro.web.filter.authc.UserFilter; import org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter; +import org.apache.shiro.web.filter.authz.IpFilter; import org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter; import org.apache.shiro.web.filter.authz.PortFilter; import org.apache.shiro.web.filter.authz.RolesAuthorizationFilter; @@ -86,6 +87,8 @@ public abstract class ShiroWebModule extends ShiroModule { @SuppressWarnings({"UnusedDeclaration"}) public static final Key SSL = Key.get(SslFilter.class); @SuppressWarnings({"UnusedDeclaration"}) + public static final Key IP = Key.get(IpFilter.class); + @SuppressWarnings({"UnusedDeclaration"}) public static final Key USER = Key.get(UserFilter.class); diff --git a/web/src/main/java/org/apache/shiro/web/filter/authz/IpAddressMatcher.java b/web/src/main/java/org/apache/shiro/web/filter/authz/IpAddressMatcher.java new file mode 100644 index 0000000000..107c80e7e0 --- /dev/null +++ b/web/src/main/java/org/apache/shiro/web/filter/authz/IpAddressMatcher.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed 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.shiro.web.filter.authz; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; + +/** + * Matches a request based on IP Address or subnet mask matching against the remote + * address. + *

+ * Both IPv6 and IPv4 addresses are supported, but a matcher which is configured with an + * IPv4 address will never match a request which returns an IPv6 address, and vice-versa. + * + * @see Original Spring Security version + * @since 2.0 + */ +public final class IpAddressMatcher { + private final int nMaskBits; + private final InetAddress requiredAddress; + + /** + * Takes a specific IP address or a range specified using the IP/Netmask (e.g. + * 192.168.1.0/24 or 202.24.0.0/14). + * + * @param ipAddress the address or range of addresses from which the request must + * come. + */ + public IpAddressMatcher(String ipAddress) { + int i = ipAddress.indexOf('/'); + if (i > 0) { + nMaskBits = Integer.parseInt(ipAddress.substring(i + 1)); + ipAddress = ipAddress.substring(0, i); + } else { + nMaskBits = -1; + } + requiredAddress = parseAddress(ipAddress); + } + + public boolean matches(String address) { + InetAddress remoteAddress = parseAddress(address); + + if (!requiredAddress.getClass().equals(remoteAddress.getClass())) { + return false; + } + + if (nMaskBits < 0) { + return remoteAddress.equals(requiredAddress); + } + + byte[] remAddr = remoteAddress.getAddress(); + byte[] reqAddr = requiredAddress.getAddress(); + + int oddBits = nMaskBits % 8; + int nMaskBytes = nMaskBits / 8 + (oddBits == 0 ? 0 : 1); + byte[] mask = new byte[nMaskBytes]; + + Arrays.fill(mask, 0, oddBits == 0 ? mask.length : mask.length - 1, (byte) 0xFF); + + if (oddBits != 0) { + int finalByte = (1 << oddBits) - 1; + finalByte <<= 8 - oddBits; + mask[mask.length - 1] = (byte) finalByte; + } + + for (int i = 0; i < mask.length; i++) { + if ((remAddr[i] & mask[i]) != (reqAddr[i] & mask[i])) { + return false; + } + } + + return true; + } + + private InetAddress parseAddress(String address) { + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException e) { + throw new IllegalArgumentException("Failed to parse address" + address, e); + } + } +} diff --git a/web/src/main/java/org/apache/shiro/web/filter/authz/IpFilter.java b/web/src/main/java/org/apache/shiro/web/filter/authz/IpFilter.java new file mode 100644 index 0000000000..c5bd4a968d --- /dev/null +++ b/web/src/main/java/org/apache/shiro/web/filter/authz/IpFilter.java @@ -0,0 +1,142 @@ +/* + * 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.shiro.web.filter.authz; + +import org.apache.shiro.config.ConfigurationException; +import org.apache.shiro.util.StringUtils; +import org.apache.shiro.web.util.WebUtils; + +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Collection; + +/** + * A Filter that requires the request to be from within a specific set of IP + * address ranges and / or not from with a specific (denied) set. + *

+ * Example config: + *

+ * [main]
+ * localLan = org.apache.shiro.web.filter.authz.IpFilter
+ * localLan.authorizedIps = 192.168.10.0/24
+ * localLan.deniedIps = 192.168.10.10/32
+ * 

+ * [urls] + * /some/path/** = localLan + * # override for just this path: + * /another/path/** = localLan + *

+ * + * @since 2.0 + */ +public class IpFilter extends AuthorizationFilter { + + private static IpSource DEFAULT_IP_SOURCE = new IpSource() { + public Collection getAuthorizedIps() { + return Collections.emptySet(); + } + public Collection getDeniedIps() { + return Collections.emptySet(); + } + }; + + private IpSource ipSource = DEFAULT_IP_SOURCE; + + private List authorizedIpMatchers = Collections.emptyList(); + private List deniedIpMatchers = Collections.emptyList(); + + /** + * Specifies a set of (comma, tab or space-separated) strings representing + * IP address representing IPv4 or IPv6 ranges / CIDRs from which access + * should be allowed (if the IP is not included in either the list of + * statically defined denied IPs or the dynamic list of IPs obtained from + * the IP source. + */ + public void setAuthorizedIps(String authorizedIps) { + String[] ips = StringUtils.tokenizeToStringArray(authorizedIps, ", \t"); + if (ips != null && ips.length > 0) { + authorizedIpMatchers = new ArrayList(); + for (String ip : ips) { + authorizedIpMatchers.add(new IpAddressMatcher(ip)); + } + } + } + + /** + * Specified a set of (comma, tab or space-separated) strings representing + * IP address representing IPv4 or IPv6 ranges / CIDRs from which access + * should be blocked. + */ + public void setDeniedIps(String deniedIps) { + String[] ips = StringUtils.tokenizeToStringArray(deniedIps, ", \t"); + if (ips != null && ips.length > 0) { + deniedIpMatchers = new ArrayList(); + for (String ip : ips) { + deniedIpMatchers.add(new IpAddressMatcher(ip)); + } + } + } + + public void setIpSource(IpSource source) { + this.ipSource = source; + } + + /** + * Returns the remote host for a given HTTP request. By default uses the + * remote method ServletRequest.getRemoteAddr(). May be overriden by + * subclasses to obtain address information from specific headers (e.g. XFF + * or Forwarded) in situations with reverse proxies. + */ + public String getHostFromRequest(ServletRequest request) { + return request.getRemoteAddr(); + } + + protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { + String remoteIp = getHostFromRequest(request); + for (IpAddressMatcher matcher : deniedIpMatchers) { + if (matcher.matches(remoteIp)) { + return false; + } + } + for (String ip : ipSource.getDeniedIps()) { + IpAddressMatcher matcher = new IpAddressMatcher(ip); + if (matcher.matches(remoteIp)) { + return false; + } + } + for (IpAddressMatcher matcher : authorizedIpMatchers) { + if (matcher.matches(remoteIp)) { + return true; + } + } + for (String ip : ipSource.getAuthorizedIps()) { + IpAddressMatcher matcher = new IpAddressMatcher(ip); + if (matcher.matches(remoteIp)) { + return true; + } + } + return false; + } +} diff --git a/web/src/main/java/org/apache/shiro/web/filter/authz/IpSource.java b/web/src/main/java/org/apache/shiro/web/filter/authz/IpSource.java new file mode 100644 index 0000000000..7b2f626af5 --- /dev/null +++ b/web/src/main/java/org/apache/shiro/web/filter/authz/IpSource.java @@ -0,0 +1,43 @@ +/* + * 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.shiro.web.filter.authz; + +import java.util.Collection; + +/** + * Represents a source of information for IP restrictions (see IpFilter) + * @since 2.0 + */ +public interface IpSource { + + /** + * Returns a set of strings representing IP address representing + * IPv4 or IPv6 ranges / CIDRs. e.g. 192.168.0.0/16 from which + * access should be allowed (if and only if the IP is not included + * in the list of denied IPs) + */ + public Collection getAuthorizedIps(); + + /** + * Returns a set of strings representing IP address representing + * IPv4 or IPv6 ranges / CIDRs. e.g. 192.168.0.0/16 from which + * access should be denied. + */ + public Collection getDeniedIps(); +} diff --git a/web/src/main/java/org/apache/shiro/web/filter/mgt/DefaultFilter.java b/web/src/main/java/org/apache/shiro/web/filter/mgt/DefaultFilter.java index 036f62fd12..a023febf4f 100644 --- a/web/src/main/java/org/apache/shiro/web/filter/mgt/DefaultFilter.java +++ b/web/src/main/java/org/apache/shiro/web/filter/mgt/DefaultFilter.java @@ -41,6 +41,7 @@ public enum DefaultFilter { authc(FormAuthenticationFilter.class), authcBasic(BasicHttpAuthenticationFilter.class), authcBearer(BearerHttpAuthenticationFilter.class), + ip(IpFilter.class), logout(LogoutFilter.class), noSessionCreation(NoSessionCreationFilter.class), perms(PermissionsAuthorizationFilter.class), diff --git a/web/src/test/java/org/apache/shiro/web/filter/authz/IpAddressMatcherTests.java b/web/src/test/java/org/apache/shiro/web/filter/authz/IpAddressMatcherTests.java new file mode 100644 index 0000000000..ad87303a38 --- /dev/null +++ b/web/src/test/java/org/apache/shiro/web/filter/authz/IpAddressMatcherTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2016 the original author or authors. + * + * Licensed 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.shiro.web.filter.authz; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +/** + * @since 2.0 + */ +public class IpAddressMatcherTests { + final IpAddressMatcher v6matcher = new IpAddressMatcher("fe80::21f:5bff:fe33:bd68"); + final IpAddressMatcher v4matcher = new IpAddressMatcher("192.168.1.104"); + final String ipv6Address = "fe80::21f:5bff:fe33:bd68"; + final String ipv4Address = "192.168.1.104"; + + @Test + public void ipv6MatcherMatchesIpv6Address() { + assertTrue(v6matcher.matches(ipv6Address)); + } + + @Test + public void ipv6MatcherDoesntMatchIpv4Address() { + assertFalse(v6matcher.matches(ipv4Address)); + } + + @Test + public void ipv4MatcherMatchesIpv4Address() { + assertTrue(v4matcher.matches(ipv4Address)); + } + + @Test + public void ipv4SubnetMatchesCorrectly() throws Exception { + IpAddressMatcher matcher = new IpAddressMatcher("192.168.1.0/24"); + assertTrue(matcher.matches(ipv4Address)); + matcher = new IpAddressMatcher("192.168.1.128/25"); + assertFalse(matcher.matches(ipv4Address)); + assertTrue(matcher.matches("192.168.1.159")); + } + + @Test + public void ipv6RangeMatches() throws Exception { + IpAddressMatcher matcher = new IpAddressMatcher("2001:DB8::/48"); + assertTrue(matcher.matches("2001:DB8:0:0:0:0:0:0")); + assertTrue(matcher.matches("2001:DB8:0:0:0:0:0:1")); + assertTrue(matcher.matches("2001:DB8:0:FFFF:FFFF:FFFF:FFFF:FFFF")); + assertFalse(matcher.matches("2001:DB8:1:0:0:0:0:0")); + } + + // https://github.com/spring-projects/spring-security/issues/1970q + @Test + public void zeroMaskMatchesAnything() throws Exception { + IpAddressMatcher matcher = new IpAddressMatcher("0.0.0.0/0"); + + assertTrue(matcher.matches("123.4.5.6")); + assertTrue(matcher.matches("192.168.0.159")); + + matcher = new IpAddressMatcher("192.168.0.159/0"); + assertTrue(matcher.matches("123.4.5.6")); + assertTrue(matcher.matches("192.168.0.159")); + } +} diff --git a/web/src/test/java/org/apache/shiro/web/filter/authz/IpFilterTest.java b/web/src/test/java/org/apache/shiro/web/filter/authz/IpFilterTest.java new file mode 100644 index 0000000000..c5def24f72 --- /dev/null +++ b/web/src/test/java/org/apache/shiro/web/filter/authz/IpFilterTest.java @@ -0,0 +1,103 @@ +/* + * 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.shiro.web.filter.authz; + +import org.junit.Test; + +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.util.Collection; +import java.util.Collections; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +/** + * Test cases for the {@link AuthorizationFilter} class. + * @since 2.0 + */ +public class IpFilterTest { + + @Test + public void accessShouldBeDeniedByDefault() throws Exception { + IpFilter filter = new IpFilter(); + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + expect(request.getRemoteAddr()).andReturn("192.168.42.42"); + replay(request); + assertFalse(filter.isAccessAllowed(request, null, null)); + verify(request); + } + + @Test + public void accessShouldBeDeniedWhenNotInTheAllowedSet() throws Exception { + IpFilter filter = new IpFilter(); + filter.setAuthorizedIps("192.168.33/24"); + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + expect(request.getRemoteAddr()).andReturn("192.168.42.42"); + replay(request); + assertFalse(filter.isAccessAllowed(request, null, null)); + verify(request); + } + + @Test + public void accessShouldBeGrantedToIpsInTheAllowedSet() throws Exception { + IpFilter filter = new IpFilter(); + filter.setAuthorizedIps("192.168.32/24 192.168.33/24 192.168.34/24"); + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + expect(request.getRemoteAddr()).andReturn("192.168.33.44"); + replay(request); + assertFalse(filter.isAccessAllowed(request, null, null)); + verify(request); + } + + @Test + public void deniedTakesPrecedenceOverAllowed() throws Exception { + IpFilter filter = new IpFilter(); + filter.setAuthorizedIps("192.168.0.0/16"); + filter.setDeniedIps("192.168.33.0/24"); + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + expect(request.getRemoteAddr()).andReturn("192.168.33.44"); + replay(request); + assertFalse(filter.isAccessAllowed(request, null, null)); + verify(request); + } + + @Test + public void willBlockAndAllowBasedOnIpSource() throws Exception { + IpSource source = new IpSource() { + public Collection getAuthorizedIps() { + return Collections.singleton("192.168.0.0/16"); + } + public Collection getDeniedIps() { + return Collections.singleton("192.168.33.0/24"); + } + }; + IpFilter filter = new IpFilter(); + filter.setIpSource(source); + HttpServletRequest request = createNiceMock(HttpServletRequest.class); + expect(request.getRemoteAddr()).andReturn("192.168.33.44"); + replay(request); + assertFalse(filter.isAccessAllowed(request, null, null)); + verify(request); + } +}