1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
let log_src = Logs.Src.create "sihl.middleware.csrf"
module Logs = (val Logs.src_log log_src : Logs.LOG)
exception Exception of string
let key : string Opium.Context.key =
Opium.Context.Key.create ("csrf token", Sexplib.Std.sexp_of_string)
;;
let find req = Opium.Context.find key req.Opium.Request.env
let find_exn req =
match Opium.Context.find key req.Opium.Request.env with
| Some csrf -> csrf
| None ->
failwith "CSRF token was fetched but CSRF middleware is not installed"
;;
let set token req =
let env = req.Opium.Request.env in
let env = Opium.Context.add key token env in
{ req with env }
;;
module Crypto = struct
let () = Nocrypto_entropy_unix.initialize ()
let block_size = 16
(** [token_length] is the amount of bytes used in the unencrypted CSRF tokens. *)
let token_length = 4 * block_size
module Secret : sig
type t
(** [make raw] turns a [raw] string secret to a fixed length SHA256 digest.
A fixed length secret is required for the hard-coded AES key sizes to
work. *)
val make : string -> t
(** [to_raw secret] turns a [secret] into a [Cstruct.t]. *)
val to_raw : t -> Cstruct.t
end = struct
type t = Cstruct.t
let make secret = secret |> Cstruct.of_string |> Nocrypto.Hash.SHA256.digest
let to_raw = CCFun.id
end
module Encrypted_token : sig
type t
(** [equal tkn1 tkn2] checks if two encrypted tokens [tkn1] and [tkn2] are
equal. *)
val equal : t -> t -> bool
(** [to_uri_safe_string tkn] turns an encrypted token [tkn] into a URI-safe
base64 string. Used to make sure CSRF works with all encodings. *)
val to_uri_safe_string : t -> string
(** [of_uri_safe_string tkn] turns a URI-safe string [tkn] into an encrypted
token. Attempts to parse the base64 input [tkn], which can fail. This
does not perform any cryptographic operation to ensure [tkn] can be
decrypted. *)
val of_uri_safe_string : string -> t option
(** [to_struct tkn] turns an encrypted token [tkn] to a raw format. *)
val to_struct : t -> Cstruct.t
(** [from_struct ~with_secret tkn] encrypts a raw token [tkn] using AES in
ECB mode given a secret [with_secret]. *)
val from_struct : with_secret:Secret.t -> Cstruct.t -> t
(** [from_struct_random ~with_secret tkn] encrypts a raw token [tkn].
Additionally the encrypted result is scrambled with a random salt (IV)
using AES in CBC mode given a secret [with_secret].*)
val from_struct_random : with_secret:Secret.t -> Cstruct.t -> t
end = struct
type t = Cstruct.t
let equal = Cstruct.equal
let to_uri_safe_string t =
t
|> Cstruct.to_string
|> Base64.encode_string ~alphabet:Base64.uri_safe_alphabet
;;
let of_uri_safe_string t =
t
|> Base64.decode ~alphabet:Base64.uri_safe_alphabet
|> CCResult.to_opt
|> CCOption.map Cstruct.of_string
;;
let to_struct = CCFun.id
let from_struct ~with_secret value =
let open Nocrypto.Cipher_block.AES.ECB in
let key = with_secret |> Secret.to_raw |> of_secret in
encrypt ~key value
;;
let from_struct_random ~with_secret value =
let open Nocrypto.Cipher_block.AES.CBC in
let key = with_secret |> Secret.to_raw |> of_secret in
let iv = Nocrypto.Rng.generate block_size in
Cstruct.append iv @@ encrypt ~key ~iv value
;;
end
(** This module does not provide an API to read a decrypted token (by turning
it into a string, Cstruct.t or similar). This is to prevent leaking CSRF
tokens. *)
module Decrypted_token : sig
type t
(** [equal tkn1 tkn2] checks if two decrypted tokens [tkn1] and [tkn2] are
equal. *)
val equal : t -> t -> bool
(** [equal_struct tkn raw] checks if a decrypted token [tkn] is equal to a
raw token [raw]. *)
val equal_struct : t -> Cstruct.t -> bool
(** [from_encrypted ~with_secret tkn] decrypts an encrypted token [tkn]
using AES in ECB mode given a secret [with_secret]. *)
val from_encrypted : with_secret:Secret.t -> Encrypted_token.t -> t
(** [from_encrypted_random ~with_secret tkn] decrypts a randomized encrypted
token [tkn] given a secret [with_secret]. This function reverses
[Encrypted_token.from_struct_random] since a specific format is
required. *)
val from_encrypted_random : with_secret:Secret.t -> Encrypted_token.t -> t
(** [from_encrypted_to_encrypted_random ~with_secret tkn] turns a normal
encrypted token [tkn] into a randomly encrypted token by first
decrypting it and then re-encrypting it with
[Encrypted_token.from_struct_random].*)
val from_encrypted_to_encrypted_random
: with_secret:Secret.t
-> Encrypted_token.t
-> Encrypted_token.t
end = struct
type t = Cstruct.t
let equal = Cstruct.equal
let equal_struct = equal
let from_encrypted ~with_secret value =
let open Nocrypto.Cipher_block.AES.ECB in
let key = with_secret |> Secret.to_raw |> of_secret in
decrypt ~key (Encrypted_token.to_struct value)
;;
let from_encrypted_random ~with_secret value =
let open Nocrypto.Cipher_block.AES.CBC in
let key = with_secret |> Secret.to_raw |> of_secret in
let iv, value =
value
|> Encrypted_token.to_struct
|> CCFun.flip Cstruct.split block_size
in
decrypt ~key ~iv value
;;
let from_encrypted_to_encrypted_random ~with_secret value =
from_encrypted ~with_secret value
|> Encrypted_token.from_struct_random ~with_secret
;;
end
end
let default_not_allowed_handler _ =
Opium.Response.(of_plain_text ~status:`Forbidden "") |> Lwt.return
;;
let middleware
?(not_allowed_handler = default_not_allowed_handler)
?(key = "_csrf")
?(session_key = "_session")
?(input_name = "_csrf")
?(secret = Core_configuration.read_secret ())
()
=
let open Crypto in
let block_secret = Secret.make secret in
let filter handler req =
let check_csrf =
Core_configuration.is_production ()
|| Option.value (Core_configuration.read_bool "CHECK_CSRF") ~default:false
in
if not check_csrf
then
handler (set "development" req)
else
let
open CCOption.Infix in
let%lwt multipart = Opium.Request.to_multipart_form_data req in
let%lwt received_encrypted_token =
multipart
>>= List.assoc_opt input_name
|> (function
| None -> Opium.Request.urlencoded input_name req
| tkn -> Lwt.return tkn)
|> Lwt.map (CCOption.flat_map Encrypted_token.of_uri_safe_string)
in
let stored_encrypted_token =
Web_session.find key req >>= Encrypted_token.of_uri_safe_string
in
let storable_encrypted_token, submittable_encrypted_token =
match stored_encrypted_token with
| Some tkn ->
( tkn
, Decrypted_token.from_encrypted_to_encrypted_random
~with_secret:block_secret
tkn )
| None ->
let value = Nocrypto.Rng.generate token_length in
( Encrypted_token.from_struct ~with_secret:block_secret value
, Encrypted_token.from_struct_random ~with_secret:block_secret value )
in
let req =
set
(submittable_encrypted_token |> Encrypted_token.to_uri_safe_string)
req
in
let construct_response handler =
let tkn = Encrypted_token.to_uri_safe_string storable_encrypted_token in
handler req
|> Lwt.map
@@ fun resp ->
Web_session.set_value
~cookie_key:session_key
~secret
~key
tkn
req
resp
in
let is_safe =
match req.Opium.Request.meth with
| `GET | `HEAD | `OPTIONS | `TRACE -> true
| _ -> false
in
match received_encrypted_token, is_safe with
| _, true -> construct_response handler
| None, false -> construct_response not_allowed_handler
| Some received_encrypted_token, false ->
(match stored_encrypted_token with
| None ->
Logs.err (fun m ->
m
"No token stored in session for received CSRF token '%s'"
(Encrypted_token.to_uri_safe_string received_encrypted_token));
construct_response not_allowed_handler
| Some stored_encrypted_token ->
let stored_token =
Decrypted_token.from_encrypted
~with_secret:block_secret
stored_encrypted_token
in
let received_token =
Decrypted_token.from_encrypted_random
~with_secret:block_secret
received_encrypted_token
in
if Decrypted_token.equal stored_token received_token
then construct_response handler
else (
Logs.err (fun m ->
m
"Encrypted stored token '%s' does not match with the \
received encrypted token '%s'"
(Encrypted_token.to_uri_safe_string stored_encrypted_token)
(Encrypted_token.to_uri_safe_string received_encrypted_token));
construct_response not_allowed_handler))
in
Rock.Middleware.create ~name:"csrf" ~filter
;;