diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpURI.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpURI.java index 5dc6db1302aa..0a2e758caf39 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpURI.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpURI.java @@ -36,7 +36,7 @@ /** * Http URI. * Parse an HTTP URI from a string or byte array. Given a URI - * <code>http://user@host:port/path;param1/%2e/info;param2?query#fragment</code> + * {@code http://user@host:port/path;param1/%2e/info;param2?query#fragment} * this class will split it into the following optional elements:<ul> * <li>{@link #getScheme()} - http:</li> * <li>{@link #getAuthority()} - //name@host:port</li> @@ -65,11 +65,13 @@ * Thus this class avoid and/or detects such ambiguities. Furthermore, by decoding characters and * removing parameters before relative path normalization, ambiguous paths will be resolved in such * a way to be non-standard-but-non-ambiguous to down stream interpretation of the decoded path string. - * The violations are recorded and available by API such as {@link #hasViolation(Violation)} so that requests + * The violations are recorded and available by API such as {@link #hasAmbiguousSegment()} so that requests * containing them may be rejected in case the non-standard-but-non-ambiguous interpretations - * are not satisfactory for a given compliance configuration. Implementations that wish to - * process ambiguous URI paths must configure the compliance modes to accept them and then perform - * their own decoding of {@link #getPath()}. + * are not satisfactory for a given compliance configuration. + * </p> + * <p> + * Implementations that wish to process ambiguous URI paths must configure the compliance modes + * to accept them and then perform their own decoding of {@link #getPath()}. * </p> * <p> * If there are multiple path parameters, only the last one is returned by {@link #getParam()}. @@ -95,30 +97,30 @@ private enum State /** * Violations of safe URI interpretations */ - public enum Violation + enum Violation { /** - * Ambiguous path segments e.g. <code>/foo/%2E%2E/bar</code> + * Ambiguous path segments e.g. {@code /foo/%2E%2E/bar} */ SEGMENT("Ambiguous path segments"), /** - * Ambiguous path separator within a URI segment e.g. <code>/foo%2Fbar</code> + * Ambiguous path separator within a URI segment e.g. {@code /foo%2Fbar} */ SEPARATOR("Ambiguous path separator"), /** - * Ambiguous path parameters within a URI segment e.g. <code>/foo/..;/bar</code> + * Ambiguous path parameters within a URI segment e.g. {@code /foo/..;/bar} */ PARAM("Ambiguous path parameters"), /** - * Ambiguous double encoding within a URI segment e.g. <code>/%2557EB-INF</code> + * Ambiguous double encoding within a URI segment e.g. {@code /%2557EB-INF} */ ENCODING("Ambiguous double encoding"), /** - * Ambiguous empty segments e.g. <code>/foo//bar</code> + * Ambiguous empty segments e.g. {@code /foo//bar} */ EMPTY("Ambiguous empty segments"), /** - * Non standard UTF-16 encoding eg <code>/foo%u2192bar</code>. + * Non standard UTF-16 encoding eg {@code /foo%u2192bar}. */ UTF16("Non standard UTF-16 encoding"); @@ -338,7 +340,7 @@ private void parse(State state, final String uri, final int offset, final int en int encodedValue = 0; // the partial encoded value boolean dot = false; // set to true if the path contains . or .. segments - for (int i = 0; i < end; i++) + for (int i = offset; i < end; i++) { char c = uri.charAt(i); @@ -567,6 +569,8 @@ else if (c == '/') switch (encodedValue) { case 0: + // Byte 0 cannot be present in a UTF-8 sequence in any position + // other than as the NUL ASCII byte which we do not wish to allow. throw new IllegalArgumentException("Illegal character in path"); case '/': _violations.add(Violation.SEPARATOR); @@ -653,26 +657,11 @@ else if (c == '/') } case QUERY: { - switch (c) + if (c == '#') { - case '%': - encodedCharacters = 2; - break; - case 'u': - case 'U': - if (encodedCharacters == 1) - _violations.add(Violation.UTF16); - encodedCharacters = 0; - break; - case '#': - _query = uri.substring(mark, i); - mark = i + 1; - state = State.FRAGMENT; - encodedCharacters = 0; - break; - default: - encodedCharacters = 0; - break; + _query = uri.substring(mark, i); + mark = i + 1; + state = State.FRAGMENT; } break; } @@ -687,7 +676,9 @@ else if (c == '/') break; } default: + { throw new IllegalStateException(state.toString()); + } } } @@ -741,8 +732,8 @@ else if (_path != null) { // The RFC requires this to be canonical before decoding, but this can leave dot segments and dot dot segments // which are not canonicalized and could be used in an attempt to bypass security checks. - String decodeNonCanonical = URIUtil.decodePath(_path); - _decodedPath = URIUtil.canonicalPath(decodeNonCanonical); + String decodedNonCanonical = URIUtil.decodePath(_path); + _decodedPath = URIUtil.canonicalPath(decodedNonCanonical); if (_decodedPath == null) throw new IllegalArgumentException("Bad URI"); } @@ -763,7 +754,8 @@ private void checkSegment(String uri, int segment, int end, boolean param) // This method is called once for every segment parsed. // A URI like "/foo/" has two segments: "foo" and an empty segment. // Empty segments are only ambiguous if they are not the last segment - // So if this method is called for any segment and we have previously seen an empty segment, then it was ambiguous + // So if this method is called for any segment and we have previously + // seen an empty segment, then it was ambiguous. if (_emptySegment) _violations.add(Violation.EMPTY); @@ -843,7 +835,8 @@ public boolean hasAmbiguousEncoding() } /** - * @return True if the URI has either an {@link #hasAmbiguousSegment()} or {@link #hasAmbiguousSeparator()}. + * @return True if the URI has either an {@link #hasAmbiguousSegment()} or {@link #hasAmbiguousEmptySegment()} + * or {@link #hasAmbiguousSeparator()} or {@link #hasAmbiguousParameter()} */ public boolean isAmbiguous() { @@ -858,7 +851,7 @@ public boolean hasViolations() return !_violations.isEmpty(); } - public boolean hasViolation(Violation violation) + boolean hasViolation(Violation violation) { return _violations.contains(violation); } diff --git a/jetty-http/src/test/java/org/eclipse/jetty/http/HttpURITest.java b/jetty-http/src/test/java/org/eclipse/jetty/http/HttpURITest.java index 2489c75cf34e..28be9c41f0ba 100644 --- a/jetty-http/src/test/java/org/eclipse/jetty/http/HttpURITest.java +++ b/jetty-http/src/test/java/org/eclipse/jetty/http/HttpURITest.java @@ -322,6 +322,7 @@ public static Stream<Arguments> decodePathTests() {"/f%6f%6F/bar", "/foo/bar", EnumSet.noneOf(Violation.class)}, {"/f%u006f%u006F/bar", "/foo/bar", EnumSet.of(Violation.UTF16)}, {"/f%u0001%u0001/bar", "/f\001\001/bar", EnumSet.of(Violation.UTF16)}, + {"/foo/%u20AC/bar", "/foo/\u20AC/bar", EnumSet.of(Violation.UTF16)}, // illegal paths {"//host/../path/info", null, EnumSet.noneOf(Violation.class)}, @@ -333,6 +334,9 @@ public static Stream<Arguments> decodePathTests() {"/path/%u000X/info", null, EnumSet.noneOf(Violation.class)}, {"/path/Fo%u0000/info", null, EnumSet.noneOf(Violation.class)}, {"/path/Fo%00/info", null, EnumSet.noneOf(Violation.class)}, + {"/path/Foo/info%u0000", null, EnumSet.noneOf(Violation.class)}, + {"/path/Foo/info%00", null, EnumSet.noneOf(Violation.class)}, + {"/path/%U20AC", null, EnumSet.noneOf(Violation.class)}, {"%2e%2e/info", null, EnumSet.noneOf(Violation.class)}, {"%u002e%u002e/info", null, EnumSet.noneOf(Violation.class)}, {"%2e%2e;/info", null, EnumSet.noneOf(Violation.class)}, @@ -769,4 +773,20 @@ public void testCompareToJavaNetURI(String input, String scheme, String host, In assertThat("[" + input + "] .fragment", httpUri.getFragment(), is(javaUri.getFragment())); assertThat("[" + input + "] .toString", httpUri.toString(), is(javaUri.toASCIIString())); } + + public static Stream<Arguments> queryData() + { + return Stream.of( + new String[]{"/path?p=%U20AC", "p=%U20AC"}, + new String[]{"/path?p=%u20AC", "p=%u20AC"} + ).map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("queryData") + public void testEncodedQuery(String input, String expectedQuery) + { + HttpURI httpURI = new HttpURI(input); + assertThat("[" + input + "] .query", httpURI.getQuery(), is(expectedQuery)); + } } diff --git a/jetty-maven-plugin/src/main/java/org/eclipse/jetty/maven/plugin/JettyWebAppContext.java b/jetty-maven-plugin/src/main/java/org/eclipse/jetty/maven/plugin/JettyWebAppContext.java index d2efd9018989..8108b9d2ac21 100644 --- a/jetty-maven-plugin/src/main/java/org/eclipse/jetty/maven/plugin/JettyWebAppContext.java +++ b/jetty-maven-plugin/src/main/java/org/eclipse/jetty/maven/plugin/JettyWebAppContext.java @@ -39,6 +39,7 @@ import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlet.ServletMapping; import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.URIUtil; import org.eclipse.jetty.util.log.Log; import org.eclipse.jetty.util.log.Logger; import org.eclipse.jetty.util.resource.Resource; @@ -450,12 +451,17 @@ public Resource getResource(String uriInContext) throws MalformedURLException // If no regular resource exists check for access to /WEB-INF/lib or /WEB-INF/classes if ((resource == null || !resource.exists()) && uriInContext != null && _classes != null) { + // Canonicalize again to look for the resource inside /WEB-INF subdirectories. + String uri = URIUtil.canonicalPath(uriInContext); + if (uri == null) + return null; + try { // Replace /WEB-INF/classes with candidates for the classpath - if (uriInContext.startsWith(WEB_INF_CLASSES_PREFIX)) + if (uri.startsWith(WEB_INF_CLASSES_PREFIX)) { - if (uriInContext.equalsIgnoreCase(WEB_INF_CLASSES_PREFIX) || uriInContext.equalsIgnoreCase(WEB_INF_CLASSES_PREFIX + "/")) + if (uri.equalsIgnoreCase(WEB_INF_CLASSES_PREFIX) || uri.equalsIgnoreCase(WEB_INF_CLASSES_PREFIX + "/")) { //exact match for a WEB-INF/classes, so preferentially return the resource matching the web-inf classes //rather than the test classes @@ -471,7 +477,7 @@ else if (_testClasses != null) int i = 0; while (res == null && (i < _webInfClasses.size())) { - String newPath = StringUtil.replace(uriInContext, WEB_INF_CLASSES_PREFIX, _webInfClasses.get(i).getPath()); + String newPath = StringUtil.replace(uri, WEB_INF_CLASSES_PREFIX, _webInfClasses.get(i).getPath()); res = Resource.newResource(newPath); if (!res.exists()) { @@ -482,11 +488,11 @@ else if (_testClasses != null) return res; } } - else if (uriInContext.startsWith(WEB_INF_LIB_PREFIX)) + else if (uri.startsWith(WEB_INF_LIB_PREFIX)) { // Return the real jar file for all accesses to // /WEB-INF/lib/*.jar - String jarName = StringUtil.strip(uriInContext, WEB_INF_LIB_PREFIX); + String jarName = StringUtil.strip(uri, WEB_INF_LIB_PREFIX); if (jarName.startsWith("/") || jarName.startsWith("\\")) jarName = jarName.substring(1); if (jarName.length() == 0) diff --git a/jetty-rewrite/src/main/java/org/eclipse/jetty/rewrite/handler/RedirectUtil.java b/jetty-rewrite/src/main/java/org/eclipse/jetty/rewrite/handler/RedirectUtil.java index 4754173ca315..43347ca26196 100644 --- a/jetty-rewrite/src/main/java/org/eclipse/jetty/rewrite/handler/RedirectUtil.java +++ b/jetty-rewrite/src/main/java/org/eclipse/jetty/rewrite/handler/RedirectUtil.java @@ -53,12 +53,12 @@ public static String toRedirectURL(final HttpServletRequest request, String loca String path = request.getRequestURI(); String parent = (path.endsWith("/")) ? path : URIUtil.parentPath(path); location = URIUtil.canonicalURI(URIUtil.addEncodedPaths(parent, location)); - if (!location.startsWith("/")) + if (location != null && !location.startsWith("/")) url.append('/'); } if (location == null) - throw new IllegalStateException("path cannot be above root"); + throw new IllegalStateException("redirect path cannot be above root"); url.append(location); location = url.toString(); diff --git a/jetty-rewrite/src/test/java/org/eclipse/jetty/rewrite/handler/ValidUrlRuleTest.java b/jetty-rewrite/src/test/java/org/eclipse/jetty/rewrite/handler/ValidUrlRuleTest.java index fff775784b97..b060f29d13ee 100644 --- a/jetty-rewrite/src/test/java/org/eclipse/jetty/rewrite/handler/ValidUrlRuleTest.java +++ b/jetty-rewrite/src/test/java/org/eclipse/jetty/rewrite/handler/ValidUrlRuleTest.java @@ -24,6 +24,7 @@ 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; @SuppressWarnings("unused") @@ -75,6 +76,12 @@ public void testInvalidUrlWithReason() throws Exception @Test public void testInvalidJsp() throws Exception + { + assertThrows(IllegalArgumentException.class, () -> _request.setURIPathQuery("/jsp/bean1.jsp%00")); + } + + @Test + public void testInvalidJspWithNullByte() throws Exception { _rule.setCode("405"); _rule.setReason("foo"); @@ -87,6 +94,12 @@ public void testInvalidJsp() throws Exception assertEquals("foo", _response.getReason()); } + @Test + public void testInvalidShamrock() throws Exception + { + assertThrows(IllegalArgumentException.class, () -> _request.setURIPathQuery("/jsp/shamrock-%00%E2%98%98.jsp")); + } + @Test public void testValidShamrock() throws Exception { @@ -110,4 +123,3 @@ public void testCharacters() throws Exception //@checkstyle-enable-check : IllegalTokenText } } - diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java b/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java index c7c93a6fab66..97ef7e7442cd 100644 --- a/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java +++ b/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java @@ -1942,13 +1942,11 @@ public Resource getResource(String path) throws MalformedURLException if (_baseResource == null) return null; - // Does the path go above the current scope? - path = URIUtil.canonicalPath(path); - if (path == null) - return null; - try { + // addPath with accept non-canonical paths that don't go above the root, + // but will treat them as aliases. So unless allowed by an AliasChecker + // they will be rejected below. Resource resource = _baseResource.addPath(path); if (checkAlias(path, resource)) @@ -2133,9 +2131,6 @@ public ContextHandler getContextHandler() return ContextHandler.this; } - /* - * @see javax.servlet.ServletContext#getContext(java.lang.String) - */ @Override public ServletContext getContext(String uripath) { @@ -2224,9 +2219,6 @@ public ServletContext getContext(String uripath) return null; } - /* - * @see javax.servlet.ServletContext#getMimeType(java.lang.String) - */ @Override public String getMimeType(String file) { @@ -2235,9 +2227,6 @@ public String getMimeType(String file) return _mimeTypes.getMimeByExtension(file); } - /* - * @see javax.servlet.ServletContext#getRequestDispatcher(java.lang.String) - */ @Override public RequestDispatcher getRequestDispatcher(String uriInContext) { @@ -2250,6 +2239,7 @@ public RequestDispatcher getRequestDispatcher(String uriInContext) try { + // The uriInContext will be canonicalized by HttpURI. HttpURI uri = new HttpURI(null, null, 0, uriInContext); String pathInfo = uri.getDecodedPath(); String contextPath = getContextPath(); @@ -2265,12 +2255,13 @@ public RequestDispatcher getRequestDispatcher(String uriInContext) return null; } - /* - * @see javax.servlet.ServletContext#getRealPath(java.lang.String) - */ @Override public String getRealPath(String path) { + // This is an API call from the application which may have arbitrary non canonical paths passed + // Thus we canonicalize here, to avoid the enforcement of only canonical paths in + // ContextHandler.this.getResource(path). + path = URIUtil.canonicalPath(path); if (path == null) return null; if (path.length() == 0) @@ -2299,6 +2290,10 @@ else if (path.charAt(0) != '/') @Override public URL getResource(String path) throws MalformedURLException { + // This is an API call from the application which may have arbitrary non canonical paths passed + // Thus we canonicalize here, to avoid the enforcement of only canonical paths in + // ContextHandler.this.getResource(path). + path = URIUtil.canonicalPath(path); if (path == null) return null; Resource resource = ContextHandler.this.getResource(path); @@ -2307,9 +2302,6 @@ public URL getResource(String path) throws MalformedURLException return null; } - /* - * @see javax.servlet.ServletContext#getResourceAsStream(java.lang.String) - */ @Override public InputStream getResourceAsStream(String path) { @@ -2331,65 +2323,48 @@ public InputStream getResourceAsStream(String path) } } - /* - * @see javax.servlet.ServletContext#getResourcePaths(java.lang.String) - */ @Override public Set<String> getResourcePaths(String path) { + // This is an API call from the application which may have arbitrary non canonical paths passed + // Thus we canonicalize here, to avoid the enforcement of only canonical paths in + // ContextHandler.this.getResource(path). + path = URIUtil.canonicalPath(path); if (path == null) return null; return ContextHandler.this.getResourcePaths(path); } - /* - * @see javax.servlet.ServletContext#log(java.lang.Exception, java.lang.String) - */ @Override public void log(Exception exception, String msg) { _logger.warn(msg, exception); } - /* - * @see javax.servlet.ServletContext#log(java.lang.String) - */ @Override public void log(String msg) { _logger.info(msg); } - /* - * @see javax.servlet.ServletContext#log(java.lang.String, java.lang.Throwable) - */ @Override public void log(String message, Throwable throwable) { _logger.warn(message, throwable); } - /* - * @see javax.servlet.ServletContext#getInitParameter(java.lang.String) - */ @Override public String getInitParameter(String name) { return ContextHandler.this.getInitParameter(name); } - /* - * @see javax.servlet.ServletContext#getInitParameterNames() - */ @Override public Enumeration<String> getInitParameterNames() { return ContextHandler.this.getInitParameterNames(); } - /* - * @see javax.servlet.ServletContext#getAttribute(java.lang.String) - */ @Override public Object getAttribute(String name) { @@ -2399,9 +2374,6 @@ public Object getAttribute(String name) return o; } - /* - * @see javax.servlet.ServletContext#getAttributeNames() - */ @Override public Enumeration<String> getAttributeNames() { @@ -2420,9 +2392,6 @@ public Enumeration<String> getAttributeNames() return Collections.enumeration(set); } - /* - * @see javax.servlet.ServletContext#setAttribute(java.lang.String, java.lang.Object) - */ @Override public void setAttribute(String name, Object value) { @@ -2449,9 +2418,6 @@ else if (value == null) } } - /* - * @see javax.servlet.ServletContext#removeAttribute(java.lang.String) - */ @Override public void removeAttribute(String name) { @@ -2468,9 +2434,6 @@ public void removeAttribute(String name) } } - /* - * @see javax.servlet.ServletContext#getServletContextName() - */ @Override public String getServletContextName() { diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ResourceHandler.java b/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ResourceHandler.java index 43709a297223..e98f20410d3a 100644 --- a/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ResourceHandler.java +++ b/jetty-server/src/main/java/org/eclipse/jetty/server/handler/ResourceHandler.java @@ -187,7 +187,9 @@ public Resource getResource(String path) } } else if (_context != null) + { r = _context.getResource(path); + } if ((r == null || !r.exists()) && path.endsWith("/jetty-dir.css")) r = getStylesheet(); diff --git a/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java b/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java index 9ce41675f850..29ab15308e75 100644 --- a/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java +++ b/jetty-server/src/test/java/org/eclipse/jetty/server/HttpConnectionTest.java @@ -829,6 +829,12 @@ public void testBadUTF8FallsbackTo8859() throws Exception Log.getLogger(HttpParser.class).info("badMessage: bad encoding expected ..."); String response; + response = connector.getResponse("GET /foo/bar%c0%00 HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Connection: close\r\n" + + "\r\n"); + checkContains(response, 0, "HTTP/1.1 400"); + response = connector.getResponse("GET /bad/utf8%c1 HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: close\r\n" + diff --git a/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerGetResourceTest.java b/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerGetResourceTest.java index f3b26d86fd07..a82c14a07e8d 100644 --- a/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerGetResourceTest.java +++ b/jetty-server/src/test/java/org/eclipse/jetty/server/handler/ContextHandlerGetResourceTest.java @@ -235,16 +235,23 @@ public void testDoesNotExistResource() throws Exception @Test public void testAlias() throws Exception { - Resource resource = context.getResource("/./index.html"); - assertNotNull(resource); - assertFalse(resource.isAlias()); + String path = "/./index.html"; + Resource resource = context.getResource(path); + assertNull(resource); + URL resourceURL = context.getServletContext().getResource(path); + assertFalse(resourceURL.getPath().contains("/./")); - resource = context.getResource("/down/../index.html"); - assertNotNull(resource); - assertFalse(resource.isAlias()); + path = "/down/../index.html"; + resource = context.getResource(path); + assertNull(resource); + resourceURL = context.getServletContext().getResource(path); + assertFalse(resourceURL.getPath().contains("/../")); - resource = context.getResource("//index.html"); + path = "//index.html"; + resource = context.getResource(path); assertNull(resource); + resourceURL = context.getServletContext().getResource(path); + assertNull(resourceURL); } @ParameterizedTest diff --git a/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/RequestURITest.java b/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/RequestURITest.java index 02ac7ebaedb1..27f02260f649 100644 --- a/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/RequestURITest.java +++ b/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/RequestURITest.java @@ -74,7 +74,8 @@ public static Stream<Arguments> data() ret.add(Arguments.of("/hello%u0025world", "/hello%u0025world", null)); ret.add(Arguments.of("/hello-euro-%E2%82%AC", "/hello-euro-%E2%82%AC", null)); ret.add(Arguments.of("/hello-euro?%E2%82%AC", "/hello-euro", "%E2%82%AC")); - // test the ascii control characters (just for completeness) + // Test the ascii control characters (just for completeness). + // Zero is not allowed in UTF-8 sequences so start from 1. for (int i = 0x1; i < 0x1f; i++) { String raw = String.format("/hello%%%02Xworld", i); @@ -198,7 +199,6 @@ public void testGetRequestURIHTTP10(String rawpath, String expectedReqUri, Strin // Read the response. String response = readResponse(client); - // TODO: is HTTP/1.1 response appropriate for an HTTP/1.0 request? assertThat(response, Matchers.containsString("HTTP/1.1 200 OK")); assertThat(response, Matchers.containsString("RequestURI: " + expectedReqUri)); assertThat(response, Matchers.containsString("QueryString: " + expectedQuery)); @@ -225,4 +225,45 @@ public void testGetRequestURIHTTP11(String rawpath, String expectedReqUri, Strin assertThat(response, Matchers.containsString("QueryString: " + expectedQuery)); } } + + public static Stream<Arguments> badData() + { + List<Arguments> ret = new ArrayList<>(); + ret.add(Arguments.of("/hello\000")); + ret.add(Arguments.of("/hello%00")); + ret.add(Arguments.of("/hello%u0000")); + ret.add(Arguments.of("/hello\000/world")); + ret.add(Arguments.of("/hello%00world")); + ret.add(Arguments.of("/hello%u0000world")); + ret.add(Arguments.of("/hello%GG")); + ret.add(Arguments.of("/hello%;/world")); + ret.add(Arguments.of("/hello/../../world")); + ret.add(Arguments.of("/hello/..;/world")); + ret.add(Arguments.of("/hello/..;?/world")); + ret.add(Arguments.of("/hello/%#x/../world")); + ret.add(Arguments.of("/../hello/world")); + ret.add(Arguments.of("/hello%u00u00/world")); + ret.add(Arguments.of("hello")); + + return ret.stream(); + } + + @ParameterizedTest + @MethodSource("badData") + public void testGetBadRequestsURIHTTP10(String rawpath) throws Exception + { + try (Socket client = newSocket(serverURI.getHost(), serverURI.getPort())) + { + OutputStream os = client.getOutputStream(); + + String request = String.format("GET %s HTTP/1.0\r\n\r\n", rawpath); + os.write(request.getBytes(StandardCharsets.ISO_8859_1)); + os.flush(); + + // Read the response. + String response = readResponse(client); + + assertThat(response, Matchers.containsString("HTTP/1.1 400 ")); + } + } } diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/URIUtil.java b/jetty-util/src/main/java/org/eclipse/jetty/util/URIUtil.java index 3c6f7a53d343..c078fc135061 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/URIUtil.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/URIUtil.java @@ -537,7 +537,6 @@ public static String decodePath(String path, int offset, int length) { throw new IllegalArgumentException("cannot decode URI", e); } - } /* Decode a URI path and strip parameters of ISO-8859-1 path @@ -790,7 +789,6 @@ public static String parentPath(String p) * @param uri the encoded URI from the path onwards, which may contain query strings and/or fragments * @return the canonical path, or null if path traversal above root. * @see #canonicalPath(String) - * @see #canonicalURI(String) */ public static String canonicalURI(String uri) { @@ -890,6 +888,17 @@ else if (slash) return canonical.toString(); } + /** + * @param path the encoded URI from the path onwards, which may contain query strings and/or fragments + * @return the canonical path, or null if path traversal above root. + * @deprecated Use {@link #canonicalURI(String)} + */ + @Deprecated + public static String canonicalEncodedPath(String path) + { + return canonicalURI(path); + } + /** * Convert a decoded URI path to a canonical form. * <p> diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/FileResource.java b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/FileResource.java index 40ead6f17972..c3d77b329c42 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/FileResource.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/FileResource.java @@ -270,8 +270,11 @@ public Resource addPath(String path) { assertValidPath(path); - if (path == null) - throw new MalformedURLException(); + // Check that the path is within the root, + // but use the original path to create the + // resource, to preserve aliasing. + if (URIUtil.canonicalPath(path) == null) + throw new MalformedURLException(path); if ("/".equals(path)) return this; diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/PathResource.java b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/PathResource.java index 22f936a62cfd..3c58739016b6 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/PathResource.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/PathResource.java @@ -94,8 +94,11 @@ private Path checkAliasPath() if (!abs.isAbsolute()) abs = path.toAbsolutePath(); + // Any normalization difference means it's an alias, + // and we don't want to bother further to follow + // symlinks as it's an alias anyway. Path normal = path.normalize(); - if (!abs.equals(normal)) + if (!isSameName(abs, normal)) return normal; try @@ -105,11 +108,8 @@ private Path checkAliasPath() if (Files.exists(path)) { Path real = abs.toRealPath(FOLLOW_LINKS); - if (!isSameName(abs, real)) - { return real; - } } } catch (IOException e) @@ -361,20 +361,23 @@ public boolean isSame(Resource resource) } @Override - public Resource addPath(final String subpath) throws IOException + public Resource addPath(final String subPath) throws IOException { - if ((subpath == null) || (subpath.length() == 0)) - throw new MalformedURLException(subpath); + // Check that the path is within the root, + // but use the original path to create the + // resource, to preserve aliasing. + if (URIUtil.canonicalPath(subPath) == null) + throw new MalformedURLException(subPath); - if ("/".equals(subpath)) + if ("/".equals(subPath)) return this; - // subpaths are always under PathResource - // compensate for input subpaths like "/subdir" + // Sub-paths are always under PathResource + // compensate for input sub-paths like "/subdir" // where default resolve behavior would be // to treat that like an absolute path - return new PathResource(this, subpath); + return new PathResource(this, subPath); } private void assertValidPath(Path path) diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java index dc443becffb3..d3a32b5d69fc 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/Resource.java @@ -459,10 +459,12 @@ public abstract boolean renameTo(Resource dest) * Returns the resource contained inside the current resource with the * given name. * - * @param path The path segment to add, which is not encoded + * @param path The path segment to add, which is not encoded. The path may be non canonical, but if so then + * the resulting Resource will return true from {@link #isAlias()}. * @return the Resource for the resolved path within this Resource. * @throws IOException if unable to resolve the path - * @throws MalformedURLException if the resolution of the path fails because the input path parameter is malformed. + * @throws MalformedURLException if the resolution of the path fails because the input path parameter is malformed, or + * a relative path attempts to access above the root resource. */ public abstract Resource addPath(String path) throws IOException, MalformedURLException; @@ -555,6 +557,8 @@ public String getListHTML(String base, boolean parent) throws IOException */ public String getListHTML(String base, boolean parent, String query) throws IOException { + // This method doesn't check aliases, so it is OK to canonicalize here. + base = URIUtil.canonicalPath(base); if (base == null || !isDirectory()) return null; diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResource.java b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResource.java index 054f4d0d5ab6..40d68abdf56c 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResource.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/resource/URLResource.java @@ -271,8 +271,11 @@ public String[] list() public Resource addPath(String path) throws IOException { - if (path == null) - throw new MalformedURLException("null path"); + // Check that the path is within the root, + // but use the original path to create the + // resource, to preserve aliasing. + if (URIUtil.canonicalPath(path) == null) + throw new MalformedURLException(path); return newResource(URIUtil.addEncodedPaths(_url.toExternalForm(), URIUtil.encodePath(path)), _useCaches); } diff --git a/jetty-util/src/test/java/org/eclipse/jetty/util/URIUtilCanonicalPathTest.java b/jetty-util/src/test/java/org/eclipse/jetty/util/URIUtilCanonicalPathTest.java index c40ff6a83c7e..a52a64444d2a 100644 --- a/jetty-util/src/test/java/org/eclipse/jetty/util/URIUtilCanonicalPathTest.java +++ b/jetty-util/src/test/java/org/eclipse/jetty/util/URIUtilCanonicalPathTest.java @@ -151,7 +151,9 @@ public void testCanonicalPath(String input, String expectedResult) // Check canonicalURI if (expectedResult == null) + { assertThat(URIUtil.canonicalURI(input), nullValue()); + } else { // mostly encodedURI will be the same @@ -162,4 +164,22 @@ public void testCanonicalPath(String input, String expectedResult) } } + public static Stream<Arguments> queries() + { + String[][] data = + { + {"/ctx/../dir?/../index.html", "/dir?/../index.html"}, + {"/get-files?file=/etc/passwd", "/get-files?file=/etc/passwd"}, + {"/get-files?file=../../../../../passwd", "/get-files?file=../../../../../passwd"} + }; + return Stream.of(data).map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("queries") + public void testQuery(String input, String expectedPath) + { + String actual = URIUtil.canonicalURI(input); + assertThat(actual, is(expectedPath)); + } } diff --git a/jetty-util/src/test/java/org/eclipse/jetty/util/resource/ResourceTest.java b/jetty-util/src/test/java/org/eclipse/jetty/util/resource/ResourceTest.java index c93dc731ed74..5d7c7b29dc5a 100644 --- a/jetty-util/src/test/java/org/eclipse/jetty/util/resource/ResourceTest.java +++ b/jetty-util/src/test/java/org/eclipse/jetty/util/resource/ResourceTest.java @@ -21,6 +21,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.nio.file.Path; @@ -45,6 +46,8 @@ import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class ResourceTest { @@ -325,4 +328,19 @@ public void testEqualsWindowsCaseInsensitiveDrive() throws Exception assertEquals(rb, ra); } + + @Test + public void testClimbAboveBase() throws Exception + { + Resource resource = Resource.newResource("/foo/bar"); + assertThrows(MalformedURLException.class, () -> resource.addPath("..")); + + Resource same = resource.addPath("."); + assertNotNull(same); + assertTrue(same.isAlias()); + + assertThrows(MalformedURLException.class, () -> resource.addPath("./..")); + + assertThrows(MalformedURLException.class, () -> resource.addPath("./../bar")); + } }