Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
# 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
To enable CSRF-protection for your veb app you must embed the `CsrfContext` struct
on your `Context` struct. You must also provide configuration options
(see [configuration & security](#configuration--security-considerations)).
**Example:**
```v
import veb
import veb.csrf
pub struct Context {
veb.Context
csrf.CsrfContext
}
```
Change `secret` and `allowed_hosts` in a production environment!
**Example:**
```v ignore
const csrf_config := csrf.CsrfConfig{
secret: 'my-secret'
allowed_hosts: ['*']
}
```
### Middleware
Enable CSRF protection for all routes, or a certain route(s) by using veb's middleware.
**Example:**
```v ignore
pub struct App {
veb.Middleware[Context]
}
fn main() {
mut app := &App{}
// register the CSRF middleware and pass our configuration
// protect a specific route
app.route_use('/login', csrf.middleware[Context](csrf_config))
veb.run[App, Context](mut app, 8080)
}
```
### Setting the token
For the CSRF-protection to work we have to generate an anti-CSRF token and set it
as an hidden input field on any form that will be submitted to the route we
want to protect.
**Example:**
_main.v_
```v ignore
fn (app &App) index(mut ctx) veb.Result {
// assign the config before generating the token on non-middleware routes
ctx.config = csrf_config
// this function will set a cookie header and generate a CSRF token
ctx.set_csrf_token(mut ctx)
return $veb.html()
}
@[post]
fn (app &App) login(mut ctx, password string) veb.Result {
// implement your own password validation here
if password == 'password' {
return ctx.text('You are logged in!')
} else {
return ctx.text('Invalid password!')
}
}
```
_templates/index.html_
```html
<h1>Log in</h1>
<form method="POST" action="/login">
@{ctx.csrf_token_input()}
<label for="password">Password:</label>
<input type="text" name="password" id="password" />
<button type="submit">Log in</button>
</form>
```
If we run the app with `v run main.v` and navigate to `http://localhost:8080/`
we will see the login form and we can login using the password "password".
If we remove the hidden input, by removing the line `@{ctx.csrf_token_input()}`
from our html code we will see an error message indicating that the CSRF token
is not set or invalid! By default the CSRF module sends an HTTP-403 response when
a token is invalid, if you want to send a custom response see the
[advanced usage](#advanced-usage) section.
> **Note:**
> Please read the security and configuration section! If you configure
> the CSRF module in an unsafe way, the protection will be useless.
## Advanced Usage
If you want more control over what routes are protected or what action you want to
do when a CSRF-token is invalid, you can call `csrf.protect` yourself whenever you want
to protect a route against CSRF attacks. This function returns `false` if the current CSRF token
and cookie combination is not valid.
**Example:**
```v ignore
@[post]
fn (app &App) login(mut ctx, password string) veb.Result {
if csrf.protect(mut ctx, csrf_config) == false {
// CSRF verification failed!
}
// ...
}
```
### Obtaining the anti-CSRF token
When `set_csrf_token` is called the token is stored in the `csrf_token` field. You access
this field directly to use it in an input field, or call `csrf_token_input`.
If the handler is not covered by `csrf.middleware`, assign `ctx.config = csrf_config`
first so the generated cookie and token use the same configuration.
**Example:**
```v ignore
fn (app &App) index(mut ctx) veb.Result {
ctx.config = csrf_config
token := ctx.set_csrf_token(mut ctx)
}
```
### Clearing the anti-CSRF token
If you want to remove the anti-CSRF token and the cookie header you can call `clear_csrf_token`
**Example:**
```v ignore
ctx.clear_csrf_token()
```
## 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. This is way
by default the `HTTPOnly` flag on the cookie is set to true.
See [client side CSRF][client-side-csrf] for more information.
This is a high level overview of the implementation.
## Configuration & Security Considerations
### The secret key
The secret key should be a random string that is not easily guessable.
### 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 and 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
+241
View File
@@ -0,0 +1,241 @@
module csrf
import crypto.hmac
import crypto.sha256
import encoding.base64
import net.http
import net.urllib
import rand
import time
import veb
@[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
// whether the cookie can be send only over HTTPS
secure bool
// enable printing verbose statements
verbose bool
}
pub struct CsrfContext {
pub mut:
config CsrfConfig
exempt bool
// the csrftoken that should be placed in an html form
csrf_token string
}
// set_token generates a new csrf_token and adds a Cookie to the response
pub fn (mut ctx CsrfContext) set_csrf_token[T](mut user_context T) string {
ctx.csrf_token = set_token(mut user_context, ctx.config)
return ctx.csrf_token
}
// clear the csrf token and cookie header from the context
pub fn (ctx &CsrfContext) clear_csrf_token[T](mut user_context T) {
user_context.set_cookie(http.Cookie{
name: config.cookie_name
value: ''
max_age: 0
})
}
// csrf_token_input returns an HTML hidden input containing the csrf token
pub fn (ctx &CsrfContext) csrf_token_input() veb.RawHtml {
return '<input type="hidden" name="${ctx.config.token_name}" value="${ctx.csrf_token}">'
}
// middleware returns a handler that you can use with veb's middleware
pub fn middleware[T](config CsrfConfig) veb.MiddlewareOptions[T] {
return veb.MiddlewareOptions[T]{
after: false
handler: fn [config] [T](mut ctx T) bool {
ctx.config = config
if ctx.exempt {
return true
} else if ctx.req.method in config.safe_methods {
return true
} else {
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 veb.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(), session_id, config.nonce_length)
cookie := generate_cookie(expire_time.unix(), 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: config.secure
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 veb.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 config.verbose {
eprintln('[CSRF] Token data: ${data}')
}
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()
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}')
mut actual_hash := ctx.get_cookie(config.cookie_name) or {
request_is_invalid(mut ctx)
return false
}
// old_expire := actual_hash.all_before('.')
// actual_hash = actual_hash.replace_once (old_expire, expire_timestamp.str())
// generate new hmac based on information in the http request
expected_hash := generate_cookie(expire_timestamp, expected_token, config.secret)
if config.verbose {
eprintln('[CSRF] Actual Hash: ${actual_hash}')
eprintln('[CSRF] Expected Hash: ${expected_hash}')
}
// if the new hmac matches the cookie value the request is legit
if actual_hash != expected_hash {
if config.verbose {
eprintln('[CSRF] The actual hash differs from the expected hash')
}
request_is_invalid(mut ctx)
return false
}
if config.verbose {
eprintln('[CSRF] The actual hash matches the expected hash')
}
return true
}
// check_origin_and_referer validates the `Origin` and `Referer` headers.
fn check_origin_and_referer(ctx veb.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) or { return false }
origin_url := urllib.parse(origin) or { urllib.URL{} }
valid_origin := origin_url.hostname() in config.allowed_hosts
referer := ctx.get_header(.referer) or { return false }
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 veb.Context) {
ctx.res.set_status(.forbidden)
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
}
+358
View File
@@ -0,0 +1,358 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import time
import net.http
import net.html
import os
import veb
import veb.csrf
const sport = 12385
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 12000 // milliseconds
const session_id_cookie_name = 'session_id'
const csrf_config = &csrf.CsrfConfig{
secret: 'my-256bit-secret'
allowed_hosts: ['*']
session_cookie: session_id_cookie_name
}
const allowed_origin = 'example.com'
const 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 := veb.Context{}
token := csrf.set_token(mut ctx, csrf_config)
cookie := ctx.res.header.get(.set_cookie) or { '' }
assert cookie.len != 0
assert cookie.starts_with('${csrf_config.cookie_name}=')
}
fn test_protect() {
mut ctx := veb.Context{}
token := csrf.set_token(mut ctx, csrf_config)
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: http.Request{
method: .post
}
}
ctx.req.add_cookie(name: csrf_config.cookie_name, value: cookie)
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 := veb.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.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
ctx = veb.Context{
form: {
short_time_config.token_name: token
}
req: http.Request{
method: .post
}
}
ctx.req.add_cookie(name: short_time_config.cookie_name, value: cookie)
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('')
mut req := http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.origin, 'http://${allowed_origin}')
req.add_header(.referer, 'http://${allowed_origin}/test')
mut ctx := veb.Context{
form: {
csrf_config.token_name: token
}
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('')
mut req := http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.origin, 'http://${allowed_origin}')
mut ctx := veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
mut valid := csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
req = http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.referer, 'http://${allowed_origin}/test')
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
valid = csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
req = http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
valid = csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
}
// Testing App
// ================================
pub struct Context {
veb.Context
csrf.CsrfContext
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
fn (app &App) index(mut ctx Context) veb.Result {
ctx.config = *csrf_config
ctx.set_csrf_token(mut ctx)
return ctx.html('<form action="/auth" method="post">
${ctx.csrf_token_input()}
<label for="password">Your password:</label>
<input type="text" id="password" name="password" placeholder="Your password" />
</form>')
}
@[post]
fn (app &App) auth(mut ctx Context) veb.Result {
return ctx.ok('authenticated')
}
// App cleanup function
// ======================================
pub fn (mut app App) shutdown(mut ctx Context) veb.Result {
spawn app.exit_gracefully()
return ctx.ok('good bye')
}
fn (app &App) exit_gracefully() {
eprintln('>> webserver: exit_gracefully')
time.sleep(100 * time.millisecond)
exit(0)
}
fn exit_after_timeout(mut app App, timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
eprintln('>> webserver: pid: ${os.getpid()}, exiting ...')
app.exit_gracefully()
eprintln('App timed out!')
assert true == false
}
// Tests for the App
// ======================================
fn test_run_app_in_background() {
mut app := &App{}
app.route_use('/auth', csrf.middleware[Context](csrf_config))
spawn exit_after_timeout(mut app, exit_after_time)
spawn veb.run_at[App, Context](mut app, port: sport, family: .ip)
_ := <-app.started
}
fn test_token_input() {
res := http.get('http://${localserver}/') or { panic(err) }
mut doc := html.parse(res.body)
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
assert inputs.len == 1
assert csrf_config.token_name == inputs[0].attributes['name']
}
fn test_token_roundtrip_from_index_form() {
res := http.get('http://${localserver}/') or { panic(err) }
assert res.status() == .ok
cookies := res.cookies()
assert cookies.len == 1
mut doc := html.parse(res.body)
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
assert inputs.len == 1
token := inputs[0].attributes['value']
assert token.len != 0
mut req := http.Request{
method: .post
url: 'http://${localserver}/auth'
data: http.url_encode_form_data({
csrf_config.token_name: token
})
}
for cookie in cookies {
req.add_cookie(cookie)
}
post_res := req.do() or { panic(err) }
assert post_res.status() == .ok
assert post_res.body == 'authenticated'
}
// 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
header: header
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_cookie(name: session_id_cookie_name, value: 'altered')
res = req.do() or { panic(err) }
assert res.status() == .forbidden
req = http.Request{
method: .post
url: 'http://${localserver}/${path}'
data: formdata
header: header
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_cookie(name: session_id_cookie_name, value: session_id)
// Everything is valid now and the request should succeed, since session_id_cookie_name will be session_id
res = req.do() or { panic(err) }
assert res.status() == .ok
}
fn test_protect_app() {
protect_route_util('/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 := veb.Context{}
ctx.req.add_cookie(name: session_id_cookie_name, value: session_id)
token := csrf.set_token(mut ctx, csrf_config_origin)
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
return token, cookie
}