Skip to content

Commit 70aec88

Browse files
authored
Merge pull request #53 from mooreds/mooreds/update-directory-handling
added better directory checking
2 parents 5a54e5c + 3e17261 commit 70aec88

File tree

3 files changed

+260
-5
lines changed

3 files changed

+260
-5
lines changed

build.savant

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ restifyVersion = "4.2.1"
1818
slf4jVersion = "2.0.17"
1919
testngVersion = "7.11.0"
2020

21-
project(group: "io.fusionauth", name: "java-http", version: "1.4.0", licenses: ["ApacheV2_0"]) {
21+
project(group: "io.fusionauth", name: "java-http", version: "1.4.1", licenses: ["ApacheV2_0"]) {
2222
workflow {
2323
fetch {
2424
// Dependency resolution order:

src/main/java/io/fusionauth/http/server/HTTPContext.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ public Map<String, Object> getAttributes() {
6161
/**
6262
* 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
6363
* borked or the path somehow cannot be converted to a URL, then this throws an exception.
64+
* <p>
65+
* This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir.
66+
* Attempts to escape the baseDir using sequences like {@code ../} will cause this method to return null.
6467
*
6568
* @param path The path.
6669
* @return The URL to the resource or null.
@@ -74,7 +77,13 @@ public URL getResource(String path) throws IllegalStateException {
7477
}
7578

7679
try {
77-
Path resolved = baseDir.resolve(filePath);
80+
Path resolved = baseDir.resolve(filePath).normalize();
81+
82+
// Security: Verify the resolved path stays within baseDir to prevent path traversal attacks
83+
if (!resolved.startsWith(baseDir.normalize())) {
84+
return null;
85+
}
86+
7887
if (Files.exists(resolved)) {
7988
return resolved.toUri().toURL();
8089
}
@@ -98,17 +107,27 @@ public Object removeAttribute(String name) {
98107
}
99108

100109
/**
101-
* Locates the path given the webapps baseDir (passed into the constructor.
110+
* Locates the path given the webapps baseDir (passed into the constructor).
111+
* <p>
112+
* This method protects against path traversal attacks by normalizing the resolved path and ensuring it stays within the baseDir.
113+
* Attempts to escape the baseDir using sequences like {@code ../} will return null.
102114
*
103115
* @param appPath The app path to a resource (like an FTL file).
104-
* @return The resolved path, which is almost always just the baseDir plus the appPath with a file separator in the middle.
116+
* @return The resolved path, or null if the path attempts to escape the baseDir.
105117
*/
106118
public Path resolve(String appPath) {
107119
if (appPath.startsWith("/")) {
108120
appPath = appPath.substring(1);
109121
}
110122

111-
return baseDir.resolve(appPath);
123+
Path resolved = baseDir.resolve(appPath).normalize();
124+
125+
// Security: Verify the resolved path stays within baseDir to prevent path traversal attacks
126+
if (!resolved.startsWith(baseDir.normalize())) {
127+
return null;
128+
}
129+
130+
return resolved;
112131
}
113132

114133
/**
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/*
2+
* Copyright (c) 2026, FusionAuth, All Rights Reserved
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing,
11+
* software distributed under the License is distributed on an
12+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
* either express or implied. See the License for the specific
14+
* language governing permissions and limitations under the License.
15+
*/
16+
package io.fusionauth.http.server;
17+
18+
import java.io.IOException;
19+
import java.net.URL;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
23+
import org.testng.annotations.AfterMethod;
24+
import org.testng.annotations.BeforeMethod;
25+
import org.testng.annotations.Test;
26+
import static org.testng.Assert.assertEquals;
27+
import static org.testng.Assert.assertNotNull;
28+
import static org.testng.Assert.assertNull;
29+
import static org.testng.Assert.assertTrue;
30+
31+
/**
32+
* Tests for HTTPContext focusing on path traversal security and resource resolution.
33+
* <p>
34+
* These tests verify that HTTPContext properly prevents path traversal attacks as described in:
35+
* - CVE-2019-19781 (Citrix path traversal)
36+
* - Blog post: https://blog.dochia.dev/blog/http_edge_cases/
37+
*
38+
* @author FusionAuth
39+
*/
40+
public class HTTPContextTest {
41+
private Path tempDir;
42+
43+
private HTTPContext context;
44+
45+
@BeforeMethod
46+
public void setup() throws IOException {
47+
// Create a temporary directory structure for testing
48+
tempDir = Files.createTempDirectory("http-context-test");
49+
50+
// Create legitimate test files
51+
Files.writeString(tempDir.resolve("index.html"), "<html>Index</html>");
52+
53+
Path cssDir = Files.createDirectory(tempDir.resolve("css"));
54+
Files.writeString(cssDir.resolve("style.css"), "body { color: blue; }");
55+
56+
Path subDir = Files.createDirectory(tempDir.resolve("subdir"));
57+
Files.writeString(subDir.resolve("file.txt"), "Legitimate file");
58+
59+
// Create file outside the baseDir to test traversal attempts
60+
Path parentDir = tempDir.getParent();
61+
Files.writeString(parentDir.resolve("secret.txt"), "Secret data");
62+
63+
context = new HTTPContext(tempDir);
64+
}
65+
66+
@AfterMethod
67+
public void teardown() throws IOException {
68+
// Cleanup temp files
69+
if (tempDir != null && Files.exists(tempDir)) {
70+
Files.walk(tempDir)
71+
.sorted((a, b) -> b.compareTo(a)) // Delete files before directories
72+
.forEach(path -> {
73+
try {
74+
Files.deleteIfExists(path);
75+
} catch (IOException e) {
76+
// Ignore cleanup errors
77+
}
78+
});
79+
}
80+
81+
// Cleanup secret file from parent
82+
Path secretFile = tempDir.getParent().resolve("secret.txt");
83+
Files.deleteIfExists(secretFile);
84+
}
85+
86+
/**
87+
* Test that legitimate file paths work correctly.
88+
*/
89+
@Test
90+
public void testLegitimatePathsSucceed() {
91+
// Test root level file
92+
URL indexUrl = context.getResource("index.html");
93+
assertNotNull(indexUrl, "Should resolve index.html");
94+
assertTrue(indexUrl.toString().contains("index.html"));
95+
96+
// Test subdirectory file
97+
URL cssUrl = context.getResource("css/style.css");
98+
assertNotNull(cssUrl, "Should resolve css/style.css");
99+
assertTrue(cssUrl.toString().contains("style.css"));
100+
101+
// Test with leading slash (should be stripped)
102+
URL slashUrl = context.getResource("/css/style.css");
103+
assertNotNull(slashUrl, "Should resolve /css/style.css");
104+
assertTrue(slashUrl.toString().contains("style.css"));
105+
106+
// Test nested path
107+
URL subdirUrl = context.getResource("subdir/file.txt");
108+
assertNotNull(subdirUrl, "Should resolve subdir/file.txt");
109+
assertTrue(subdirUrl.toString().contains("file.txt"));
110+
}
111+
112+
/**
113+
* Test path traversal attack using ../ sequences (CVE-2019-19781 style).
114+
* These attacks attempt to escape the baseDir and access parent directories.
115+
*/
116+
@Test
117+
public void testPathTraversalAttacksBlocked() {
118+
// Simple parent directory traversal
119+
URL result1 = context.getResource("../secret.txt");
120+
assertNull(result1, "Should block ../secret.txt");
121+
122+
// Multiple parent traversals
123+
URL result2 = context.getResource("../../etc/passwd");
124+
assertNull(result2, "Should block ../../etc/passwd");
125+
126+
// Traversal with valid path prefix
127+
URL result3 = context.getResource("css/../../secret.txt");
128+
assertNull(result3, "Should block css/../../secret.txt");
129+
130+
// Deep traversal
131+
URL result4 = context.getResource("subdir/../../secret.txt");
132+
assertNull(result4, "Should block subdir/../../secret.txt");
133+
134+
// Many parent directory references
135+
URL result5 = context.getResource("../../../../../../../../../etc/passwd");
136+
assertNull(result5, "Should block ../../../../../../../../../etc/passwd");
137+
}
138+
139+
/**
140+
* Test URL-encoded path traversal attacks.
141+
* Attackers often URL-encode the ../ sequences to bypass naive filters.
142+
*/
143+
@Test
144+
public void testUrlEncodedTraversalBlocked() {
145+
// URL-encoded ../ is %2e%2e%2f
146+
URL result1 = context.getResource("%2e%2e%2fsecret.txt");
147+
assertNull(result1, "Should block URL-encoded traversal %2e%2e%2fsecret.txt");
148+
149+
URL result2 = context.getResource("%2e%2e%2f%2e%2e%2fsecret.txt");
150+
assertNull(result2, "Should block %2e%2e%2f%2e%2e%2fsecret.txt");
151+
152+
// Mixed encoded and plain
153+
URL result3 = context.getResource("css/%2e%2e%2f%2e%2e%2fsecret.txt");
154+
assertNull(result3, "Should block css/%2e%2e%2f%2e%2e%2fsecret.txt");
155+
}
156+
157+
/**
158+
* Test that resolve() method also prevents path traversal.
159+
*/
160+
@Test
161+
public void testResolvePathTraversalBlocked() {
162+
// Simple parent directory traversal
163+
Path result1 = context.resolve("../secret.txt");
164+
assertNull(result1, "Should block ../secret.txt in resolve()");
165+
166+
// Multiple parent traversals
167+
Path result2 = context.resolve("../../etc/passwd");
168+
assertNull(result2, "Should block ../../etc/passwd in resolve()");
169+
170+
// Traversal with valid path prefix
171+
Path result3 = context.resolve("css/../../secret.txt");
172+
assertNull(result3, "Should block css/../../secret.txt in resolve()");
173+
}
174+
175+
/**
176+
* Test that resolve() works correctly for legitimate paths.
177+
*/
178+
@Test
179+
public void testResolveLegitimatePathsSucceed() {
180+
// Test root level file
181+
Path indexPath = context.resolve("index.html");
182+
assertNotNull(indexPath, "Should resolve index.html");
183+
assertEquals(indexPath, tempDir.resolve("index.html"));
184+
185+
// Test subdirectory file
186+
Path cssPath = context.resolve("css/style.css");
187+
assertNotNull(cssPath, "Should resolve css/style.css");
188+
assertEquals(cssPath, tempDir.resolve("css/style.css"));
189+
190+
// Test with leading slash
191+
Path slashPath = context.resolve("/css/style.css");
192+
assertNotNull(slashPath, "Should resolve /css/style.css");
193+
assertEquals(slashPath, tempDir.resolve("css/style.css"));
194+
}
195+
196+
/**
197+
* Test edge case: path that goes down then up but stays within baseDir.
198+
* For example: "subdir/../index.html" should resolve to "index.html"
199+
*/
200+
@Test
201+
public void testNormalizedPathWithinBaseDirSucceeds() {
202+
// This path traverses up but stays within baseDir after normalization
203+
URL result = context.getResource("subdir/../index.html");
204+
assertNotNull(result, "Should allow subdir/../index.html as it normalizes to index.html");
205+
assertTrue(result.toString().contains("index.html"));
206+
207+
Path resolved = context.resolve("subdir/../index.html");
208+
assertNotNull(resolved, "Should resolve subdir/../index.html");
209+
assertEquals(resolved, tempDir.resolve("index.html"));
210+
}
211+
212+
/**
213+
* Test that non-existent files return null (not exceptions).
214+
*/
215+
@Test
216+
public void testNonExistentFileReturnsNull() {
217+
URL result = context.getResource("does-not-exist.txt");
218+
assertNull(result);
219+
}
220+
221+
/**
222+
* Test attribute storage (not security related, but completeness).
223+
*/
224+
@Test
225+
public void testAttributeStorage() {
226+
context.setAttribute("test", "value");
227+
assertEquals(context.getAttribute("test"), "value");
228+
229+
context.setAttribute("number", 42);
230+
assertEquals(context.getAttribute("number"), 42);
231+
232+
Object removed = context.removeAttribute("test");
233+
assertEquals(removed, "value");
234+
assertNull(context.getAttribute("test"));
235+
}
236+
}

0 commit comments

Comments
 (0)