--- a/java/org/apache/catalina/filters/RemoteCIDRFilter.java +++ a/java/org/apache/catalina/filters/RemoteCIDRFilter.java @@ -0,0 +1,241 @@ +package org.apache.catalina.filters; + +import org.apache.catalina.comet.CometEvent; +import org.apache.catalina.comet.CometFilter; +import org.apache.catalina.comet.CometFilterChain; +import org.apache.catalina.util.NetMask; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public final class RemoteCIDRFilter + extends FilterBase + implements CometFilter { + + /** + * text/plain MIME type: this is the MIME type we return when a + * {@link ServletResponse} is not an {@link HttpServletResponse} + */ + + private static final String PLAIN_TEXT_MIME_TYPE = "text/plain"; + + /** + * Our logger + */ + + private static final Log log = LogFactory.getLog(RemoteCIDRFilter.class); + + /** + * The list of allowed {@link NetMask}s + */ + + private final List allow = new ArrayList(); + + /** + * The list of denied {@link NetMask}s + */ + + private final List deny = new ArrayList(); + + /** + * Return a string representation of the {@link NetMask} list in #allow. + * + * @return the #allow list as a string, without the leading '[' and + * trailing ']' + */ + + public String getAllow() { + return allow.toString().replace("[", "").replace("]", ""); + } + + + /** + * Fill the #allow list with the list of netmasks provided as an argument, + * if any. Calls #fillFromInput. + * + * @param input The list of netmasks, as a comma separated string + * @throws IllegalArgumentException One or more netmasks are invalid + */ + + public void setAllow(final String input) { + final List messages = fillFromInput(input, allow); + + if (messages.isEmpty()) + return; + + for (final String message:messages) + log.error(message); + + throw new IllegalArgumentException("Filter error, see messages above"); + } + + + /** + * Return a string representation of the {@link NetMask} list in #deny. + * + * @return the #deny list as string, without the leading '[' and trailing + * ']' + */ + + public String getDeny() { + return deny.toString().replace("[", "").replace("]", ""); + } + + + /** + * Fill the #deny list with the list of netmasks provided as an argument, + * if any. Calls #fillFromInput. + * + * @param input The list of netmasks, as a comma separated string + * @throws IllegalArgumentException One or more netmasks are invalid + */ + + public void setDeny(final String input) { + final List messages = fillFromInput(input, deny); + + if (messages.isEmpty()) + return; + + for (final String message: messages) + log.error(message); + + throw new IllegalArgumentException("Filter error: illegal netmask(s) " + + "in allow, see messages above"); + } + + @Override + public void doFilterEvent(CometEvent event, CometFilterChain chain) + throws IOException, ServletException { + processCometEvent(event.getHttpServletRequest().getRemoteHost(), + event, chain); + } + + @Override + public void doFilter(final ServletRequest request, + final ServletResponse response, final FilterChain chain) + throws IOException, ServletException { + process(request.getRemoteAddr(), request, response, chain); + } + + public void processCometEvent(final String property, final CometEvent event, + final CometFilterChain chain) + throws IOException, ServletException { + HttpServletResponse response = event.getHttpServletResponse(); + + if (isAllowed(property)) { + chain.doFilterEvent(event); + return; + } + + response.sendError(HttpServletResponse.SC_FORBIDDEN); + event.close(); + } + + public void process(final String property, final ServletRequest request, + final ServletResponse response, final FilterChain chain) + throws IOException, ServletException { + + if (isAllowed(property)) { + chain.doFilter(request, response); + return; + } + + if (!(response instanceof HttpServletResponse)) { + sendErrorWhenNotHttp(response); + return; + } + + ((HttpServletResponse) response) + .sendError(HttpServletResponse.SC_FORBIDDEN); + } + + @Override + public Log getLogger() { + return log; + } + + /** + * Test if a remote's IP address is allowed to proceed. + * + * @param property The remote's IP address, as a string + * @return true if allowed + */ + + private boolean isAllowed(final String property) { + final InetAddress addr; + + try { + addr = InetAddress.getByName(property); + } catch (UnknownHostException e) { + //Eh? + log.error("Eh? Our remote doesn't even have a valid IP address? ", + e); + return false; + } + + for (final NetMask nm: deny) + if (nm.matches(addr)) + return false; + + for (final NetMask nm: allow) + if (nm.matches(addr)) + return true; + + // Allow if deny is specified but allow isn't + if (!deny.isEmpty() && allow.isEmpty()) + return true; + + // Deny this request + return false; + } + + private void sendErrorWhenNotHttp(ServletResponse response) + throws IOException { + final PrintWriter writer = response.getWriter(); + response.setContentType(PLAIN_TEXT_MIME_TYPE); + writer.write(sm.getString("http.403")); + writer.flush(); + } + + /** + * Fill a {@link NetMask} list from a string input containing a + * comma-separated list of (hopefully valid) {@link NetMask}s. + * + * @param input The input string + * @param victim The list to fill + * @return a string list of processing errors (empty when no errors) + */ + + private List fillFromInput(final String input, + final List victim) { + victim.clear(); + if (input == null || input.isEmpty()) + return Collections.emptyList(); + + final List messages = new LinkedList(); + NetMask nm; + + for (final String s: input.split("\\s*,\\s*")) + try { + nm = new NetMask(s); + victim.add(nm); + } catch (IllegalArgumentException e) { + messages.add(s + ": " + e.getMessage()); + } + + return Collections.unmodifiableList(messages); + } +} --- a/java/org/apache/catalina/util/NetMask.java +++ a/java/org/apache/catalina/util/NetMask.java @@ -0,0 +1,213 @@ +package org.apache.catalina.util; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * A class representing a CIDR netmask. + * + *

The constructor takes a string as an argument which represents a + * netmask, as per the CIDR notation -- whether this netmask be IPv4 or + * IPv6. It then extracts the network address (before the /) and the CIDR + * prefix (after the /), and tells through the #matches() method whether a + * candidate {@link InetAddress} object fits in the recorded range.

+ * + *

As byte arrays as returned by InetAddress.getByName() are + * always in network byte order, finding a match is therefore as simple as + * testing whether the n first bits (where n is the CIDR) are the same in both + * byte arrays (the one of the network address and the one of the candidate + * address). We do that by first doing byte comparisons, then testing the last + * bits if any (that is, if the remainder of the integer division of the CIDR + * by 8 is not 0).

+ * + *

As a bonus, if no / is found in the input, it is assumed that an exact + * address match is required.

+ */ + +public final class NetMask { + /** + * The argument to the constructor, used for .toString() + */ + private final String expression; + + /** + * The byte array representing the address extracted from the expression + */ + private final byte[] netaddr; + + /** + * The number of bytes to test for equality (CIDR / 8) + */ + private final int nrBytes; + + /** + * The right shift to apply to the last byte if CIDR % 8 is not 0; if it is + * 0, this variable is set to 0 + */ + private final int lastByteShift; + + /** + * Constructor + * + * @param input the CIDR netmask + * @throws IllegalArgumentException if the netmask is not correct + * (invalid address specification, malformed CIDR prefix, etc) + */ + + public NetMask(final String input) { + + expression = input; + + final int idx = input.indexOf("/"); + + /* + * Handle the "IP only" case first + */ + if (idx == -1) { + try { + netaddr = InetAddress.getByName(input).getAddress(); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("invalid address " + + "specification"); + } + nrBytes = netaddr.length; + lastByteShift = 0; + return; + } + + /* + * OK, we do have a netmask specified, so let's extract both the + * address and the CIDR. + */ + + final String addressPart = input.substring(0, idx), + cidrPart = input.substring(idx + 1); + + try { + /* + * The address first... + */ + netaddr = InetAddress.getByName(addressPart).getAddress(); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("invalid address " + + "specification"); + } + + final int addrlen = netaddr.length * 8; + final int cidr; + + try { + /* + * And then the CIDR. + */ + cidr = Integer.parseInt(cidrPart); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("CIDR is not a number"); + } + + /* + * We don't want a negative CIDR, nor do we want a CIDR which is + * greater than the address length (consider 0.0.0.0/33, or ::/129) + */ + if (cidr < 0) + throw new IllegalArgumentException("CIDR is negative"); + if (cidr > addrlen) + throw new IllegalArgumentException("CIDR is greater than address " + + "length"); + + nrBytes = cidr / 8; + + /* + * These last two lines could be shortened to: + * + * lastByteShift = (8 - (cidr % 8)) & 7; + * + * But... It's not worth it. In fact, explaining why it could work + * would be too long to be worth the trouble, so let's do it the simple + * way... + */ + + final int remainder = cidr % 8; + + lastByteShift = (remainder == 0) ? 0 : 8 - remainder; + } + + /** + * Test if a given address matches this netmask + * + * @param addr The {@link java.net.InetAddress} to test + * @return true on match, false otherwise + */ + + public boolean matches (final InetAddress addr) { + final byte[] candidate = addr.getAddress(); + + /* + * OK, remember that a CIDR prefix tells the number of BITS which + * should be equal between this NetMask's recorded address (netaddr) + * and the candidate address. One byte is 8 bits, no matter what, + * and IP addresses, whether they be IPv4 or IPv6, are big endian, + * aka MSB, Most Significant Byte (first). + * + * We therefore need to get the byte array of the candidate address, + * compare as many bytes of the candidate address with the recorded + * address as the CIDR prefix tells us to (that is, CIDR / 8), + * and then deal with the remaining bits -- if any. + * + * But prior to that, a simple test can be done: we deal with IP + * addresses here, which means IPv4 and IPv6. IPv4 addresses are + * encoded on 4 bytes, IPv6 addresses are encoded on 16 bytes. If the + * candidate address length is different than this NetMask's + * address, we don't have a match. + */ + if (candidate.length != netaddr.length) + return false; + + int i; + + /* + * Now do the byte-compare. The constructor has recorded the number + * of bytes to compare in nrBytes, use that. If any of the byte we have + * to compare is different than what we expect, we don't have a match. + * + * If, on the opposite, after this loop, all bytes have been deemed + * equal, then the loop variable i will point to the byte right after + * that -- which we will need... + */ + for (i = 0; i < nrBytes; i++) + if (netaddr[i] != candidate[i]) + return false; + + /* + * ... if there are bits left to test. There aren't any if + * lastByteShift is set to 0. + */ + if (lastByteShift == 0) + return true; + + /* + * If it is not 0, however, we must test for the relevant bits in the + * next byte (whatever is in the bytes after that doesn't matter). We + * do it this way (remember that lastByteShift contains the amount of + * bits we should _right_ shift the last byte): + * + * - grab both bytes at index i, both from the netmask address and + * the candidate address; + * - xor them both. + * + * After the xor, it means that all the remaining bits of the CIDR + * should be set to 0... + */ + final int lastByte = netaddr[i] ^ candidate[i]; + + /* + * ... Which means that right shifting by lastByteShift should be 0. + */ + return (lastByte >> lastByteShift == 0); + } + + @Override + public String toString() { + return expression; + } +} --- a/java/org/apache/catalina/valves/RemoteCIDRValve.java +++ a/java/org/apache/catalina/valves/RemoteCIDRValve.java @@ -0,0 +1,184 @@ +package org.apache.catalina.valves; + +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.Response; +import org.apache.catalina.util.NetMask; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public final class RemoteCIDRValve + extends ValveBase { + + /** + * What this Valve is to the administrator + */ + + private static final String info + = "org.apache.catalina.valves.RemoteCIDRValve/1.0"; + + /** + * Our logger + */ + + private static final Log log = LogFactory.getLog(RemoteCIDRValve.class); + + /** + * The list of allowed {@link NetMask}s + */ + + private final List allow = new ArrayList(); + + /** + * The list of denied {@link NetMask}s + */ + + private final List deny = new ArrayList(); + + public RemoteCIDRValve() { + super(true); + } + + /** + * Return a string representation of the {@link NetMask} list in #allow. + * + * @return the #allow list as a string, without the leading '[' and + * trailing ']' + */ + + public String getAllow() { + return allow.toString().replace("[", "").replace("]", ""); + } + + /** + * Fill the #allow list with the list of netmasks provided as an argument, + * if any. Calls #fillFromInput. + * + * @param input The list of netmasks, as a comma separated string + * @throws IllegalArgumentException One or more netmasks are invalid + */ + + public void setAllow(final String input) { + final List messages = fillFromInput(input, allow); + + if (messages.isEmpty()) + return; + + for (final String message: messages) + log.error(message); + + throw new IllegalArgumentException("Valve error: illegal netmask(s) " + + "in allow, see messages above"); + } + + /** + * Return a string representation of the {@link NetMask} list in #deny. + * + * @return the #deny list as a string, without the leading '[' and + * trailing ']' + */ + + public String getDeny() { + return deny.toString().replace("[", "").replace("]", ""); + } + + /** + * Fill the #deny list with the list of netmasks provided as an argument, + * if any. Calls #fillFromInput. + * + * @param input The list of netmasks, as a comma separated string + * @throws IllegalArgumentException One or more netmasks are invalid + */ + + public void setDeny(final String input) { + final List messages = fillFromInput(input, deny); + + if (messages.isEmpty()) + return; + + for (final String message: messages) + log.error(message); + + throw new IllegalArgumentException("Valve error: illegal netmask(s) " + + "in deny, see messages above"); + } + + + /** + * Return descriptive information about this Valve implementation. + */ + + @Override + public String getInfo() { + return info; + } + + @Override + public void invoke(final Request request, final Response response) + throws IOException, ServletException { + process(request.getRequest().getRemoteAddr(), request, response); + } + + private void process(final String property, final Request request, + final Response response) + throws IOException, ServletException { + + final InetAddress addr = InetAddress.getByName(property); + + for (final NetMask nm : deny) + if (nm.matches(addr)) { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } + + if (allow.isEmpty()) { + getNext().invoke(request, response); + return; + } + + for (final NetMask nm : allow) + if (nm.matches(addr)) { + getNext().invoke(request, response); + return; + } + + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + + /** + * Fill a {@link NetMask} list from a string input containing a + * comma-separated list of (hopefully valid) {@link NetMask}s. + * + * @param input The input string + * @param victim The list to fill + * @return a string list of processing errors (empty when no errors) + */ + + private List fillFromInput(final String input, + final List victim) { + victim.clear(); + if (input == null || input.isEmpty()) + return Collections.emptyList(); + + final List messages = new LinkedList(); + NetMask nm; + + for (final String s: input.split("\\s*,\\s*")) + try { + nm = new NetMask(s); + victim.add(nm); + } catch (IllegalArgumentException e) { + messages.add(s + ": " + e.getMessage()); + } + + return Collections.unmodifiableList(messages); + } +} --- a/test/org/apache/catalina/util/TestNetMask.java +++ a/test/org/apache/catalina/util/TestNetMask.java @@ -0,0 +1,72 @@ +package org.apache.catalina.util; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public final class TestNetMask +{ + + @Test + public void testIPV4InitErrors() { + try { + new NetMask("260.1.1.1"); + fail("NetMask succeeded with an invalid address!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "invalid address specification"); + } + + try { + new NetMask("1.2.3.4/foo"); + fail("NetMask succeeded with a non numeric CIDR!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is not a number"); + } + + try { + new NetMask("1.2.3.4/-1"); + fail("NetMask succeeded with a negative CIDR!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is negative"); + } + + try { + new NetMask("1.2.3.4/33"); + fail("NetMask succeeded with CIDR greater than address length!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is greater than address length"); + } + } + + @Test + public void testIPV6InitErrors() { + try { + new NetMask("fffff::/71"); + fail("NetMask succeeded with an invalid address!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "invalid address specification"); + } + + try { + new NetMask("ae31::27:ef2:1/foo"); + fail("NetMask succeeded with a non numeric CIDR!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is not a number"); + } + + try { + new NetMask("ae31::27:ef2:1/-1"); + fail("NetMask succeeded with a negative CIDR!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is negative"); + } + + try { + new NetMask("ae31::27:ef2:1/129"); + fail("NetMask succeeded with CIDR greater than address length!"); + } catch (IllegalArgumentException e) { + assertEquals(e.getMessage(), "CIDR is greater than address length"); + } + } +} --- a/webapps/docs/config/filter.xml +++ a/webapps/docs/config/filter.xml @@ -654,6 +654,109 @@ FINE: Request "/docs/config/manager.html" with response status "200" content-typ +
+ + + +

The Remote CIDR Filter allows you to compare the + IP address of the client that submitted this request against one or more + netmasks following the CIDR notation, and either allow the request to + continue or refuse to process the request from this client. IPv4 and + IPv6 are both fully supported. +

+ +

This filter mimicks Apache's Order, + Allow from and Deny from directives, + with the following limitations: +

+ +
    +
  • Order will always be allow, deny;
  • +
  • dotted quad notations for netmasks are not supported (that is, you + cannot write 192.168.1.0/255.255.255.0, you must write + 192.168.1.0/24; +
  • +
  • shortcuts, like 10.10., which is equivalent to + 10.10.0.0/16, are not supported; +
  • +
  • as the filter name says, this is a CIDR only filter, + therefore subdomain notations like .mydomain.com are not + supported either. +
  • +
+ +

Some more features of this filter are: +

+ +
    +
  • if you omit the CIDR prefix, this filter becomes a single IP + filter;
  • +
  • unlike the Remote Host Filter, + it will do the correct thing with IPv6 in every case, which means you can + write IPv6 addresses in condensed form (::1, + fe80::/71, etc).
  • +
+ +
+ + + +

The filter class name for the Remote Address Filter is + org.apache.catalina.filters.RemoteCIDRFilter + .

+ +
+ + + +

The Remote CIDR Filter supports the following + initialisation parameters:

+ + + + +

A comma-separated list of IPv4 or IPv6 netmasks or addresses + that the remote client's IP address is matched against. + If this attribute is specified, the remote address MUST match + for this request to be accepted. If this attribute is not specified, + all requests will be accepted UNLESS the remote IP is matched by a + netmask in the deny attribute. +

+
+ + +

A comma-separated list of IPv4 or IPv6 netmasks or addresses + that the remote client's IP address is matched against. + If this attribute is specified, the remote address MUST NOT match + for this request to be accepted. If this attribute is not specified, + request acceptance is governed solely by the accept + attribute. +

+
+ +
+ +
+ + +

To allow access only for the clients connecting from localhost:

+
+      <filter>
+      <filter-name>Remote CIDR Filter</filter-name>
+      <filter-class>org.apache.catalina.filters.RemoteCIDRFilter</filter-class>
+      <init-param>
+      <param-name>allow</param-name>
+      <param-value>127.0.0.0/8, ::1</param-value>
+      </init-param>
+      </filter>
+      <filter-mapping>
+      <filter-name>Remote CIDR Filter</filter-name>
+      <url-pattern>/*</url-pattern>
+      </filter-mapping>
+    
+
+ +
--- a/webapps/docs/config/valve.xml +++ a/webapps/docs/config/valve.xml @@ -621,6 +621,100 @@
+
+ + + +

The Remote CIDR Filter allows you to compare the + IP address of the client that submitted this request against one or more + netmasks following the CIDR notation, and either allow the request to + continue or refuse to process the request from this client. IPv4 and + IPv6 are both fully supported. A Remote CIDR Filter can be associated + with any Catalina container (Engine, + Host, or Context), and + must accept any request presented to this container for processing before + it will be passed on. +

+ +

This filter mimicks Apache's Order, + Allow from and Deny from directives, + with the following limitations: +

+ +
    +
  • Order will always be allow, deny;
  • +
  • dotted quad notations for netmasks are not supported (that is, you + cannot write 192.168.1.0/255.255.255.0, you must write + 192.168.1.0/24; +
  • +
  • shortcuts, like 10.10., which is equivalent to + 10.10.0.0/16, are not supported; +
  • +
  • as the filter name says, this is a CIDR only filter, + therefore subdomain notations like .mydomain.com are not + supported either. +
  • +
+ +

Some more features of this filter are: +

+ +
    +
  • if you omit the CIDR prefix, this filter becomes a single IP + filter;
  • +
  • unlike the Remote Host Filter, + it will do the correct thing with IPv6 in every case, which means you can + write IPv6 addresses in condensed form (::1, + fe80::/71, etc).
  • +
+ +
+ + + +

The Remote CIDR Filter supports the following + configuration attributes:

+ + + + +

Java class name of the implementation to use. This MUST be set to + org.apache.catalina.valves.RemoteCIDRValve.

+
+ + +

A comma-separated list of IPv4 or IPv6 netmasks or addresses + that the remote client's IP address is matched against. + If this attribute is specified, the remote address MUST match + for this request to be accepted. If this attribute is not specified, + all requests will be accepted UNLESS the remote IP is matched by a + netmask in the deny attribute. +

+
+ + +

A comma-separated list of IPv4 or IPv6 netmasks or addresses + that the remote client's IP address is matched against. + If this attribute is specified, the remote address MUST NOT match + for this request to be accepted. If this attribute is not specified, + request acceptance is governed solely by the accept + attribute. +

+
+ +
+ +
+ + +

To allow access only for the clients connecting from localhost:

+
+      <Valve className="org.apache.catalina.valves.RemoteCIDRValve"
+      allow="127.0.0.1, ::1"/>
+    
+
+ +