Skip to content

Commit 5a9057d

Browse files
committed
Csrf Handler
1 parent a3c2b34 commit 5a9057d

File tree

5 files changed

+267
-0
lines changed

5 files changed

+267
-0
lines changed

docs/asciidoc/handlers.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ include::handlers/access-log.adoc[]
66

77
include::handlers/cors.adoc[]
88

9+
include::handlers/csrf.adoc[]
10+
911
include::handlers/head.adoc[]
1012

1113
include::handlers/rate-limit.adoc[]

docs/asciidoc/handlers/csrf.adoc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
=== CsrfHandler
2+
3+
The javadoc:CsrfHandler[text="Cross Site Request Forgery Handler"] helps to protect from (CSRF)
4+
attacks. Cross-site request forgeries are a type of malicious exploit whereby unauthorized commands
5+
are performed on behalf of an authenticated user.
6+
7+
Jooby automatically generates a CSRF "token" for each active user session managed by the
8+
application. This token is used to verify that the authenticated user is the one actually making
9+
the requests to the application.
10+
11+
Anytime you define an HTML form in your application, you should include a hidden CSRF token
12+
field in the form so that the CSRF protection middleware can validate the request
13+
14+
.CSRF
15+
[source, html]
16+
----
17+
<form method="POST" action="...">
18+
<input name="csrf" value="{{csrf}}" type="hidden" />
19+
...
20+
</form>
21+
----
22+
23+
The `csrf` is a request attribute created by the javadoc:CsrfHandler[] handler and rendered by a
24+
template engine. Here `{{csrf}}` we use Handlebars template engine (as example).
25+
26+
The javadoc:CsrfHandler[] handler, will automatically verify that the token in the request input
27+
matches the token stored in the session.
28+
29+
The token defaults name is `csrf` and can be provided as:
30+
31+
- header
32+
- cookie
33+
- form parameter
34+
35+
Configuration methods:
36+
37+
- javadoc:CsrfHandler["setTokenGenerator", java.util.Function]: Set a custom token generator. Defaults uses a random UUID.
38+
- javadoc:CsrfHandler["setRequestFilter", java.util.Predicate]: Set a custom request filter. Defaults is to process `POST`, `PUT`, `PATCH` and `DELETE`.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package io.jooby;
2+
3+
import io.jooby.exception.InvalidCsrfToken;
4+
5+
import javax.annotation.Nonnull;
6+
import java.util.Objects;
7+
import java.util.UUID;
8+
import java.util.function.Function;
9+
import java.util.function.Predicate;
10+
import java.util.stream.Stream;
11+
12+
/**
13+
* <h1>Cross Site Request Forgery handler</h1>
14+
*
15+
* <pre>
16+
* {
17+
* before(new CsrfHandler());
18+
* }
19+
* </pre>
20+
*
21+
* <p>
22+
* This filter require a token on <code>POST</code>, <code>PUT</code>, <code>PATCH</code> and
23+
* <code>DELETE</code> requests. A custom policy might be provided via:
24+
* {@link #setRequestFilter(Predicate)}.
25+
* </p>
26+
*
27+
* <p>
28+
* Default token generator, use a {@link UUID#randomUUID()}. A custom token generator might be
29+
* provided via: {@link #setTokenGenerator(Function)}.
30+
* </p>
31+
*
32+
* <p>
33+
* Default token name is: <code>csrf</code>. If you want to use a different name, just pass the name
34+
* to the {@link #CsrfHandler(String)} constructor.
35+
* </p>
36+
*
37+
* <h2>Token verification</h2>
38+
* <p>
39+
* The {@link CsrfHandler} handler will read an existing token from {@link Session} (or created a
40+
* new one is necessary) and make available as a request local variable via:
41+
* {@link Context#attribute(String, Object)}.
42+
* </p>
43+
*
44+
* <p>
45+
* If the incoming request require a token verification, it will extract the token from:
46+
* </p>
47+
* <ol>
48+
* <li>HTTP header</li>
49+
* <li>HTTP cookie</li>
50+
* <li>HTTP parameter (query or form)</li>
51+
* </ol>
52+
*
53+
* <p>
54+
* If the extracted token doesn't match the existing token (from {@link Session}) a <code>403</code>
55+
* will be thrown.
56+
* </p>
57+
*
58+
* @author edgar
59+
* @since 2.5.2
60+
*/
61+
public class CsrfHandler implements Route.Before {
62+
63+
/**
64+
* Default request filter. Requires an existing session and only check for POST, DELETE, PUT and
65+
* PATCH methods.
66+
*/
67+
public static final Predicate<Context> DEFAULT_FILTER = ctx -> {
68+
return Router.POST.equals(ctx.getMethod())
69+
|| Router.DELETE.equals(ctx.getMethod())
70+
|| Router.PATCH.equals(ctx.getMethod())
71+
|| Router.PUT.equals(ctx.getMethod());
72+
};
73+
74+
/**
75+
* UUID token generator.
76+
*/
77+
public static final Function<Context, String> DEFAULT_GENERATOR = ctx -> UUID.randomUUID()
78+
.toString();
79+
80+
private String name;
81+
82+
private Function<Context, String> generator = DEFAULT_GENERATOR;
83+
84+
private Predicate<Context> filter = DEFAULT_FILTER;
85+
86+
/**
87+
* Creates a new {@link CsrfHandler} handler and use the given name to save the token in the
88+
* {@link Session} and or extract the token from incoming requests.
89+
*
90+
* @param name Token's name.
91+
*/
92+
public CsrfHandler(String name) {
93+
this.name = name;
94+
}
95+
96+
/**
97+
* Creates a new {@link CsrfHandler} handler and use the given name to save the token in the
98+
* {@link Session} and or extract the token from incoming requests.
99+
*/
100+
public CsrfHandler() {
101+
this("csrf");
102+
}
103+
104+
@Override public void apply(@Nonnull Context ctx) throws Exception {
105+
106+
Session session = ctx.session();
107+
String token = session.get(name).toOptional().orElseGet(() -> {
108+
String newToken = generator.apply(ctx);
109+
session.put(name, newToken);
110+
return newToken;
111+
});
112+
113+
ctx.attribute(name, token);
114+
115+
if (filter.test(ctx)) {
116+
String clientToken = Stream.of(
117+
ctx.header(name).valueOrNull(),
118+
ctx.cookie(name).valueOrNull(),
119+
ctx.form(name).valueOrNull(),
120+
ctx.query(name).valueOrNull()
121+
).filter(Objects::nonNull)
122+
.findFirst()
123+
.orElse(null);
124+
if (!token.equals(clientToken)) {
125+
throw new InvalidCsrfToken(clientToken);
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Set a custom token generator. Default generator use: {@link UUID#randomUUID()}.
132+
*
133+
* @param generator A custom token generator.
134+
* @return This filter.
135+
*/
136+
public @Nonnull CsrfHandler setTokenGenerator(@Nonnull Function<Context, String> generator) {
137+
this.generator = generator;
138+
return this;
139+
}
140+
141+
/**
142+
* Decided whenever or not an incoming request require token verification. Default predicate
143+
* requires verification on: <code>POST</code>, <code>PUT</code>, <code>PATCH</code> and
144+
* <code>DELETE</code> requests.
145+
*
146+
* @param filter Predicate to use.
147+
* @return This filter.
148+
*/
149+
public @Nonnull CsrfHandler setRequestFilter(@Nonnull Predicate<Context> filter) {
150+
this.filter = filter;
151+
return this;
152+
}
153+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.jooby.exception;
2+
3+
import javax.annotation.Nullable;
4+
5+
/**
6+
* Generate by CSRF handler.
7+
*
8+
* @author edgar
9+
* @since 2.5.2
10+
*/
11+
public class InvalidCsrfToken extends ForbiddenException {
12+
13+
/**
14+
* Creates a new exception.
15+
*
16+
* @param token Token or <code>null</code>.
17+
*/
18+
public InvalidCsrfToken(@Nullable String token) {
19+
super(token);
20+
}
21+
}

tests/src/test/java/io/jooby/FeaturedTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import okhttp3.MediaType;
1717
import okhttp3.MultipartBody;
1818
import okhttp3.RequestBody;
19+
import okhttp3.Response;
1920
import okhttp3.ResponseBody;
2021
import org.apache.commons.io.IOUtils;
2122
import org.junit.jupiter.api.DisplayName;
@@ -51,6 +52,7 @@
5152
import java.util.Properties;
5253
import java.util.Scanner;
5354
import java.util.Set;
55+
import java.util.UUID;
5456
import java.util.concurrent.CompletableFuture;
5557
import java.util.concurrent.atomic.AtomicInteger;
5658
import java.util.function.Function;
@@ -2873,6 +2875,50 @@ public void accessLog(ServerTestRunner runner) {
28732875
});
28742876
}
28752877

2878+
@ServerTest
2879+
public void csrf(ServerTestRunner runner) {
2880+
String token = UUID.randomUUID().toString();
2881+
runner.define(app -> {
2882+
app.before(new CsrfHandler().setTokenGenerator(ctx -> token));
2883+
2884+
app.post("/form", Context::getRequestPath);
2885+
2886+
}).ready(client -> {
2887+
client.post("/form", new FormBody.Builder()
2888+
.add("foo", "bar")
2889+
.build(), rsp -> {
2890+
assertEquals(StatusCode.FORBIDDEN.value(), rsp.code());
2891+
});
2892+
2893+
client.post("/form", new FormBody.Builder()
2894+
.add("foo", "bar")
2895+
.add("csrf", token)
2896+
.build(), rsp -> {
2897+
assertEquals(200, rsp.code());
2898+
});
2899+
2900+
client.post("/form?csrf=" + token, new FormBody.Builder()
2901+
.add("foo", "bar")
2902+
.build(), rsp -> {
2903+
assertEquals(200, rsp.code());
2904+
});
2905+
2906+
client.header("csrf", token);
2907+
client.post("/form", new FormBody.Builder()
2908+
.add("foo", "bar")
2909+
.build(), rsp -> {
2910+
assertEquals(200, rsp.code());
2911+
});
2912+
2913+
client.header("Cookie", "csrf=" + token);
2914+
client.post("/form", new FormBody.Builder()
2915+
.add("foo", "bar")
2916+
.build(), rsp -> {
2917+
assertEquals(200, rsp.code());
2918+
});
2919+
});
2920+
}
2921+
28762922
private byte[][] partition(byte[] bytes, int size) {
28772923
List<byte[]> result = new ArrayList<>();
28782924
int offset = 0;
@@ -2920,4 +2966,11 @@ private Map<String, Object> mapOf(String... values) {
29202966
}
29212967
return hash;
29222968
}
2969+
2970+
private String sid(Response rsp, String prefix) {
2971+
String setCookie = rsp.header("Set-Cookie");
2972+
assertNotNull(setCookie);
2973+
assertTrue(setCookie.startsWith(prefix));
2974+
return setCookie.substring(prefix.length(), setCookie.indexOf(";"));
2975+
}
29232976
}

0 commit comments

Comments
 (0)