v2 / vlib / veb / csrf / csrf.v
241 lines · 209 sloc · 7.51 KB · e2e5cf8db56f3562c7baa735061690be936bdf3e
Raw
1module csrf
2
3import crypto.hmac
4import crypto.sha256
5import encoding.base64
6import net.http
7import net.urllib
8import rand
9import time
10import veb
11
12@[params]
13pub struct CsrfConfig {
14pub:
15 secret string
16 // how long the random part of the csrf-token should be
17 nonce_length int = 64
18 // HTTP "safe" methods meaning they shouldn't alter state.
19 // If a request with any of these methods is made, `protect` will always return true
20 // https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
21 safe_methods []http.Method = [.get, .head, .options]
22 // which hosts are allowed, enforced by checking the Origin and Referer header
23 // if allowed_hosts contains '*' the check will be skipped.
24 // Subdomains need to be included separately: a request from `"sub.example.com"`
25 // will be rejected when `allowed_host = ['example.com']`.
26 allowed_hosts []string
27 // if set to true both the Referer and Origin headers must match `allowed_hosts`
28 // else if either one is valid the request is accepted
29 check_origin_and_referer bool = true
30 // the name of the csrf-token in the hidden html input
31 token_name string = 'csrftoken'
32 // the name of the cookie that contains the session id
33 session_cookie string
34 // cookie options
35 cookie_name string = 'csrftoken'
36 same_site http.SameSite = .same_site_strict_mode
37 cookie_path string = '/'
38 // how long the cookie stays valid in seconds. Default is 30 days
39 max_age int = 60 * 60 * 24 * 30
40 cookie_domain string
41 // whether the cookie can be send only over HTTPS
42 secure bool
43 // enable printing verbose statements
44 verbose bool
45}
46
47pub struct CsrfContext {
48pub mut:
49 config CsrfConfig
50 exempt bool
51 // the csrftoken that should be placed in an html form
52 csrf_token string
53}
54
55// set_token generates a new csrf_token and adds a Cookie to the response
56pub fn (mut ctx CsrfContext) set_csrf_token[T](mut user_context T) string {
57 ctx.csrf_token = set_token(mut user_context, ctx.config)
58 return ctx.csrf_token
59}
60
61// clear the csrf token and cookie header from the context
62pub fn (ctx &CsrfContext) clear_csrf_token[T](mut user_context T) {
63 user_context.set_cookie(http.Cookie{
64 name: config.cookie_name
65 value: ''
66 max_age: 0
67 })
68}
69
70// csrf_token_input returns an HTML hidden input containing the csrf token
71pub fn (ctx &CsrfContext) csrf_token_input() veb.RawHtml {
72 return '<input type="hidden" name="${ctx.config.token_name}" value="${ctx.csrf_token}">'
73}
74
75// middleware returns a handler that you can use with veb's middleware
76pub fn middleware[T](config CsrfConfig) veb.MiddlewareOptions[T] {
77 return veb.MiddlewareOptions[T]{
78 after: false
79 handler: fn [config] [T](mut ctx T) bool {
80 ctx.config = config
81 if ctx.exempt {
82 return true
83 } else if ctx.req.method in config.safe_methods {
84 return true
85 } else {
86 return protect(mut ctx, config)
87 }
88 }
89 }
90}
91
92// set_token returns the csrftoken and sets an encrypted cookie with the hmac of
93// `config.get_secret` and the csrftoken
94pub fn set_token(mut ctx veb.Context, config &CsrfConfig) string {
95 expire_time := time.now().add_seconds(config.max_age)
96 session_id := ctx.get_cookie(config.session_cookie) or { '' }
97
98 token := generate_token(expire_time.unix(), session_id, config.nonce_length)
99 cookie := generate_cookie(expire_time.unix(), token, config.secret)
100
101 // the hmac key is set as a cookie and later validated with `app.token` that must
102 // be in an html form
103 ctx.set_cookie(http.Cookie{
104 name: config.cookie_name
105 value: cookie
106 same_site: config.same_site
107 http_only: true
108 secure: config.secure
109 path: config.cookie_path
110 expires: expire_time
111 max_age: config.max_age
112 })
113
114 return token
115}
116
117// protect returns false and sends an http 401 response when the csrf verification
118// fails. protect will always return true if the current request method is in
119// `config.safe_methods`.
120pub fn protect(mut ctx veb.Context, config &CsrfConfig) bool {
121 // if the request method is a "safe" method we allow the request
122 if ctx.req.method in config.safe_methods {
123 return true
124 }
125
126 // check origin and referer header
127 if check_origin_and_referer(ctx, config) == false {
128 request_is_invalid(mut ctx)
129 return false
130 }
131
132 // use the session id from the cookie, not from the csrftoken
133 session_id := ctx.get_cookie(config.session_cookie) or { '' }
134
135 actual_token := ctx.form[config.token_name] or {
136 request_is_invalid(mut ctx)
137 return false
138 }
139 // retrieve timestamp and nonce from csrftoken
140 data := base64.url_decode_str(actual_token).split('.')
141 if config.verbose {
142 eprintln('[CSRF] Token data: ${data}')
143 }
144 if data.len < 3 {
145 request_is_invalid(mut ctx)
146 return false
147 }
148
149 // check the timestamp from the csrftoken against the current time
150 // if an attacker would change the timestamp on the cookie, the token or both the
151 // hmac would also change.
152 now := time.now().unix()
153 expire_timestamp := data[0].i64()
154 if expire_timestamp < now {
155 // token has expired
156 request_is_invalid(mut ctx)
157 return false
158 }
159 nonce := data.last()
160 expected_token := base64.url_encode_str('${expire_timestamp}.${session_id}.${nonce}')
161
162 mut actual_hash := ctx.get_cookie(config.cookie_name) or {
163 request_is_invalid(mut ctx)
164 return false
165 }
166 // old_expire := actual_hash.all_before('.')
167 // actual_hash = actual_hash.replace_once (old_expire, expire_timestamp.str())
168
169 // generate new hmac based on information in the http request
170 expected_hash := generate_cookie(expire_timestamp, expected_token, config.secret)
171 if config.verbose {
172 eprintln('[CSRF] Actual Hash: ${actual_hash}')
173 eprintln('[CSRF] Expected Hash: ${expected_hash}')
174 }
175
176 // if the new hmac matches the cookie value the request is legit
177 if actual_hash != expected_hash {
178 if config.verbose {
179 eprintln('[CSRF] The actual hash differs from the expected hash')
180 }
181 request_is_invalid(mut ctx)
182 return false
183 }
184 if config.verbose {
185 eprintln('[CSRF] The actual hash matches the expected hash')
186 }
187
188 return true
189}
190
191// check_origin_and_referer validates the `Origin` and `Referer` headers.
192fn check_origin_and_referer(ctx veb.Context, config &CsrfConfig) bool {
193 // wildcard allow all hosts NOT SAFE!
194 if '*' in config.allowed_hosts {
195 return true
196 }
197
198 // only match host and match the full domain name
199 // because lets say `allowed_host` = `['example.com']`.
200 // Attackers shouldn't be able to bypass this check with the domain `example.com.attacker.com`
201
202 origin := ctx.get_header(.origin) or { return false }
203 origin_url := urllib.parse(origin) or { urllib.URL{} }
204
205 valid_origin := origin_url.hostname() in config.allowed_hosts
206
207 referer := ctx.get_header(.referer) or { return false }
208 referer_url := urllib.parse(referer) or { urllib.URL{} }
209
210 valid_referer := referer_url.hostname() in config.allowed_hosts
211
212 if config.check_origin_and_referer {
213 return valid_origin && valid_referer
214 } else {
215 return valid_origin || valid_referer
216 }
217}
218
219// request_is_invalid sends an http 403 response
220fn request_is_invalid(mut ctx veb.Context) {
221 ctx.res.set_status(.forbidden)
222 ctx.text('Forbidden: Invalid or missing CSRF token')
223}
224
225fn generate_token(expire_time i64, session_id string, nonce_length int) string {
226 nonce := rand.string_from_set('0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz',
227 nonce_length)
228 token := '${expire_time}.${session_id}.${nonce}'
229
230 return base64.url_encode_str(token)
231}
232
233// generate_cookie converts secret key based on the request context and a random
234// token into an hmac key
235fn generate_cookie(expire_time i64, token string, secret string) string {
236 hash :=
237 base64.url_encode(hmac.new(secret.bytes(), token.bytes(), sha256.sum, sha256.block_size))
238 cookie := '${expire_time}.${hash}'
239
240 return cookie
241}
242