| 1 | module csrf |
| 2 | |
| 3 | import crypto.hmac |
| 4 | import crypto.sha256 |
| 5 | import encoding.base64 |
| 6 | import net.http |
| 7 | import net.urllib |
| 8 | import rand |
| 9 | import time |
| 10 | import veb |
| 11 | |
| 12 | @[params] |
| 13 | pub struct CsrfConfig { |
| 14 | pub: |
| 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 | |
| 47 | pub struct CsrfContext { |
| 48 | pub 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 |
| 56 | pub 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 |
| 62 | pub 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 |
| 71 | pub 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 |
| 76 | pub 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 |
| 94 | pub 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`. |
| 120 | pub 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. |
| 192 | fn 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 |
| 220 | fn request_is_invalid(mut ctx veb.Context) { |
| 221 | ctx.res.set_status(.forbidden) |
| 222 | ctx.text('Forbidden: Invalid or missing CSRF token') |
| 223 | } |
| 224 | |
| 225 | fn 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 |
| 235 | fn 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 | |