Skip to content

Commit

Permalink
x.vweb: add cors middleware (#20713)
Browse files Browse the repository at this point in the history
  • Loading branch information
Casper64 committed Feb 4, 2024
1 parent a80af0f commit 4b46461
Show file tree
Hide file tree
Showing 3 changed files with 312 additions and 0 deletions.
58 changes: 58 additions & 0 deletions examples/xvweb/cors/vweb_cors_example.v
@@ -0,0 +1,58 @@
import time
import x.vweb

// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
// and https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
// > Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows
// > a server to indicate any origins (domain, scheme, or port) other than its own from
// > which a browser should permit loading resources...

// Usage: do `./v run examples/xvweb/cors/` to start the app,
// then check the headers in another shell:
//
// 1) `curl -vvv -X OPTIONS http://localhost:45678/time`
// 2) `curl -vvv -X POST http://localhost:45678/time`

pub struct Context {
vweb.Context
}

pub struct App {
vweb.Middleware[Context]
}

// time is a simple POST request handler, that returns the current time. It should be available
// to JS scripts, running on arbitrary other origins/domains.
@[post]
pub fn (app &App) time(mut ctx Context) vweb.Result {
return ctx.json({
'time': time.now().format_ss_milli()
})
}

fn main() {
println("
To test, if CORS works, copy this JS snippet, then go to for example https://stackoverflow.com/ ,
press F12, then paste the snippet in the opened JS console. You should see the vweb server's time:
var xhr = new XMLHttpRequest();
xhr.onload = function(data) {
console.log('xhr loaded');
console.log(xhr.response);
};
xhr.open('POST', 'http://localhost:45678/time');
xhr.send();
")

mut app := &App{}

// use vweb's cors middleware to handle CORS requests
app.use(vweb.cors[Context](vweb.CorsOptions{
// allow CORS requests from every domain
origins: ['*']
// allow CORS requests with the following request methods:
allowed_methods: [.get, .head, .patch, .put, .post, .delete]
}))

vweb.run[App, Context](mut app, 45678)
}
147 changes: 147 additions & 0 deletions vlib/x/vweb/middleware.v
@@ -1,6 +1,7 @@
module vweb

import compress.gzip
import net.http

pub type MiddlewareHandler[T] = fn (mut T) bool

Expand Down Expand Up @@ -173,3 +174,149 @@ pub fn decode_gzip[T]() MiddlewareOptions[T] {
interface HasBeforeRequest {
before_request()
}

pub const cors_safelisted_response_headers = [http.CommonHeader.cache_control, .content_language,
.content_length, .content_type, .expires, .last_modified, .pragma].map(it.str())

// CorsOptions is used to set CORS response headers.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#the_http_response_headers
@[params]
pub struct CorsOptions {
pub:
// from which origin(s) can cross-origin requests be made; `Access-Control-Allow-Origin`
origins []string @[required]
// indicate whether the server allows credentials, e.g. cookies, in cross-origin requests.
// ;`Access-Control-Allow-Credentials`
allow_credentials bool
// allowed HTTP headers for a cross-origin request; `Access-Control-Allow-Headers`
allowed_headers []string = ['*']
// allowed HTTP methods for a cross-origin request; `Access-Control-Allow-Methods`
allowed_methods []http.Method
// indicate if clients are able to access other headers than the "CORS-safelisted"
// response headers; `Access-Control-Expose-Headers`
expose_headers []string
// how long the results of a preflight requets can be cached, value is in seconds
// ; `Access-Control-Max-Age`
max_age ?int
}

// set_headers adds the CORS headers on the response
pub fn (options &CorsOptions) set_headers(mut ctx Context) {
// A browser will reject a CORS request when the Access-Control-Allow-Origin header
// is not present. By not setting the CORS headers when an invalid origin is supplied
// we force the browser to reject the preflight and the actual request.
origin := ctx.req.header.get(.origin) or { return }
if options.origins != ['*'] && origin !in options.origins {
return
}

ctx.set_header(.access_control_allow_origin, origin)
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')

// dont' set the value of `Access-Control-Allow-Credentials` to 'false', but
// omit the header if the value is `false`
if options.allow_credentials {
ctx.set_header(.access_control_allow_credentials, 'true')
}

if options.allowed_headers.len > 0 {
ctx.set_header(.access_control_allow_headers, options.allowed_headers.join(','))
} else if _ := ctx.req.header.get(.access_control_request_headers) {
// a server must respond with `Access-Control-Allow-Headers` if
// `Access-Control-Request-Headers` is present in a preflight request
ctx.set_header(.access_control_allow_headers, vweb.cors_safelisted_response_headers.join(','))
}

if options.allowed_methods.len > 0 {
method_str := options.allowed_methods.str().trim('[]')
ctx.set_header(.access_control_allow_methods, method_str)
}

if options.expose_headers.len > 0 {
ctx.set_header(.access_control_expose_headers, options.expose_headers.join(','))
}

if max_age := options.max_age {
ctx.set_header(.access_control_max_age, max_age.str())
}
}

// validate_request checks if a cross-origin request is made and verifies the CORS
// headers. If a cross-origin request is invalid this method will send a response
// using `ctx`.
pub fn (options &CorsOptions) validate_request(mut ctx Context) bool {
origin := ctx.req.header.get(.origin) or { return true }
if options.origins != ['*'] && origin !in options.origins {
ctx.res.set_status(.forbidden)
ctx.text('invalid CORS origin')

$if vweb_trace_cors ? {
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid origin')
}
return false
}

ctx.set_header(.access_control_allow_origin, origin)
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')

// validate request method
if ctx.req.method !in options.allowed_methods {
ctx.res.set_status(.method_not_allowed)
ctx.text('${ctx.req.method} requests are not allowed')

$if vweb_trace_cors ? {
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid request method: ${ctx.req.method}')
}
return false
}

if options.allowed_headers.len > 0 && options.allowed_headers != ['*'] {
// validate request headers
for header in ctx.req.header.keys() {
if header !in options.allowed_headers {
ctx.res.set_status(.forbidden)
ctx.text('invalid Header "${header}"')

$if vweb_trace_cors ? {
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"')
}
return false
}
}
}

$if vweb_trace_cors ? {
eprintln('[vweb]: received CORS request from "${origin}": HTTP ${ctx.req.method} ${ctx.req.url}')
}

return true
}

// cors handles cross-origin requests by adding Access-Control-* headers to a
// preflight request and validating the headers of a cross-origin request.
// Example:
// ```v
// app.use(vweb.cors[Context](vweb.CorsOptions{
// origin: '*'
// allowed_methods: [.get, .head, .patch, .put, .post, .delete]
// }))
// ```
pub fn cors[T](options CorsOptions) MiddlewareOptions[T] {
return MiddlewareOptions[T]{
handler: fn [options] [T](mut ctx T) bool {
if ctx.req.method == .options {
// preflight request
options.set_headers(mut ctx.Context)
ctx.text('ok')
return false
} else {
// check if there is a cross-origin request
if options.validate_request(mut ctx.Context) == false {
return false
}
// no cross-origin request / valid cross-origin request
return true
}
}
}
}
107 changes: 107 additions & 0 deletions vlib/x/vweb/tests/cors_test.v
@@ -0,0 +1,107 @@
import x.vweb
import net.http
import os
import time

const port = 13012
const localserver = 'http://localhost:${port}'
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
const cors_options = vweb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [.get, .head]
}

pub struct Context {
vweb.Context
}

pub struct App {
vweb.Middleware[Context]
mut:
started chan bool
}

pub fn (mut app App) before_accept_loop() {
app.started <- true
}

pub fn (app &App) index(mut ctx Context) vweb.Result {
return ctx.text('index')
}

@[post]
pub fn (app &App) post(mut ctx Context) vweb.Result {
return ctx.text('post')
}

fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()

mut app := &App{}
app.use(vweb.cors[Context](cors_options))

spawn vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2)
// app startup time
_ := <-app.started
}

fn test_valid_cors() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: allowed_origin
})
})!

assert x.status() == .ok
assert x.body == 'index'
}

fn test_preflight() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .options
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'ok'

assert x.header.get(.access_control_allow_origin)! == allowed_origin
if _ := x.header.get(.access_control_allow_credentials) {
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
}
assert x.header.get(.access_control_allow_methods)! == 'GET, HEAD'
}

fn test_invalid_origin() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: 'https://google.com'
})
})!

assert x.status() == .forbidden
}

fn test_invalid_method() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/post'
method: .post
header: http.new_header_from_map({
.origin: allowed_origin
})
})!

assert x.status() == .method_not_allowed
}

0 comments on commit 4b46461

Please sign in to comment.