From 88717a3533806b981cf6633744cc186b48aa12ed Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 06:03:46 +0100 Subject: [PATCH 1/5] added better directory checking --- .../fusionauth/http/server/HTTPContext.java | 27 +- .../http/server/HTTPContextTest.java | 239 ++++++++++++++++++ 2 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/fusionauth/http/server/HTTPContextTest.java diff --git a/src/main/java/io/fusionauth/http/server/HTTPContext.java b/src/main/java/io/fusionauth/http/server/HTTPContext.java index 6c1ab99f..6d61979e 100644 --- a/src/main/java/io/fusionauth/http/server/HTTPContext.java +++ b/src/main/java/io/fusionauth/http/server/HTTPContext.java @@ -61,6 +61,9 @@ public Map getAttributes() { /** * Attempts to retrieve a file or classpath resource at the given path. If the path is invalid, this will return null. If the classpath is * borked or the path somehow cannot be converted to a URL, then this throws an exception. + *

+ * This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir. + * Attempts to escape the baseDir using sequences like {@code ../} will cause this method to return null. * * @param path The path. * @return The URL to the resource or null. @@ -74,7 +77,13 @@ public URL getResource(String path) throws IllegalStateException { } try { - Path resolved = baseDir.resolve(filePath); + Path resolved = baseDir.resolve(filePath).normalize(); + + // Security: Verify the resolved path stays within baseDir to prevent path traversal attacks + if (!resolved.startsWith(baseDir.normalize())) { + return null; + } + if (Files.exists(resolved)) { return resolved.toUri().toURL(); } @@ -98,17 +107,27 @@ public Object removeAttribute(String name) { } /** - * Locates the path given the webapps baseDir (passed into the constructor. + * Locates the path given the webapps baseDir (passed into the constructor). + *

+ * This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir. + * Attempts to escape the baseDir using sequences like {@code ../} will return null. * * @param appPath The app path to a resource (like an FTL file). - * @return The resolved path, which is almost always just the baseDir plus the appPath with a file separator in the middle. + * @return The resolved path, or null if the path attempts to escape the baseDir. */ public Path resolve(String appPath) { if (appPath.startsWith("/")) { appPath = appPath.substring(1); } - return baseDir.resolve(appPath); + Path resolved = baseDir.resolve(appPath).normalize(); + + // Security: Verify the resolved path stays within baseDir to prevent path traversal attacks + if (!resolved.startsWith(baseDir.normalize())) { + return null; + } + + return resolved; } /** diff --git a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java new file mode 100644 index 00000000..5fd1e87f --- /dev/null +++ b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2022-2025, FusionAuth, All Rights Reserved + * + * 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 io.fusionauth.http.server; + +import java.io.IOException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Tests for HTTPContext focusing on path traversal security and resource resolution. + *

+ * These tests verify that HTTPContext properly prevents path traversal attacks as described in: + * - CVE-2019-19781 (Citrix path traversal) + * - Blog post: https://blog.dochia.dev/blog/http_edge_cases/ + * + * @author FusionAuth + */ +public class HTTPContextTest { + private Path tempDir; + + private HTTPContext context; + + @BeforeMethod + public void setup() throws IOException { + // Create a temporary directory structure for testing + tempDir = Files.createTempDirectory("http-context-test"); + + // Create legitimate test files + Files.writeString(tempDir.resolve("index.html"), "Index"); + + Path cssDir = Files.createDirectory(tempDir.resolve("css")); + Files.writeString(cssDir.resolve("style.css"), "body { color: blue; }"); + + Path subDir = Files.createDirectory(tempDir.resolve("subdir")); + Files.writeString(subDir.resolve("file.txt"), "Legitimate file"); + + // Create file outside the baseDir to test traversal attempts + Path parentDir = tempDir.getParent(); + Files.writeString(parentDir.resolve("secret.txt"), "Secret data"); + + context = new HTTPContext(tempDir); + } + + @AfterMethod + public void teardown() throws IOException { + // Cleanup temp files + if (tempDir != null && Files.exists(tempDir)) { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) // Delete files before directories + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // Ignore cleanup errors + } + }); + } + + // Cleanup secret file from parent + Path secretFile = tempDir.getParent().resolve("secret.txt"); + Files.deleteIfExists(secretFile); + } + + /** + * Test that legitimate file paths work correctly. + */ + @Test + public void testLegitimatePathsSucceed() throws Exception { + // Test root level file + URL indexUrl = context.getResource("index.html"); + assertNotNull(indexUrl, "Should resolve index.html"); + assertTrue(indexUrl.toString().contains("index.html")); + + // Test subdirectory file + URL cssUrl = context.getResource("css/style.css"); + assertNotNull(cssUrl, "Should resolve css/style.css"); + assertTrue(cssUrl.toString().contains("style.css")); + + // Test with leading slash (should be stripped) + URL slashUrl = context.getResource("/css/style.css"); + assertNotNull(slashUrl, "Should resolve /css/style.css"); + assertTrue(slashUrl.toString().contains("style.css")); + + // Test nested path + URL subdirUrl = context.getResource("subdir/file.txt"); + assertNotNull(subdirUrl, "Should resolve subdir/file.txt"); + assertTrue(subdirUrl.toString().contains("file.txt")); + } + + /** + * Test path traversal attack using ../ sequences (CVE-2019-19781 style). + * These attacks attempt to escape the baseDir and access parent directories. + */ + @Test + public void testPathTraversalAttacksBlocked() { + // Simple parent directory traversal + URL result1 = context.getResource("../secret.txt"); + assertNull(result1, "Should block ../secret.txt"); + + // Multiple parent traversals + URL result2 = context.getResource("../../etc/passwd"); + assertNull(result2, "Should block ../../etc/passwd"); + + // Traversal with valid path prefix + URL result3 = context.getResource("css/../../secret.txt"); + assertNull(result3, "Should block css/../../secret.txt"); + + // Deep traversal + URL result4 = context.getResource("subdir/../../secret.txt"); + assertNull(result4, "Should block subdir/../../secret.txt"); + + // Many parent directory references + URL result5 = context.getResource("../../../../../../../../../etc/passwd"); + assertNull(result5, "Should block ../../../../../../../../../etc/passwd"); + } + + /** + * Test URL-encoded path traversal attacks. + * Attackers often URL-encode the ../ sequences to bypass naive filters. + */ + @Test + public void testUrlEncodedTraversalBlocked() { + // URL-encoded ../ is %2e%2e%2f + URL result1 = context.getResource("%2e%2e%2fsecret.txt"); + assertNull(result1, "Should block URL-encoded traversal %2e%2e%2fsecret.txt"); + + URL result2 = context.getResource("%2e%2e%2f%2e%2e%2fsecret.txt"); + assertNull(result2, "Should block %2e%2e%2f%2e%2e%2fsecret.txt"); + + // Mixed encoded and plain + URL result3 = context.getResource("css/%2e%2e%2f%2e%2e%2fsecret.txt"); + assertNull(result3, "Should block css/%2e%2e%2f%2e%2e%2fsecret.txt"); + } + + /** + * Test that resolve() method also prevents path traversal. + */ + @Test + public void testResolvePathTraversalBlocked() { + // Simple parent directory traversal + Path result1 = context.resolve("../secret.txt"); + assertNull(result1, "Should block ../secret.txt in resolve()"); + + // Multiple parent traversals + Path result2 = context.resolve("../../etc/passwd"); + assertNull(result2, "Should block ../../etc/passwd in resolve()"); + + // Traversal with valid path prefix + Path result3 = context.resolve("css/../../secret.txt"); + assertNull(result3, "Should block css/../../secret.txt in resolve()"); + } + + /** + * Test that resolve() works correctly for legitimate paths. + */ + @Test + public void testResolveLegitimatePathsSucceed() { + // Test root level file + Path indexPath = context.resolve("index.html"); + assertNotNull(indexPath, "Should resolve index.html"); + assertEquals(indexPath, tempDir.resolve("index.html")); + + // Test subdirectory file + Path cssPath = context.resolve("css/style.css"); + assertNotNull(cssPath, "Should resolve css/style.css"); + assertEquals(cssPath, tempDir.resolve("css/style.css")); + + // Test with leading slash + Path slashPath = context.resolve("/css/style.css"); + assertNotNull(slashPath, "Should resolve /css/style.css"); + assertEquals(slashPath, tempDir.resolve("css/style.css")); + } + + /** + * Test edge case: path that goes down then up but stays within baseDir. + * For example: "subdir/../index.html" should resolve to "index.html" + */ + @Test + public void testNormalizedPathWithinBaseDirSucceeds() { + // This path traverses up but stays within baseDir after normalization + URL result = context.getResource("subdir/../index.html"); + assertNotNull(result, "Should allow subdir/../index.html as it normalizes to index.html"); + assertTrue(result.toString().contains("index.html")); + + Path resolved = context.resolve("subdir/../index.html"); + assertNotNull(resolved, "Should resolve subdir/../index.html"); + assertEquals(resolved, tempDir.resolve("index.html")); + } + + /** + * Test that non-existent files return null (not exceptions). + */ + @Test + public void testNonExistentFileReturnsNull() { + URL result = context.getResource("does-not-exist.txt"); + // This might return null or try classpath lookup, either is acceptable + // The key is it doesn't throw an exception or allow traversal + } + + /** + * Test attribute storage (not security related, but completeness). + */ + @Test + public void testAttributeStorage() { + context.setAttribute("test", "value"); + assertEquals(context.getAttribute("test"), "value"); + + context.setAttribute("number", 42); + assertEquals(context.getAttribute("number"), 42); + + Object removed = context.removeAttribute("test"); + assertEquals(removed, "value"); + assertNull(context.getAttribute("test")); + } +} From 9489b3adf7dcb338bc4de2911c07dcc24ddb3cb0 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 06:20:34 +0100 Subject: [PATCH 2/5] updated copyright --- src/test/java/io/fusionauth/http/server/HTTPContextTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java index 5fd1e87f..b71381f8 100644 --- a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java +++ b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2025, FusionAuth, All Rights Reserved + * Copyright (c) 2026, FusionAuth, All Rights Reserved * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 9f77961134ea22081e1a1436d27623c71a04dc35 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Fri, 27 Mar 2026 06:23:29 +0100 Subject: [PATCH 3/5] Improved testing --- src/test/java/io/fusionauth/http/server/HTTPContextTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java index b71381f8..b9ce8de1 100644 --- a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java +++ b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java @@ -217,8 +217,7 @@ public void testNormalizedPathWithinBaseDirSucceeds() { @Test public void testNonExistentFileReturnsNull() { URL result = context.getResource("does-not-exist.txt"); - // This might return null or try classpath lookup, either is acceptable - // The key is it doesn't throw an exception or allow traversal + assertNull(result); } /** From f325a7c37e1e64ac59a2d58974174b99b2807e05 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Mon, 30 Mar 2026 10:57:31 -0600 Subject: [PATCH 4/5] feedback from PR review --- src/test/java/io/fusionauth/http/server/HTTPContextTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java index b9ce8de1..516f3a66 100644 --- a/src/test/java/io/fusionauth/http/server/HTTPContextTest.java +++ b/src/test/java/io/fusionauth/http/server/HTTPContextTest.java @@ -17,8 +17,6 @@ import java.io.IOException; import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -89,7 +87,7 @@ public void teardown() throws IOException { * Test that legitimate file paths work correctly. */ @Test - public void testLegitimatePathsSucceed() throws Exception { + public void testLegitimatePathsSucceed() { // Test root level file URL indexUrl = context.getResource("index.html"); assertNotNull(indexUrl, "Should resolve index.html"); From 3e1726126418f598adef5b2d2d19aae5dc413da2 Mon Sep 17 00:00:00 2001 From: Dan Moore Date: Mon, 30 Mar 2026 11:30:37 -0600 Subject: [PATCH 5/5] bumping version --- build.savant | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.savant b/build.savant index 5ee435d9..ce02e360 100644 --- a/build.savant +++ b/build.savant @@ -18,7 +18,7 @@ restifyVersion = "4.2.1" slf4jVersion = "2.0.17" testngVersion = "7.11.0" -project(group: "io.fusionauth", name: "java-http", version: "1.4.0", licenses: ["ApacheV2_0"]) { +project(group: "io.fusionauth", name: "java-http", version: "1.4.1", licenses: ["ApacheV2_0"]) { workflow { fetch { // Dependency resolution order: