Skip to content
This repository was archived by the owner on Mar 3, 2026. It is now read-only.

Commit ea2060f

Browse files
committed
XSS escapers
* fix jooby-project#473 * XSS vulnerability in the 404 page fix jooby-project#453
1 parent 3d272f9 commit ea2060f

File tree

13 files changed

+421
-35
lines changed

13 files changed

+421
-35
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package org.jooby.issues;
2+
3+
import org.jooby.test.ServerFeature;
4+
import org.junit.Test;
5+
6+
public class Issue453 extends ServerFeature {
7+
8+
public static class Form {
9+
public String text;
10+
}
11+
12+
{
13+
get("/453", req -> {
14+
return req.param("text", "html").value();
15+
});
16+
17+
get("/453/h", req -> {
18+
return req.header("text", "html").value();
19+
});
20+
21+
get("/453/escape-form", req -> {
22+
return req.params(Form.class, req.param("xss").value("html")).text;
23+
});
24+
25+
get("/453/to-escape-form", req -> {
26+
return req.params(req.param("xss").value("html")).to(Form.class).text;
27+
});
28+
29+
err((req, rsp, x) -> {
30+
rsp.send(x.toMap().get("message"));
31+
});
32+
}
33+
34+
@Test
35+
public void escape() throws Exception {
36+
request()
37+
.get("/453?text=%3Ch1%3EX%3C/h1%3E")
38+
.expect("<h1>X</h1>");
39+
40+
request()
41+
.get("/453/h")
42+
.header("text", "<h1>X</h1>")
43+
.expect("&lt;h1&gt;X&lt;/h1&gt;");
44+
}
45+
46+
@Test
47+
public void escapeForm() throws Exception {
48+
request()
49+
.get("/453/escape-form?text=%3Ch1%3EX%3C/h1%3E")
50+
.expect("&lt;h1&gt;X&lt;/h1&gt;");
51+
52+
request()
53+
.get("/453/escape-form?text=%3Ch1%3EX%3C/h1%3E&xss=none")
54+
.expect("<h1>X</h1>");
55+
56+
request()
57+
.get("/453/to-escape-form?text=%3Ch1%3EX%3C/h1%3E")
58+
.expect("&lt;h1&gt;X&lt;/h1&gt;");
59+
}
60+
61+
}

jooby/src/main/java/org/jooby/Env.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static com.google.common.base.Preconditions.checkArgument;
2222
import static java.util.Objects.requireNonNull;
2323

24+
import java.util.Collections;
2425
import java.util.HashMap;
2526
import java.util.List;
2627
import java.util.Locale;
@@ -29,6 +30,7 @@
2930
import java.util.Optional;
3031
import java.util.function.BiFunction;
3132
import java.util.function.Consumer;
33+
import java.util.function.Function;
3234
import java.util.function.Predicate;
3335
import java.util.function.Supplier;
3436

@@ -66,7 +68,7 @@
6668
public interface Env extends LifeCycle {
6769

6870
/**
69-
* Utility class for generated {@link Key} for named services.
71+
* Utility class for generating {@link Key} for named services.
7072
*
7173
* @author edgar
7274
*/
@@ -139,6 +141,8 @@ default Env build(final Config config) {
139141

140142
private ImmutableList.Builder<CheckedConsumer<Registry>> shutdown = ImmutableList.builder();
141143

144+
private Map<String, Function<String, String>> xss = new HashMap<>();
145+
142146
private ServiceKey key = new ServiceKey();
143147

144148
@Override
@@ -195,6 +199,18 @@ public Env onStart(final CheckedConsumer<Registry> task) {
195199
public List<CheckedConsumer<Registry>> startTasks() {
196200
return this.start.build();
197201
}
202+
203+
@Override
204+
public Map<String, Function<String, String>> xss() {
205+
return Collections.unmodifiableMap(xss);
206+
}
207+
208+
@Override
209+
public Env xss(final String name, final Function<String, String> escaper) {
210+
xss.put(requireNonNull(name, "Name required."),
211+
requireNonNull(escaper, "Function required."));
212+
return this;
213+
}
198214
};
199215
};
200216

@@ -228,7 +244,6 @@ default ServiceKey serviceKey() {
228244
return new ServiceKey();
229245
}
230246

231-
232247
/**
233248
* Returns a string with all substitutions (the <code>${foo.bar}</code> syntax,
234249
* see <a href="https://github.com/typesafehub/config/blob/master/HOCON.md">the
@@ -423,6 +438,20 @@ default <T> Option<T> when(final Predicate<String> predicate, final T result) {
423438
return match().option(API.Case(predicate, result));
424439
}
425440

441+
/**
442+
* @return XSS escape functions.
443+
*/
444+
Map<String, Function<String, String>> xss();
445+
446+
/**
447+
* Set/override a XSS escape function.
448+
*
449+
* @param name Escape's name.
450+
* @param escaper Escape function.
451+
* @return This environment.
452+
*/
453+
Env xss(String name, Function<String, String> escaper);
454+
426455
/**
427456
* @return List of start tasks.
428457
*/

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@
132132
import com.google.common.collect.ImmutableList;
133133
import com.google.common.collect.ImmutableMap;
134134
import com.google.common.collect.ImmutableSet;
135+
import com.google.common.escape.Escaper;
136+
import com.google.common.html.HtmlEscapers;
137+
import com.google.common.net.UrlEscapers;
135138
import com.google.inject.Binder;
136139
import com.google.inject.Guice;
137140
import com.google.inject.Injector;
@@ -4060,6 +4063,9 @@ private Injector bootstrap(final Config args,
40604063
throw new IllegalStateException("Required property 'application.secret' is missing");
40614064
}
40624065

4066+
/** Some basic xss functions. */
4067+
xss(finalEnv);
4068+
40634069
/** dependency injection */
40644070
@SuppressWarnings("unchecked")
40654071
Injector injector = Guice.createInjector(stage, binder -> {
@@ -4223,6 +4229,18 @@ private Injector bootstrap(final Config args,
42234229
return injector;
42244230
}
42254231

4232+
private void xss(final Env env) {
4233+
Escaper ufe = UrlEscapers.urlFragmentEscaper();
4234+
Escaper fpe = UrlEscapers.urlFormParameterEscaper();
4235+
Escaper pse = UrlEscapers.urlPathSegmentEscaper();
4236+
Escaper html = HtmlEscapers.htmlEscaper();
4237+
4238+
env.xss("urlFragment", ufe::escape)
4239+
.xss("formParam", fpe::escape)
4240+
.xss("pathSegment", pse::escape)
4241+
.xss("html", html::escape);
4242+
}
4243+
42264244
private static Provider<Session.Definition> session(final Config $session,
42274245
final Session.Definition session) {
42284246
return () -> {

jooby/src/main/java/org/jooby/Request.java

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import com.google.common.collect.ImmutableList;
3535
import com.google.common.collect.ImmutableMap;
36+
import com.google.common.net.UrlEscapers;
3637
import com.google.inject.Key;
3738
import com.google.inject.TypeLiteral;
3839

@@ -69,6 +70,11 @@ public String path() {
6970
return req.path();
7071
}
7172

73+
@Override
74+
public String path(final boolean escape) {
75+
return req.path(escape);
76+
}
77+
7278
@Override
7379
public boolean matches(final String pattern) {
7480
return req.matches(pattern);
@@ -134,16 +140,31 @@ public Mutant params() {
134140
return req.params();
135141
}
136142

143+
@Override
144+
public Mutant params(final String... xss) {
145+
return req.params(xss);
146+
}
147+
137148
@Override
138149
public <T> T params(final Class<T> type) {
139150
return req.params(type);
140151
}
141152

153+
@Override
154+
public <T> T params(final Class<T> type, final String... xss) {
155+
return req.params(type, xss);
156+
}
157+
142158
@Override
143159
public Mutant param(final String name) {
144160
return req.param(name);
145161
}
146162

163+
@Override
164+
public Mutant param(final String name, final String... xss) {
165+
return req.param(name, xss);
166+
}
167+
147168
@Override
148169
public Upload file(final String name) {
149170
return req.file(name);
@@ -159,6 +180,11 @@ public Mutant header(final String name) {
159180
return req.header(name);
160181
}
161182

183+
@Override
184+
public Mutant header(final String name, final String... xss) {
185+
return req.header(name, xss);
186+
}
187+
162188
@Override
163189
public Map<String, Mutant> headers() {
164190
return req.headers();
@@ -389,7 +415,23 @@ public static Request unwrap(final Request req) {
389415
* @return The request URL pathname.
390416
*/
391417
default String path() {
392-
return route().path();
418+
return path(false);
419+
}
420+
421+
/**
422+
* Escape the path using {@link UrlEscapers#urlFragmentEscaper()}.
423+
*
424+
* Given:
425+
*
426+
* <pre>{@code
427+
* http://domain.com/404<h1>X</h1> {@literal ->} /404%3Ch1%3EX%3C/h1%3E
428+
* }</pre>
429+
*
430+
* @return The request URL pathname.
431+
*/
432+
default String path(final boolean escape) {
433+
String path = route().path();
434+
return escape ? UrlEscapers.urlFragmentEscaper().escape(path) : path;
393435
}
394436

395437
/**
@@ -573,6 +615,22 @@ default Optional<MediaType> accepts(final MediaType... types) {
573615
*/
574616
Mutant params();
575617

618+
/**
619+
* Get all the available parameters. A HTTP parameter can be provided in any of
620+
* these forms:
621+
*
622+
* <ul>
623+
* <li>Path parameter, like: <code>/path/:name</code> or <code>/path/{name}</code></li>
624+
* <li>Query parameter, like: <code>?name=jooby</code></li>
625+
* <li>Body parameter when <code>Content-Type</code> is
626+
* <code>application/x-www-form-urlencoded</code> or <code>multipart/form-data</code></li>
627+
* </ul>
628+
*
629+
* @param xss Xss filter to apply.
630+
* @return All the parameters.
631+
*/
632+
Mutant params(String... xss);
633+
576634
/**
577635
* Short version of <code>params().to(type)</code>.
578636
*
@@ -584,6 +642,18 @@ default <T> T params(final Class<T> type) {
584642
return params().to(type);
585643
}
586644

645+
/**
646+
* Short version of <code>params(xss).to(type)</code>.
647+
*
648+
* @param type Object type.
649+
* @param xss Xss filter to apply.
650+
* @param <T> Value type.
651+
* @return Instance of object.
652+
*/
653+
default <T> T params(final Class<T> type, final String... xss) {
654+
return params(xss).to(type);
655+
}
656+
587657
/**
588658
* Get a HTTP request parameter under the given name. A HTTP parameter can be provided in any of
589659
* these forms:
@@ -613,6 +683,36 @@ default <T> T params(final Class<T> type) {
613683
*/
614684
Mutant param(String name);
615685

686+
/**
687+
* Get a HTTP request parameter under the given name. A HTTP parameter can be provided in any of
688+
* these forms:
689+
* <ul>
690+
* <li>Path parameter, like: <code>/path/:name</code> or <code>/path/{name}</code></li>
691+
* <li>Query parameter, like: <code>?name=jooby</code></li>
692+
* <li>Body parameter when <code>Content-Type</code> is
693+
* <code>application/x-www-form-urlencoded</code> or <code>multipart/form-data</code></li>
694+
* </ul>
695+
*
696+
* The order of precedence is: <code>path</code>, <code>query</code> and <code>body</code>. For
697+
* example a pattern like: <code>GET /path/:name</code> for <code>/path/jooby?name=rocks</code>
698+
* produces:
699+
*
700+
* <pre>
701+
* assertEquals("jooby", req.param(name).value());
702+
*
703+
* assertEquals("jooby", req.param(name).toList().get(0));
704+
* assertEquals("rocks", req.param(name).toList().get(1));
705+
* </pre>
706+
*
707+
* Uploads can be retrieved too when <code>Content-Type</code> is <code>multipart/form-data</code>
708+
* see {@link Upload} for more information.
709+
*
710+
* @param name A parameter's name.
711+
* @param xss Xss filter to apply.
712+
* @return A HTTP request parameter.
713+
*/
714+
Mutant param(String name, String... xss);
715+
616716
/**
617717
* Get a file {@link Upload} with the given name. The request must be a POST with
618718
* <code>multipart/form-data</code> content-type.
@@ -643,6 +743,15 @@ default List<Upload> files(final String name) {
643743
*/
644744
Mutant header(String name);
645745

746+
/**
747+
* Get a HTTP header and apply the XSS escapers.
748+
*
749+
* @param name A header's name.
750+
* @param xss Xss escapers.
751+
* @return A HTTP request header.
752+
*/
753+
Mutant header(final String name, final String... xss);
754+
646755
/**
647756
* @return All the headers.
648757
*/

jooby/src/main/java/org/jooby/internal/EmptyBodyReference.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
119
package org.jooby.internal;
220

321
import java.io.IOException;

0 commit comments

Comments
 (0)