Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions examples/xvweb/cors/vweb_cors_example.v
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
}