From 4b4646169b28d4b5d46f537e48c68585df5139ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20K=C3=BCthe?= <43839798+Casper64@users.noreply.github.com> Date: Sun, 4 Feb 2024 23:16:29 +0100 Subject: [PATCH] x.vweb: add cors middleware (#20713) --- examples/xvweb/cors/vweb_cors_example.v | 58 ++++++++++ vlib/x/vweb/middleware.v | 147 ++++++++++++++++++++++++ vlib/x/vweb/tests/cors_test.v | 107 +++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 examples/xvweb/cors/vweb_cors_example.v create mode 100644 vlib/x/vweb/tests/cors_test.v diff --git a/examples/xvweb/cors/vweb_cors_example.v b/examples/xvweb/cors/vweb_cors_example.v new file mode 100644 index 00000000000000..b7d11da64a98be --- /dev/null +++ b/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) +} diff --git a/vlib/x/vweb/middleware.v b/vlib/x/vweb/middleware.v index 4fb53052e5843a..c151a5c5da7e87 100644 --- a/vlib/x/vweb/middleware.v +++ b/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 @@ -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 + } + } + } +} diff --git a/vlib/x/vweb/tests/cors_test.v b/vlib/x/vweb/tests/cors_test.v new file mode 100644 index 00000000000000..13ecd1f2f9b00e --- /dev/null +++ b/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 +}