Skip to content

Commit 34f5260

Browse files
committed
asset: path traversal fix #1639
1 parent 8909924 commit 34f5260

File tree

10 files changed

+272
-32
lines changed

10 files changed

+272
-32
lines changed

jooby/src/main/java/org/jooby/Jooby.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1363,7 +1363,11 @@ public Route.Definition use(final String path, final Route.OneArgHandler handler
13631363

13641364
@Override
13651365
public Route.Definition get(final String path, final Route.Handler handler) {
1366-
return appendDefinition(GET, path, handler);
1366+
if (handler instanceof AssetHandler) {
1367+
return assets(path, (AssetHandler) handler);
1368+
} else {
1369+
return appendDefinition(GET, path, handler);
1370+
}
13671371
}
13681372

13691373
@Override

jooby/src/main/java/org/jooby/Route.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,7 @@ class AssetDefinition extends Definition {
16361636
public AssetDefinition(final String method, final String pattern,
16371637
final Route.Filter handler, boolean caseSensitiveRouting) {
16381638
super(method, pattern, handler, caseSensitiveRouting);
1639+
filter().setRoute(this);
16391640
}
16401641

16411642
@Nonnull

jooby/src/main/java/org/jooby/handlers/AssetHandler.java

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@
206206
import com.google.common.base.Strings;
207207
import com.typesafe.config.ConfigFactory;
208208
import com.typesafe.config.ConfigValueFactory;
209-
import static java.util.Objects.requireNonNull;
210209
import org.jooby.Asset;
211210
import org.jooby.Err;
212211
import org.jooby.Jooby;
@@ -229,6 +228,8 @@
229228
import java.util.Date;
230229
import java.util.Map;
231230

231+
import static java.util.Objects.requireNonNull;
232+
232233
/**
233234
* Serve static resources, via {@link Jooby#assets(String)} or variants.
234235
*
@@ -287,6 +288,12 @@ private interface Loader {
287288

288289
private int statusCode = 404;
289290

291+
private String location;
292+
293+
private Path basedir;
294+
295+
private ClassLoader classLoader;
296+
290297
/**
291298
* <p>
292299
* Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for
@@ -315,7 +322,9 @@ private interface Loader {
315322
* @param loader The one who load the static resources.
316323
*/
317324
public AssetHandler(final String pattern, final ClassLoader loader) {
318-
init(Route.normalize(pattern), Paths.get("public"), loader);
325+
this.location = Route.normalize(pattern);
326+
this.basedir = Paths.get("public");
327+
this.classLoader = loader;
319328
}
320329

321330
/**
@@ -345,7 +354,9 @@ public AssetHandler(final String pattern, final ClassLoader loader) {
345354
* @param basedir Base directory.
346355
*/
347356
public AssetHandler(final Path basedir) {
348-
init("/{0}", basedir, getClass().getClassLoader());
357+
this.location = "/{0}";
358+
this.basedir = basedir;
359+
this.classLoader = getClass().getClassLoader();
349360
}
350361

351362
/**
@@ -375,7 +386,9 @@ public AssetHandler(final Path basedir) {
375386
* @param pattern Pattern to locate static resources.
376387
*/
377388
public AssetHandler(final String pattern) {
378-
init(Route.normalize(pattern), Paths.get("public"), getClass().getClassLoader());
389+
this.location = Route.normalize(pattern);
390+
this.basedir = Paths.get("public");
391+
this.classLoader = getClass().getClassLoader();
379392
}
380393

381394
/**
@@ -422,6 +435,45 @@ public AssetHandler maxAge(final long maxAge) {
422435
return this;
423436
}
424437

438+
/**
439+
* Set the route definition and initialize the handler.
440+
*
441+
* @param route Route definition.
442+
* @return This handler.
443+
*/
444+
public AssetHandler setRoute(final Route.AssetDefinition route) {
445+
String prefix;
446+
boolean rootLocation = location.equals("/") || location.equals("/{0}");
447+
if (rootLocation) {
448+
String pattern = route.pattern();
449+
int i = pattern.indexOf("/*");
450+
if (i > 0) {
451+
prefix = pattern.substring(0, i + 1);
452+
} else {
453+
prefix = pattern;
454+
}
455+
} else {
456+
int i = location.indexOf("{");
457+
if (i > 0) {
458+
prefix = location.substring(0, i);
459+
} else {
460+
/// TODO: review what we have here
461+
prefix = location;
462+
}
463+
}
464+
if (prefix.startsWith("/")) {
465+
prefix = prefix.substring(1);
466+
}
467+
if (prefix.isEmpty() && rootLocation) {
468+
throw new IllegalArgumentException(
469+
"For security reasons root classpath access is not allowed. Map your static resources "
470+
+ "using a prefix like: assets(static/**); or use a location classpath prefix like: "
471+
+ "assets(/, /static/{0})");
472+
}
473+
init(prefix, location, basedir, classLoader);
474+
return this;
475+
}
476+
425477
/**
426478
* Parse value as {@link Duration}. If the value is already a number then it uses as seconds.
427479
* Otherwise, it parse expressions like: 8m, 1h, 365d, etc...
@@ -485,7 +537,6 @@ public void handle(final Request req, final Response rsp) throws Throwable {
485537
}
486538

487539
private void doHandle(final Request req, final Response rsp, final Asset asset) throws Throwable {
488-
489540
// handle etag
490541
if (this.etag) {
491542
String etag = asset.etag();
@@ -551,21 +602,22 @@ protected URL resolve(final String path) throws Exception {
551602
return loader.getResource(path);
552603
}
553604

554-
private void init(final String pattern, final Path basedir, final ClassLoader loader) {
605+
private void init(final String classPathPrefix, final String location, final Path basedir,
606+
final ClassLoader loader) {
555607
requireNonNull(loader, "Resource loader is required.");
556-
this.fn = pattern.equals("/")
608+
this.fn = location.equals("/")
557609
? (req, p) -> prefix.apply(p)
558-
: (req, p) -> MessageFormat.format(prefix.apply(pattern), vars(req));
559-
this.loader = loader(basedir, loader);
610+
: (req, p) -> MessageFormat.format(prefix.apply(location), vars(req));
611+
this.loader = loader(basedir, classpathLoader(classPathPrefix, classLoader));
560612
}
561613

562614
private static Object[] vars(final Request req) {
563615
Map<Object, String> vars = req.route().vars();
564616
return vars.values().toArray(new Object[vars.size()]);
565617
}
566618

567-
private static Loader loader(final Path basedir, final ClassLoader classloader) {
568-
if (Files.exists(basedir)) {
619+
private static Loader loader(final Path basedir, Loader classpath) {
620+
if (basedir != null && Files.exists(basedir)) {
569621
return name -> {
570622
Path path = basedir.resolve(name).normalize();
571623
if (Files.exists(path) && path.startsWith(basedir)) {
@@ -575,10 +627,45 @@ private static Loader loader(final Path basedir, final ClassLoader classloader)
575627
// shh
576628
}
577629
}
578-
return classloader.getResource(name);
630+
return classpath.getResource(name);
579631
};
580632
}
581-
return classloader::getResource;
633+
return classpath;
634+
}
635+
636+
private static Loader classpathLoader(String prefix, ClassLoader classloader) {
637+
return name -> {
638+
String safePath = safePath(name);
639+
if (safePath.startsWith(prefix)) {
640+
URL resource = classloader.getResource(safePath);
641+
return resource;
642+
}
643+
return null;
644+
};
645+
}
646+
647+
private static String safePath(String name) {
648+
if (name.indexOf("./") > 0) {
649+
Path path = toPath(name.split("/")).normalize();
650+
return toStringPath(path);
651+
}
652+
return name;
653+
}
654+
655+
private static String toStringPath(Path path) {
656+
StringBuilder buffer = new StringBuilder();
657+
for (Path segment : path) {
658+
buffer.append("/").append(segment);
659+
}
660+
return buffer.substring(1);
661+
}
662+
663+
private static Path toPath(String[] segments) {
664+
Path path = Paths.get(segments[0]);
665+
for (int i = 1; i < segments.length; i++) {
666+
path = path.resolve(segments[i]);
667+
}
668+
return path;
582669
}
583670

584671
private static Throwing.Function<String, String> prefix() {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.jooby.internal;
2+
3+
import com.google.common.base.Strings;
4+
5+
import java.net.MalformedURLException;
6+
import java.net.URL;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
10+
public interface AssetSource {
11+
URL getResource(String name);
12+
13+
static AssetSource fromClassPath(ClassLoader loader, String source) {
14+
if (Strings.isNullOrEmpty(source) || "/".equals(source.trim())) {
15+
throw new IllegalArgumentException(
16+
"For security reasons root classpath access is not allowed: " + source);
17+
}
18+
return path -> {
19+
URL resource = loader.getResource(path);
20+
if (resource == null) {
21+
return null;
22+
}
23+
String realPath = resource.getPath();
24+
if (realPath.startsWith(source)) {
25+
return resource;
26+
}
27+
return null;
28+
};
29+
}
30+
31+
static AssetSource fromFileSystem(Path basedir) {
32+
return name -> {
33+
Path path = basedir.resolve(name).normalize();
34+
if (Files.exists(path) && path.startsWith(basedir)) {
35+
try {
36+
return path.toUri().toURL();
37+
} catch (MalformedURLException x) {
38+
// shh
39+
}
40+
}
41+
return null;
42+
};
43+
}
44+
}

jooby/src/test/java/org/jooby/handlers/AssetHandlerTest.java

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package org.jooby.handlers;
22

3-
import static org.easymock.EasyMock.expect;
4-
import static org.junit.Assert.assertNotNull;
3+
import org.jooby.Route;
4+
import org.jooby.test.MockUnit;
5+
import org.jooby.test.MockUnit.Block;
6+
import org.junit.Test;
7+
import org.junit.runner.RunWith;
8+
import org.powermock.core.classloader.annotations.PrepareForTest;
9+
import org.powermock.modules.junit4.PowerMockRunner;
510

611
import java.io.File;
712
import java.net.MalformedURLException;
@@ -11,15 +16,11 @@
1116
import java.nio.file.Path;
1217
import java.nio.file.Paths;
1318

14-
import org.jooby.test.MockUnit;
15-
import org.jooby.test.MockUnit.Block;
16-
import org.junit.Test;
17-
import org.junit.runner.RunWith;
18-
import org.powermock.core.classloader.annotations.PrepareForTest;
19-
import org.powermock.modules.junit4.PowerMockRunner;
19+
import static org.easymock.EasyMock.expect;
20+
import static org.junit.Assert.assertNotNull;
2021

2122
@RunWith(PowerMockRunner.class)
22-
@PrepareForTest({AssetHandler.class, File.class, Paths.class, Files.class })
23+
@PrepareForTest({AssetHandler.class, File.class, Paths.class, Files.class})
2324
public class AssetHandlerTest {
2425

2526
@Test
@@ -28,24 +29,30 @@ public void customClassloader() throws Exception {
2829
new MockUnit(ClassLoader.class)
2930
.expect(publicDir(uri, "JoobyTest.js"))
3031
.run(unit -> {
31-
URL value = new AssetHandler("/", unit.get(ClassLoader.class))
32+
URL value = newHandler(unit, "/")
3233
.resolve("JoobyTest.js");
3334
assertNotNull(value);
3435
});
3536
}
3637

38+
private AssetHandler newHandler(MockUnit unit, String location) {
39+
AssetHandler handler = new AssetHandler(location, unit.get(ClassLoader.class));
40+
new Route.AssetDefinition("GET", "/assets/**", handler, false);
41+
return handler;
42+
}
43+
3744
@Test
3845
public void shouldCallParentOnMissing() throws Exception {
3946
URI uri = Paths.get("src", "test", "resources", "org", "jooby").toUri();
4047
new MockUnit(ClassLoader.class)
41-
.expect(publicDir(uri, "index.js", false))
48+
.expect(publicDir(uri, "assets/index.js", false))
4249
.expect(unit -> {
4350
ClassLoader loader = unit.get(ClassLoader.class);
44-
expect(loader.getResource("index.js")).andReturn(uri.toURL());
51+
expect(loader.getResource("assets/index.js")).andReturn(uri.toURL());
4552
})
4653
.run(unit -> {
47-
URL value = new AssetHandler("/", unit.get(ClassLoader.class))
48-
.resolve("index.js");
54+
URL value = newHandler(unit, "/")
55+
.resolve("assets/index.js");
4956
assertNotNull(value);
5057
});
5158
}
@@ -54,18 +61,18 @@ public void shouldCallParentOnMissing() throws Exception {
5461
public void ignoreMalformedURL() throws Exception {
5562
Path path = Paths.get("src", "test", "resources", "org", "jooby");
5663
new MockUnit(ClassLoader.class, URI.class)
57-
.expect(publicDir(null, "index.js"))
64+
.expect(publicDir(null, "assets/index.js"))
5865
.expect(unit -> {
5966
URI uri = unit.get(URI.class);
6067
expect(uri.toURL()).andThrow(new MalformedURLException());
6168
})
6269
.expect(unit -> {
6370
ClassLoader loader = unit.get(ClassLoader.class);
64-
expect(loader.getResource("index.js")).andReturn(path.toUri().toURL());
71+
expect(loader.getResource("assets/index.js")).andReturn(path.toUri().toURL());
6572
})
6673
.run(unit -> {
67-
URL value = new AssetHandler("/", unit.get(ClassLoader.class))
68-
.resolve("index.js");
74+
URL value = newHandler(unit, "/")
75+
.resolve("assets/index.js");
6976
assertNotNull(value);
7077
});
7178
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.jooby.issues;
2+
3+
import org.jooby.test.ServerFeature;
4+
import org.junit.Test;
5+
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.nio.file.Paths;
9+
10+
import static org.junit.Assert.assertTrue;
11+
12+
public class Issue1639 extends ServerFeature {
13+
14+
{
15+
Path dir = Paths.get(System.getProperty("user.dir"), "src", "test", "resources", "assets");
16+
assertTrue(Files.exists(dir));
17+
assets("/static/**", dir);
18+
}
19+
20+
@Test
21+
public void shouldNotFallbackToArbitraryClasspathResources() throws Exception {
22+
request()
23+
.get("/static/WEB-INF/web2.xml")
24+
.expect(404);
25+
26+
request()
27+
.get("/static/../WEB-INF/web2.xml")
28+
.expect(404);
29+
}
30+
31+
}

0 commit comments

Comments
 (0)