From d0214a254e0588479d4ba9b19f2d3c38512514b9 Mon Sep 17 00:00:00 2001 From: Casper Kuethe <43839798+Casper64@users.noreply.github.com> Date: Sun, 21 May 2023 00:56:17 +0200 Subject: [PATCH] vweb: vweb.csrf re-implementation (#18220) --- vlib/vweb/README.md | 66 +----- vlib/vweb/csrf/README.md | 237 ++++++++++++++++++++ vlib/vweb/csrf/create_cookie.v | 56 ----- vlib/vweb/csrf/csrf.v | 205 ++++++++++++++++++ vlib/vweb/csrf/csrf_test.v | 381 +++++++++++++++++++++++++++++++-- vlib/vweb/csrf/protect.v | 37 ---- vlib/vweb/csrf/structs.v | 30 --- 7 files changed, 808 insertions(+), 204 deletions(-) create mode 100644 vlib/vweb/csrf/README.md delete mode 100644 vlib/vweb/csrf/create_cookie.v create mode 100644 vlib/vweb/csrf/csrf.v delete mode 100644 vlib/vweb/csrf/protect.v delete mode 100644 vlib/vweb/csrf/structs.v diff --git a/vlib/vweb/README.md b/vlib/vweb/README.md index 7e35e91e1f..aac791ea61 100644 --- a/vlib/vweb/README.md +++ b/vlib/vweb/README.md @@ -1039,68 +1039,6 @@ pub fn (mut app App) error() vweb.Result { } ``` # Cross-Site Request Forgery (CSRF) protection -## Provides protection against Cross-Site Request Forgery - -## Usage - -When building a csrf-protected service, first of all create a `struct`that implements `csrf.App` - -```v ignore -module main - -import vweb -import vweb.csrf - -// embeds the csrf.App struct in order to empower the struct to protect against CSRF -struct App { - csrf.App -} -``` - -Start a server e.g. in the main function. - -```v ignore -fn main() { - vweb.run_at(&App{}, vweb.RunParams{ - port: 8080 - }) or { panic(err) } -} -``` - -### Enable CSRF-protection - -Then add a handler-function to define on which route or on which site the CSRF-Token shall be set. - -```v ignore -fn (mut app App) index() vweb.Result { - - // Set a Csrf-Cookie (Token will be generated automatically) - app.set_csrf_cookie() - - // Get the token-value from the csrf-cookie that was just set - token := app.get_csrf_token() or { panic(err) } - - return app.text("Csrf-Token set! It's value is: $token") -} -``` - -If you want to set the cookies's HttpOnly-status to false in order to make it - accessible to scripts on your site, you can do it like this: -`app.set_csrf_cookie(csrf.HttpOnly{false})` -If no argument is passed the value will be set to true by default. - - -### Protect against CSRF - -If you want to protect a route or a site against CSRF just add -`app.csrf_protect()` at the beginning of the handler-function. - -```v ignore -fn (mut app App) foo() vweb.Result { - // Protect this handler-function against CSRF - app.csrf_protect() - return app.text("Checked and passed csrf-guard") -} -``` - +Vweb has built-in csrf protection. Go to the [csrf module](csrf/) to learn how +you can protect your app against CSRF. diff --git a/vlib/vweb/csrf/README.md b/vlib/vweb/csrf/README.md new file mode 100644 index 0000000000..a5031afe9f --- /dev/null +++ b/vlib/vweb/csrf/README.md @@ -0,0 +1,237 @@ +# Cross-Site Request Forgery (CSRF) protection + +This module implements the [double submit cookie][owasp] technique to protect routes +from CSRF attacks. + +CSRF is a type of attack that occurs when a malicious program/website (and others) causes +a user's web browser to perform an action without them knowing. A web browser automatically sends +cookies to a website when it performs a request, including session cookies. So if a user is +authenticated on your website the website can not distinguish a forged request by a legitimate +request. + +## When to not add CSRF-protection +If you are creating a service that is intended to be used by other servers e.g. an API, +you probably don't want CSRF-protection. An alternative would be to send an Authorization +token in, and only in, an HTTP-header (like JSON Web Tokens). If you do that your website +isn't vulnerable to CSRF-attacks. + +## Usage + +You can add `CsrfApp` to your own `App` struct to have the functions available +in your app's context, or you can use it with the middleware of vweb. + +The advantage of the middleware approach is that you have to define +the configuration separate from your `App`. This makes it possible to share the +configuration between modules or controllers. + +### Usage with the CsrfApp + +Change `secret` and `allowed_hosts` when creating the `CsrfApp`. + +**Example**: +```v ignore +module main + +import net.http +import vweb +import vweb.csrf + +struct App { + vweb.Context +pub mut: + csrf csrf.CsrfApp [vweb_global] +} + +fn main() { + app := &App{ + csrf: csrf.CsrfApp{ + // change the secret + secret: 'my-64bytes-secret' + // change to which domains you want to allow + allowed_hosts: ['*'] + } + } + vweb.run(app, 8080) +} + +pub fn (mut app App) index() vweb.Result { + // this line sets `app.token` and the cookie + app.csrf.set_token(mut app.Context) + return $vweb.html() +} + +[post] +pub fn (mut app App) auth() vweb.Result { + // this line protects the route against CSRF + app.csrf.protect(mut app.Context) + return app.text('authenticated!') +} +``` + +index.html +```html +
+ + + +
+``` + +### Usage without CsrfApp +If you use `vweb.Middleware` you can protect multiple routes at once. + +**Example**: +```v ignore +module main + +import net.http +import vweb +import vweb.csrf + +const ( + // the configuration moved here + csrf_config = csrf.CsrfConfig{ + // change the secret + secret: 'my-64bytes-secret' + // change to which domains you want to allow + allowed_hosts: ['*'] + } +) + +struct App { + vweb.Context +pub mut: + middlewares map[string][]vweb.Middleware +} + +fn main() { + app := &App{ + middlewares: { + // protect all routes starting with the url '/auth' + '/auth': [csrf.middleware(csrf_config)] + } + } + vweb.run(app, 8080) +} + +pub fn (mut app App) index() vweb.Result { + // get the token and set the cookie + csrftoken := csrf.set_token(mut app.Context, csrf_config) + return $vweb.html() +} + +[post] +pub fn (mut app App) auth() vweb.Result { + return app.text('authenticated!') +} + +[post] +pub fn (mut app App) register() vweb.Result { + // protect an individual route with the following line + csrf.protect(mut app.Context, csrf_config) + // ... +} +``` + +index.html (the hidden input has changed) +```html +
+ + + +
+``` + +### Protect all routes +It is possible to protect all routes against CSRF-attacks. Every request that is not +defined as a [safe method](#safe-methods) (`GET`, `OPTIONS`, `HEAD` by default) +will have CSRF-protection. + +**Example**: +```v ignore +pub fn (mut app App) before_request() { + app.csrf.protect(mut app.Context) + // or if you don't use `CsrfApp`: + // csrf.protect(mut app.Context, csrf_config) +} +``` + +## How it works +This module implements the [double submit cookie][owasp] technique: a random token +is generated, the CSRF-token. The hmac of this token and the secret key is stored in a cookie. + +When a request is made, the CSRF-token should be placed inside a HTML form element. +The CSRF-token the hmac of the CSRF-token in the formdata is compared to the cookie. +If the values match, the request is accepted. + +This approach has the advantage of being stateless: there is no need to store tokens on the server +side and validate them. The token and cookie are bound cryptographically to each other so +an attacker would need to know both values in order to make a CSRF-attack succeed. That +is why is it important to **not leak the CSRF-token** via an url, or some other way. +See [client side CSRF][client-side-csrf] for more information. + +This is a high level overview of the implementation. + +## Security Considerations + +### The secret key +The secret key should be a random string that is not easily guessable. +The recommended size is 64 bytes. + +### Sessions +If your app supports some kind of user sessions, it is recommended to cryptographically +bind the CSRF-token to the users' session. You can do that by providing the name +of the session ID cookie. If an attacker changes the session ID in the cookie, in the +token or both the hmac will be different adn the request will be rejected. + +**Example**: +```v ignore +csrf_config = csrf.CsrfConfig{ + // ... + session_cookie: 'my_session_id_cookie_name' +} +``` + +### Safe Methods +The HTTP methods `GET`, `OPTIONS`, `HEAD` are considered +[safe methods][mozilla-safe-methods] meaning they should not alter the state of +an application. If a request with a "safe method" is made, the csrf protection will be skipped. + +You can change which methods are considered safe by changing `CsrfConfig.safe_methods`. + +### Allowed Hosts + +By default, both the http Origin and Referer headers are checked and matched strictly +to the values in `allowed_hosts`. That means that you need to include each subdomain. + +If the value of `allowed_hosts` contains the wildcard: `'*'` the headers will not be checked. + +#### Domain name matching +The following configuration will not allow requests made from `test.example.com`, +only from `example.com`. + +**Example** +```v ignore +config := csrf.CsrfConfig{ + secret: '...' + allowed_hosts: ['example.com'] +} +``` + +#### Referer, Origin header check +In some cases (like if your server is behind a proxy), the Origin or Referer header will +not be present. If that is your case you can set `check_origin_and_referer` to `false`. +Request will now be accepted when the Origin *or* Referer header is valid. + +### Share csrf cookie with subdomains +If you need to share the CSRF-token cookie with subdomains, you can set +`same_site` to `.same_site_lax_mode`. + +## Configuration + +All configuration options are defined in `CsrfConfig`. + +[//]: # (Sources) +[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie +[client-side-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#client-side-csrf +[mozilla-safe-methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP \ No newline at end of file diff --git a/vlib/vweb/csrf/create_cookie.v b/vlib/vweb/csrf/create_cookie.v deleted file mode 100644 index 330abe6155..0000000000 --- a/vlib/vweb/csrf/create_cookie.v +++ /dev/null @@ -1,56 +0,0 @@ -module csrf - -import rand - -const chars = 'QWERTZUIOPASDFGHJKLYXCVBNMqwertzuiopasdfghjklyxcvbnm1234567890_-' - -const cookie_key = '__Host-Csrf-Token' - -// set_csrf_cookie - generates a CSRF-Token and sets the CSRF-Cookie. It is possible to set the HttpOnly-status of the cookie to false by adding an argument of the HttpOnly-struct like this: -// `app.set_csrf_cookie(csrf.HttpOnly{false})` -// If no argument is set, http_only will be set to `true`by default. -pub fn (mut app App) set_csrf_cookie(h ...HttpOnly) App { - mut http_only := true - if h.len > 0 { - http_only = h[0].http_only - } - cookie := create_cookie(http_only) - app = App{app.Context, cookie.value} - app.set_cookie(cookie) - return app -} - -// generate - generates the CSRF-Token -fn generate() string { - mut out := '' - for _ in 0 .. 42 { - i := rand.intn(csrf.chars.len_utf8()) or { - panic('Error while trying to generate Csrf-Token: ${err}') - } - out = out + csrf.chars[i..i + 1] - } - return out -} - -// create_cookie - creates the cookie -fn create_cookie(h bool) CsrfCookie { - return CsrfCookie{ - name: csrf.cookie_key - value: generate() - path: '/' - max_age: 0 - secure: true - http_only: h - } -} - -// get_csrf_token - returns the CSRF-Token that has been set. Make sure that you set one by using `set_csrf_cookie()`. If it's value is empty or no cookie has been generated, the function will throw an error. -pub fn (mut app App) get_csrf_token() ?string { - if app.csrf_cookie_value != '' { - return app.csrf_cookie_value - } else { - return CsrfError{ - m: 'The CSRF-Token-Value is empty. Please check if you have setted a cookie!' - } - } -} diff --git a/vlib/vweb/csrf/csrf.v b/vlib/vweb/csrf/csrf.v new file mode 100644 index 0000000000..37e9336fde --- /dev/null +++ b/vlib/vweb/csrf/csrf.v @@ -0,0 +1,205 @@ +module csrf + +import crypto.hmac +import crypto.sha256 +import encoding.base64 +import net.http +import net.urllib +import rand +import time +import vweb + +[params] +pub struct CsrfConfig { +pub: + secret string + // how long the random part of the csrf-token should be + nonce_length int = 64 + // HTTP "safe" methods meaning they shouldn't alter state. + // If a request with any of these methods is made, `protect` will always return true + // https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1 + safe_methods []http.Method = [.get, .head, .options] + // which hosts are allowed, enforced by checking the Origin and Referer header + // if allowed_hosts contains '*' the check will be skipped. + // Subdomains need to be included separately: a request from `"sub.example.com"` + // will be rejected when `allowed_host = ['example.com']`. + allowed_hosts []string + // if set to true both the Referer and Origin headers must match `allowed_hosts` + // else if either one is valid the request is accepted + check_origin_and_referer bool = true + // the name of the csrf-token in the hidden html input + token_name string = 'csrftoken' + // the name of the cookie that contains the session id + session_cookie string + // cookie options + cookie_name string = 'csrftoken' + same_site http.SameSite = .same_site_strict_mode + cookie_path string = '/' + // how long the cookie stays valid in seconds. Default is 30 days + max_age int = 60 * 60 * 24 * 30 + cookie_domain string +} + +pub struct CsrfApp { + CsrfConfig +pub mut: + // the csrftoken that should be placed in an html form + token string +} + +// set_token is the app wrapper for `set_token` +pub fn (mut app CsrfApp) set_token(mut ctx vweb.Context) { + app.token = set_token(mut ctx, app.CsrfConfig) +} + +// protect is the app wrapper for `protect` +pub fn (mut app CsrfApp) protect(mut ctx vweb.Context) bool { + return protect(mut ctx, app.CsrfConfig) +} + +// check_origin_and_referer is the app wrapper for `check_origin_and_referer` +fn (app &CsrfApp) check_origin_and_referer(ctx vweb.Context) bool { + return check_origin_and_referer(ctx, app.CsrfConfig) +} + +// middleware returns a function that you can use in `app.middlewares` +pub fn middleware(config &CsrfConfig) vweb.Middleware { + return fn [config] (mut ctx vweb.Context) bool { + return protect(mut ctx, config) + } +} + +// set_token returns the csrftoken and sets an encrypted cookie with the hmac of +// `config.get_secret` and the csrftoken +pub fn set_token(mut ctx vweb.Context, config &CsrfConfig) string { + expire_time := time.now().add_seconds(config.max_age) + session_id := ctx.get_cookie(config.session_cookie) or { '' } + + token := generate_token(expire_time.unix_time(), session_id, config.nonce_length) + cookie := generate_cookie(expire_time.unix_time(), token, config.secret) + + // the hmac key is set as a cookie and later validated with `app.token` that must + // be in an html form + ctx.set_cookie(http.Cookie{ + name: config.cookie_name + value: cookie + same_site: config.same_site + http_only: true + secure: true + path: config.cookie_path + expires: expire_time + max_age: config.max_age + }) + + return token +} + +// protect returns false and sends an http 401 response when the csrf verification +// fails. protect will always return true if the current request method is in +// `config.safe_methods`. +pub fn protect(mut ctx vweb.Context, config &CsrfConfig) bool { + // if the request method is a "safe" method we allow the request + if ctx.req.method in config.safe_methods { + return true + } + + // check origin and referer header + if check_origin_and_referer(ctx, config) == false { + request_is_invalid(mut ctx) + return false + } + + // use the session id from the cookie, not from the csrftoken + session_id := ctx.get_cookie(config.session_cookie) or { '' } + + actual_token := ctx.form[config.token_name] or { + request_is_invalid(mut ctx) + return false + } + // retrieve timestamp and nonce from csrftoken + data := base64.url_decode_str(actual_token).split('.') + if data.len < 3 { + request_is_invalid(mut ctx) + return false + } + + // check the timestamp from the csrftoken against the current time + // if an attacker would change the timestamp on the cookie, the token or both the + // hmac would also change. + now := time.now().unix_time() + expire_timestamp := data[0].i64() + if expire_timestamp < now { + // token has expired + request_is_invalid(mut ctx) + return false + } + + nonce := data.last() + expected_token := base64.url_encode_str('${expire_timestamp}.${session_id}.${nonce}') + + actual_hash := ctx.get_cookie(config.cookie_name) or { + request_is_invalid(mut ctx) + return false + } + // generate new hmac based on information in the http request + expected_hash := generate_cookie(expire_timestamp, expected_token, config.secret) + + // if the new hmac matches the cookie value the request is legit + if actual_hash != expected_hash { + request_is_invalid(mut ctx) + return false + } + + return true +} + +// check_origin_and_referer validates the `Origin` and `Referer` headers. +fn check_origin_and_referer(ctx vweb.Context, config &CsrfConfig) bool { + // wildcard allow all hosts NOT SAFE! + if '*' in config.allowed_hosts { + return true + } + + // only match host and match the full domain name + // because lets say `allowed_host` = `['example.com']`. + // Attackers shouldn't be able to bypass this check with the domain `example.com.attacker.com` + + origin := ctx.get_header('Origin') + origin_url := urllib.parse(origin) or { urllib.URL{} } + + valid_origin := origin_url.hostname() in config.allowed_hosts + + referer := ctx.get_header('Referer') + referer_url := urllib.parse(referer) or { urllib.URL{} } + + valid_referer := referer_url.hostname() in config.allowed_hosts + + if config.check_origin_and_referer { + return valid_origin && valid_referer + } else { + return valid_origin || valid_referer + } +} + +// request_is_invalid sends an http 403 response +fn request_is_invalid(mut ctx vweb.Context) { + ctx.set_status(403, '') + ctx.text('Forbidden: Invalid or missing CSRF token') +} + +fn generate_token(expire_time i64, session_id string, nonce_length int) string { + nonce := rand.string_from_set('0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz', + nonce_length) + token := '${expire_time}.${session_id}.${nonce}' + + return base64.url_encode_str(token) +} + +// generate_cookie converts secret key based on the request context and a random +// token into an hmac key +fn generate_cookie(expire_time i64, token string, secret string) string { + hash := base64.url_encode(hmac.new(secret.bytes(), token.bytes(), sha256.sum, sha256.block_size)) + cookie := '${expire_time}.${hash}' + + return cookie +} diff --git a/vlib/vweb/csrf/csrf_test.v b/vlib/vweb/csrf/csrf_test.v index 0e11ed8cf4..c9471bfc4b 100644 --- a/vlib/vweb/csrf/csrf_test.v +++ b/vlib/vweb/csrf/csrf_test.v @@ -1,30 +1,377 @@ import time import net.http +import net.html import vweb import vweb.csrf +import os -const sport = 10801 +const ( + sport = 12385 + localserver = '127.0.0.1:${sport}' + exit_after_time = 12000 // milliseconds + + session_id_cookie_name = 'session_id' + csrf_config = &csrf.CsrfConfig{ + secret: 'my-256bit-secret' + allowed_hosts: ['*'] + session_cookie: session_id_cookie_name + } + + allowed_origin = 'example.com' + csrf_config_origin = &csrf.CsrfConfig{ + secret: 'my-256bit-secret' + allowed_hosts: [allowed_origin] + session_cookie: session_id_cookie_name + } +) + +// Test CSRF functions +// ===================================== + +fn test_set_token() { + mut ctx := vweb.Context{} + + token := csrf.set_token(mut ctx, csrf_config) + + cookie := ctx.header.get(.set_cookie) or { '' } + assert cookie.len != 0 + assert cookie.starts_with('${csrf_config.cookie_name}=') +} + +fn test_protect() { + mut ctx := vweb.Context{} + + token := csrf.set_token(mut ctx, csrf_config) + + mut cookie := ctx.header.get(.set_cookie) or { '' } + // get cookie value from "name=value;" + cookie = cookie.split(' ')[0].all_after('=').replace(';', '') + + form := { + csrf_config.token_name: token + } + cookie_map := { + csrf_config.cookie_name: cookie + } + ctx = vweb.Context{ + form: form + req: http.Request{ + method: .post + cookies: cookie_map + } + } + valid := csrf.protect(mut ctx, csrf_config) + + assert valid == true +} + +fn test_timeout() { + timeout := 1 + short_time_config := &csrf.CsrfConfig{ + secret: 'my-256bit-secret' + allowed_hosts: ['*'] + session_cookie: session_id_cookie_name + max_age: timeout + } + + mut ctx := vweb.Context{} + + token := csrf.set_token(mut ctx, short_time_config) + + // after 2 seconds the cookie should expire (maxage) + time.sleep(2 * time.second) + mut cookie := ctx.header.get(.set_cookie) or { '' } + // get cookie value from "name=value;" + cookie = cookie.split(' ')[0].all_after('=').replace(';', '') + + form := { + short_time_config.token_name: token + } + cookie_map := { + short_time_config.cookie_name: cookie + } + ctx = vweb.Context{ + form: form + req: http.Request{ + method: .post + cookies: cookie_map + } + } + + valid := csrf.protect(mut ctx, short_time_config) + + assert valid == false +} + +fn test_valid_origin() { + // valid because both Origin and Referer headers are present + token, cookie := get_token_cookie('') + + form := { + csrf_config.token_name: token + } + cookie_map := { + csrf_config.cookie_name: cookie + } + + mut req := http.Request{ + method: .post + cookies: cookie_map + } + req.add_header(.origin, 'http://${allowed_origin}') + req.add_header(.referer, 'http://${allowed_origin}/test') + mut ctx := vweb.Context{ + form: form + req: req + } + + mut valid := csrf.protect(mut ctx, csrf_config_origin) + assert valid == true +} + +fn test_invalid_origin() { + // invalid because either the Origin, Referer or neither are present + token, cookie := get_token_cookie('') + + form := { + csrf_config.token_name: token + } + cookie_map := { + csrf_config.cookie_name: cookie + } + + mut req := http.Request{ + method: .post + cookies: cookie_map + } + req.add_header(.origin, 'http://${allowed_origin}') + mut ctx := vweb.Context{ + form: form + req: req + } + + mut valid := csrf.protect(mut ctx, csrf_config_origin) + assert valid == false + + req = http.Request{ + method: .post + cookies: cookie_map + } + req.add_header(.referer, 'http://${allowed_origin}/test') + ctx = vweb.Context{ + form: form + req: req + } + + valid = csrf.protect(mut ctx, csrf_config_origin) + assert valid == false + + req = http.Request{ + method: .post + cookies: cookie_map + } + ctx = vweb.Context{ + form: form + req: req + } + + valid = csrf.protect(mut ctx, csrf_config_origin) + assert valid == false +} + +// Testing App +// ================================ struct App { - csrf.App + vweb.Context +pub mut: + csrf csrf.CsrfApp [vweb_global] + middlewares map[string][]vweb.Middleware } -// index - will handle requests to path '/' -fn (mut app App) index() vweb.Result { - // Set a Csrf-Cookie(Token will be generated automatically) and set http_only-status. If no argument ist passed, it will be true by default. - app.set_csrf_cookie(csrf.HttpOnly{false}) - // Get the token-value from the csrf-cookie that was just setted - token := app.get_csrf_token() or { panic(err) } - return app.text("Csrf-Token set! It's value is: ${token}") +pub fn (mut app App) index() vweb.Result { + app.csrf.set_token(mut app.Context) + + return app.html('
+ + + +
') } -fn test_send_a_request_to_homepage_expecting_a_csrf_cookie() { - spawn vweb.run_at(&App{}, vweb.RunParams{ port: sport }) - time.sleep(500 * time.millisecond) - res := http.get('http://localhost:${sport}/')! - if res.header.str().contains('__Host-Csrf-Token') { - assert true - } else { - assert false +[post] +pub fn (mut app App) auth() vweb.Result { + app.csrf.protect(mut app.Context) + + return app.ok('authenticated') +} + +pub fn (mut app App) middleware_index() vweb.Result { + csrftoken := csrf.set_token(mut app.Context, csrf_config) + + return app.html('
+ + + +
') +} + +[post] +pub fn (mut app App) middleware_auth() vweb.Result { + return app.ok('middleware authenticated') +} + +// App cleanup function +// ====================================== + +pub fn (mut app App) shutdown() vweb.Result { + spawn app.gracefull_exit() + return app.ok('good bye') +} + +fn (mut app App) gracefull_exit() { + eprintln('>> webserver: gracefull_exit') + time.sleep(100 * time.millisecond) + exit(0) +} + +fn exit_after_timeout[T](mut app T, timeout_in_ms int) { + time.sleep(timeout_in_ms * time.millisecond) + eprintln('>> webserver: pid: ${os.getpid()}, exiting ...') + app.shutdown() + + eprintln('App timed out!') + assert true == false +} + +// Tests for the App +// ====================================== + +fn test_run_app_in_background() { + mut app := &App{ + csrf: csrf.CsrfApp{ + secret: 'my-256bit-secret' + allowed_hosts: [allowed_origin] + session_cookie: session_id_cookie_name + } + middlewares: { + '/middleware_auth': [csrf.middleware(csrf_config)] + } } + spawn vweb.run_at(app, port: sport, family: .ip) + spawn exit_after_timeout(mut app, exit_after_time) + + time.sleep(500 * time.millisecond) +} + +fn test_token_with_app() { + res := http.get('http://${localserver}/') or { panic(err) } + + mut doc := html.parse(res.body) + inputs := doc.get_tag_by_attribute_value('type', 'hidden') + assert inputs.len == 1 + assert csrf_config.token_name == inputs[0].attributes['name'] +} + +fn test_token_with_middleware() { + res := http.get('http://${localserver}/middleware_index') or { panic(err) } + + mut doc := html.parse(res.body) + inputs := doc.get_tag_by_attribute_value('type', 'hidden') + assert inputs.len == 1 + assert csrf_config.token_name == inputs[0].attributes['name'] +} + +// utility function to check whether the route at `path` is protected against csrf +fn protect_route_util(path string) { + mut req := http.Request{ + method: .post + url: 'http://${localserver}/${path}' + } + mut res := req.do() or { panic(err) } + assert res.status() == .forbidden + + // A valid request with CSRF protection should have a cookie session id, + // csrftoken in `app.form` and the hmac of that token in a cookie + session_id := 'user_session_id' + token, cookie := get_token_cookie(session_id) + + header := http.new_header_from_map({ + http.CommonHeader.origin: 'http://${allowed_origin}' + http.CommonHeader.referer: 'http://${allowed_origin}/route' + }) + + formdata := http.url_encode_form_data({ + csrf_config.token_name: token + }) + + // session id is altered: test if session hijacking is possible + // if the session id the csrftoken changes so the cookie can't be validated + mut cookies := { + csrf_config.cookie_name: cookie + session_id_cookie_name: 'altered' + } + + req = http.Request{ + method: .post + url: 'http://${localserver}/${path}' + data: formdata + cookies: cookies + header: header + } + + res = req.do() or { panic(err) } + assert res.status() == .forbidden + + // Everything is valid now and the request should succeed + cookies[session_id_cookie_name] = session_id + + req = http.Request{ + method: .post + url: 'http://${localserver}/${path}' + data: formdata + cookies: cookies + header: header + } + + res = req.do() or { panic(err) } + assert res.status() == .ok +} + +fn test_protect_with_app() { + protect_route_util('/auth') +} + +fn test_protect_with_middleware() { + protect_route_util('middleware_auth') +} + +fn testsuite_end() { + // This test is guaranteed to be called last. + // It sends a request to the server to shutdown. + x := http.get('http://${localserver}/shutdown') or { + assert err.msg() == '' + return + } + assert x.status() == .ok + assert x.body == 'good bye' +} + +// Utility functions + +fn get_token_cookie(session_id string) (string, string) { + mut ctx := vweb.Context{ + req: http.Request{ + cookies: { + session_id_cookie_name: session_id + } + } + } + + token := csrf.set_token(mut ctx, csrf_config_origin) + + mut cookie := ctx.header.get(.set_cookie) or { '' } + // get cookie value from "name=value;" + cookie = cookie.split(' ')[0].all_after('=').replace(';', '') + return token, cookie } diff --git a/vlib/vweb/csrf/protect.v b/vlib/vweb/csrf/protect.v deleted file mode 100644 index 2b460d51e6..0000000000 --- a/vlib/vweb/csrf/protect.v +++ /dev/null @@ -1,37 +0,0 @@ -module csrf - -import net.http - -// csrf_protect - protects a handler-function against CSRF. Should be set at the beginning of the handler-function. -pub fn (mut app App) csrf_protect() CheckedApp { - req_cookies := app.req.cookies.clone() - app_csrf_cookie_str := app.get_cookie(cookie_key) or { - // Do not return normally!! No Csrf-Token was set! - app.set_status(403, '') - return app.text('Error 403 - Forbidden') - } - - if cookie_key in req_cookies && req_cookies[cookie_key] == app_csrf_cookie_str { - // Csrf-Check OK - return app as normal in order to handle request normally - return app - } else if app.check_headers(app_csrf_cookie_str) { - // Csrf-Check OK - return app as normal in order to handle request normally - return app - } else { - // Do not return normally!! The client has not passed the Csrf-Check!! - app.set_status(403, '') - return app.text('Error 403 - Forbidden') - } -} - -// check_headers - checks if there is a CSRF-Token that was sent with the headers of a request -fn (app App) check_headers(app_csrf_cookie_str string) bool { - token := app.req.header.get_custom('Csrf-Token', http.HeaderQueryConfig{true}) or { - return false - } - if token == app_csrf_cookie_str { - return true - } else { - return false - } -} diff --git a/vlib/vweb/csrf/structs.v b/vlib/vweb/csrf/structs.v deleted file mode 100644 index 09b648b37a..0000000000 --- a/vlib/vweb/csrf/structs.v +++ /dev/null @@ -1,30 +0,0 @@ -// This module provides csrf-protection for apps written with libe vweb. - -module csrf - -import vweb -import net.http - -type CsrfCookie = http.Cookie - -interface CheckedApp {} - -pub struct App { - vweb.Context - csrf_cookie_value string -} - -pub struct HttpOnly { - http_only bool -} - -struct CsrfError { - Error - m string -} - -fn (err CsrfError) msg() string { - return err.m -} - -// Written by flopetautschnig (floscodes) 2022