Skip to content

Commit 4b46461

Browse files
authored
x.vweb: add cors middleware (#20713)
1 parent a80af0f commit 4b46461

3 files changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import time
2+
import x.vweb
3+
4+
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
5+
// and https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
6+
// > Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows
7+
// > a server to indicate any origins (domain, scheme, or port) other than its own from
8+
// > which a browser should permit loading resources...
9+
10+
// Usage: do `./v run examples/xvweb/cors/` to start the app,
11+
// then check the headers in another shell:
12+
//
13+
// 1) `curl -vvv -X OPTIONS http://localhost:45678/time`
14+
// 2) `curl -vvv -X POST http://localhost:45678/time`
15+
16+
pub struct Context {
17+
vweb.Context
18+
}
19+
20+
pub struct App {
21+
vweb.Middleware[Context]
22+
}
23+
24+
// time is a simple POST request handler, that returns the current time. It should be available
25+
// to JS scripts, running on arbitrary other origins/domains.
26+
@[post]
27+
pub fn (app &App) time(mut ctx Context) vweb.Result {
28+
return ctx.json({
29+
'time': time.now().format_ss_milli()
30+
})
31+
}
32+
33+
fn main() {
34+
println("
35+
To test, if CORS works, copy this JS snippet, then go to for example https://stackoverflow.com/ ,
36+
press F12, then paste the snippet in the opened JS console. You should see the vweb server's time:
37+
38+
var xhr = new XMLHttpRequest();
39+
xhr.onload = function(data) {
40+
console.log('xhr loaded');
41+
console.log(xhr.response);
42+
};
43+
xhr.open('POST', 'http://localhost:45678/time');
44+
xhr.send();
45+
")
46+
47+
mut app := &App{}
48+
49+
// use vweb's cors middleware to handle CORS requests
50+
app.use(vweb.cors[Context](vweb.CorsOptions{
51+
// allow CORS requests from every domain
52+
origins: ['*']
53+
// allow CORS requests with the following request methods:
54+
allowed_methods: [.get, .head, .patch, .put, .post, .delete]
55+
}))
56+
57+
vweb.run[App, Context](mut app, 45678)
58+
}

vlib/x/vweb/middleware.v

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module vweb
22

33
import compress.gzip
4+
import net.http
45

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

@@ -173,3 +174,149 @@ pub fn decode_gzip[T]() MiddlewareOptions[T] {
173174
interface HasBeforeRequest {
174175
before_request()
175176
}
177+
178+
pub const cors_safelisted_response_headers = [http.CommonHeader.cache_control, .content_language,
179+
.content_length, .content_type, .expires, .last_modified, .pragma].map(it.str())
180+
181+
// CorsOptions is used to set CORS response headers.
182+
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#the_http_response_headers
183+
@[params]
184+
pub struct CorsOptions {
185+
pub:
186+
// from which origin(s) can cross-origin requests be made; `Access-Control-Allow-Origin`
187+
origins []string @[required]
188+
// indicate whether the server allows credentials, e.g. cookies, in cross-origin requests.
189+
// ;`Access-Control-Allow-Credentials`
190+
allow_credentials bool
191+
// allowed HTTP headers for a cross-origin request; `Access-Control-Allow-Headers`
192+
allowed_headers []string = ['*']
193+
// allowed HTTP methods for a cross-origin request; `Access-Control-Allow-Methods`
194+
allowed_methods []http.Method
195+
// indicate if clients are able to access other headers than the "CORS-safelisted"
196+
// response headers; `Access-Control-Expose-Headers`
197+
expose_headers []string
198+
// how long the results of a preflight requets can be cached, value is in seconds
199+
// ; `Access-Control-Max-Age`
200+
max_age ?int
201+
}
202+
203+
// set_headers adds the CORS headers on the response
204+
pub fn (options &CorsOptions) set_headers(mut ctx Context) {
205+
// A browser will reject a CORS request when the Access-Control-Allow-Origin header
206+
// is not present. By not setting the CORS headers when an invalid origin is supplied
207+
// we force the browser to reject the preflight and the actual request.
208+
origin := ctx.req.header.get(.origin) or { return }
209+
if options.origins != ['*'] && origin !in options.origins {
210+
return
211+
}
212+
213+
ctx.set_header(.access_control_allow_origin, origin)
214+
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
215+
216+
// dont' set the value of `Access-Control-Allow-Credentials` to 'false', but
217+
// omit the header if the value is `false`
218+
if options.allow_credentials {
219+
ctx.set_header(.access_control_allow_credentials, 'true')
220+
}
221+
222+
if options.allowed_headers.len > 0 {
223+
ctx.set_header(.access_control_allow_headers, options.allowed_headers.join(','))
224+
} else if _ := ctx.req.header.get(.access_control_request_headers) {
225+
// a server must respond with `Access-Control-Allow-Headers` if
226+
// `Access-Control-Request-Headers` is present in a preflight request
227+
ctx.set_header(.access_control_allow_headers, vweb.cors_safelisted_response_headers.join(','))
228+
}
229+
230+
if options.allowed_methods.len > 0 {
231+
method_str := options.allowed_methods.str().trim('[]')
232+
ctx.set_header(.access_control_allow_methods, method_str)
233+
}
234+
235+
if options.expose_headers.len > 0 {
236+
ctx.set_header(.access_control_expose_headers, options.expose_headers.join(','))
237+
}
238+
239+
if max_age := options.max_age {
240+
ctx.set_header(.access_control_max_age, max_age.str())
241+
}
242+
}
243+
244+
// validate_request checks if a cross-origin request is made and verifies the CORS
245+
// headers. If a cross-origin request is invalid this method will send a response
246+
// using `ctx`.
247+
pub fn (options &CorsOptions) validate_request(mut ctx Context) bool {
248+
origin := ctx.req.header.get(.origin) or { return true }
249+
if options.origins != ['*'] && origin !in options.origins {
250+
ctx.res.set_status(.forbidden)
251+
ctx.text('invalid CORS origin')
252+
253+
$if vweb_trace_cors ? {
254+
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid origin')
255+
}
256+
return false
257+
}
258+
259+
ctx.set_header(.access_control_allow_origin, origin)
260+
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
261+
262+
// validate request method
263+
if ctx.req.method !in options.allowed_methods {
264+
ctx.res.set_status(.method_not_allowed)
265+
ctx.text('${ctx.req.method} requests are not allowed')
266+
267+
$if vweb_trace_cors ? {
268+
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid request method: ${ctx.req.method}')
269+
}
270+
return false
271+
}
272+
273+
if options.allowed_headers.len > 0 && options.allowed_headers != ['*'] {
274+
// validate request headers
275+
for header in ctx.req.header.keys() {
276+
if header !in options.allowed_headers {
277+
ctx.res.set_status(.forbidden)
278+
ctx.text('invalid Header "${header}"')
279+
280+
$if vweb_trace_cors ? {
281+
eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"')
282+
}
283+
return false
284+
}
285+
}
286+
}
287+
288+
$if vweb_trace_cors ? {
289+
eprintln('[vweb]: received CORS request from "${origin}": HTTP ${ctx.req.method} ${ctx.req.url}')
290+
}
291+
292+
return true
293+
}
294+
295+
// cors handles cross-origin requests by adding Access-Control-* headers to a
296+
// preflight request and validating the headers of a cross-origin request.
297+
// Example:
298+
// ```v
299+
// app.use(vweb.cors[Context](vweb.CorsOptions{
300+
// origin: '*'
301+
// allowed_methods: [.get, .head, .patch, .put, .post, .delete]
302+
// }))
303+
// ```
304+
pub fn cors[T](options CorsOptions) MiddlewareOptions[T] {
305+
return MiddlewareOptions[T]{
306+
handler: fn [options] [T](mut ctx T) bool {
307+
if ctx.req.method == .options {
308+
// preflight request
309+
options.set_headers(mut ctx.Context)
310+
ctx.text('ok')
311+
return false
312+
} else {
313+
// check if there is a cross-origin request
314+
if options.validate_request(mut ctx.Context) == false {
315+
return false
316+
}
317+
// no cross-origin request / valid cross-origin request
318+
return true
319+
}
320+
}
321+
}
322+
}

vlib/x/vweb/tests/cors_test.v

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import x.vweb
2+
import net.http
3+
import os
4+
import time
5+
6+
const port = 13012
7+
const localserver = 'http://localhost:${port}'
8+
const exit_after = time.second * 10
9+
const allowed_origin = 'https://vlang.io'
10+
const cors_options = vweb.CorsOptions{
11+
origins: [allowed_origin]
12+
allowed_methods: [.get, .head]
13+
}
14+
15+
pub struct Context {
16+
vweb.Context
17+
}
18+
19+
pub struct App {
20+
vweb.Middleware[Context]
21+
mut:
22+
started chan bool
23+
}
24+
25+
pub fn (mut app App) before_accept_loop() {
26+
app.started <- true
27+
}
28+
29+
pub fn (app &App) index(mut ctx Context) vweb.Result {
30+
return ctx.text('index')
31+
}
32+
33+
@[post]
34+
pub fn (app &App) post(mut ctx Context) vweb.Result {
35+
return ctx.text('post')
36+
}
37+
38+
fn testsuite_begin() {
39+
os.chdir(os.dir(@FILE))!
40+
spawn fn () {
41+
time.sleep(exit_after)
42+
assert true == false, 'timeout reached!'
43+
exit(1)
44+
}()
45+
46+
mut app := &App{}
47+
app.use(vweb.cors[Context](cors_options))
48+
49+
spawn vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2)
50+
// app startup time
51+
_ := <-app.started
52+
}
53+
54+
fn test_valid_cors() {
55+
x := http.fetch(http.FetchConfig{
56+
url: localserver
57+
method: .get
58+
header: http.new_header_from_map({
59+
.origin: allowed_origin
60+
})
61+
})!
62+
63+
assert x.status() == .ok
64+
assert x.body == 'index'
65+
}
66+
67+
fn test_preflight() {
68+
x := http.fetch(http.FetchConfig{
69+
url: localserver
70+
method: .options
71+
header: http.new_header_from_map({
72+
.origin: allowed_origin
73+
})
74+
})!
75+
assert x.status() == .ok
76+
assert x.body == 'ok'
77+
78+
assert x.header.get(.access_control_allow_origin)! == allowed_origin
79+
if _ := x.header.get(.access_control_allow_credentials) {
80+
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
81+
}
82+
assert x.header.get(.access_control_allow_methods)! == 'GET, HEAD'
83+
}
84+
85+
fn test_invalid_origin() {
86+
x := http.fetch(http.FetchConfig{
87+
url: localserver
88+
method: .get
89+
header: http.new_header_from_map({
90+
.origin: 'https://google.com'
91+
})
92+
})!
93+
94+
assert x.status() == .forbidden
95+
}
96+
97+
fn test_invalid_method() {
98+
x := http.fetch(http.FetchConfig{
99+
url: '${localserver}/post'
100+
method: .post
101+
header: http.new_header_from_map({
102+
.origin: allowed_origin
103+
})
104+
})!
105+
106+
assert x.status() == .method_not_allowed
107+
}

0 commit comments

Comments
 (0)