rexyai/RestRserve

Missing CORS response headers to some browsers' pre-flight checks?

Opened this issue · 2 comments

Describe the issue

Response headers to pre-flight requests from some browsers when using javascript fetch() when Bearer authentication is used do not include CORS headers, causing some browsers (Chrome, Safari, Edge) to reject the response. Firefox does not seem to do a pre-flight check so it is not affected.

To Reproduce

The example below is adapted from the examples in the docs.

library(RestRserve)

allowed_tokens = c(
  "super_secure_token_1"
)

auth_fun = function(token) {
  res = FALSE
  try({
    res = token %in% allowed_tokens
  }, silent = TRUE)
  return(res)
}
basic_auth_backend = AuthBackendBearer$new(FUN = auth_fun)

auth_mw = AuthMiddleware$new(
  auth_backend = basic_auth_backend, 
  routes = "/secure/", 
  match = "partial",
  id = "auth_middleware"
)

app1 = Application$new()

app2 = Application$new(
  middleware = list(
    auth_mw,
    RestRserve::CORSMiddleware$new()
  )
)

main_html = "
<html><body>
Testing.
<script>
  // Insecure page
  fetch('http://localhost:8080/hello2')
  .then(response => {
    return response.text()
  })
  .then(content => {
    document.querySelector('#hello2').innerHTML = content
  });
  // Secure page
  fetch('http://localhost:8080/secure/hello1',
    { headers: {'Authorization': 'Bearer super_secure_token_1'} }
  )
  .then(response => {
    return response.text()
  })
  .then(content => {
    document.querySelector('#hello1').innerHTML = content
  });
</script>
<div id='hello1'>HELLO1</div>
<div id='hello2'>HELLO2</div>

</body>
</html>
"

app1$add_get("/", function(req, res) {
  res$set_content_type("text/html")
  res$set_body(main_html)
})

app2$add_get("/secure/hello1", function(req, res) {res$body = "OK"})
app2$add_get("/hello2", function(req, res) {res$body = "OK"})

app2$logger$set_log_level("debug")

backend = RestRserve::BackendRserve$new()
backend$start(app2, http_port = 8080, background = TRUE)
backend$start(app1, http_port = 8081)

After starting the script, this works in a terminal:

# curl http://localhost:8080/secure/hello1 -H 'Authorization: Bearer super_secure_token_1'
OK

Chrome, Safari, Edge

Under

  • Chrome 113.0.5672.92 (Official Build) (arm64),
  • Safari 16.4 (18615.1.26.110.1), and
  • Edge 113.0.1774.35 (Official build) (arm64),

this does not work. The fetch to the secure page fails, and I see

Testing.
HELLO1
OK

In the developers' console in Chrome, I see

Access to fetch at 'http://localhost:8080/secure/hello1' from origin 'http://localhost:8081' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
GET http://localhost:8080/secure/hello1 net::ERR_FAILED
Uncaught (in promise) TypeError: Failed to fetch
Developers' console Chome 1

Under "Headers" for the pre-flight, I see:

Developers' console Chome 2

So indeed, the headers don't seem to include Access-Control-Allow-Origin: * as I would expect, and as the request to /hello2 does.

Looking at the RestRserve logs confirms this:

{"timestamp":"2023-05-09 21:22:22.344223","level":"DEBUG","name":"Application","pid":65018,"msg":"","context":{"request_id":"3456b2d8-eea7-11ed-a99a-ae6922d47386","response":{"status_code":401,"headers":{"WWW-Authenticate":"Basic"}}}}

Expected behavior

Under Firefox 112.0.2 (64-bit) this works as expected.

When I load http://localhost:8081 in a browser, I expect to see

Testing.
OK
OK

The two fetch calls to the server at localhost:8080 should replace HELLO1 and HELLO2 with the secure content and the insecure content, respectively.

When I look at the RestRserve logs, it seems like Firefox does not make a pre-flight check; instead, it just sends over the request in a GET request with the token, and RestRserve obliges with a proper response with CORS headers:

{"timestamp":"2023-05-09 21:28:00.826798","level":"DEBUG","name":"Application","pid":65118,"msg":"","context":{"request_id":"fe17904c-eea7-11ed-8461-ae6922d47386","request":{"method":"GET","path":"/secure/hello1","parameters_query":{},"parameters_path":[],"headers":{"sec-fetch-site":"same-site","connection":"keep-alive","dnt":"1","origin":"http://localhost:8081","authorization":"Bearer super_secure_token_1","sec-fetch-dest":"empty","referer":"http://localhost:8081/","accept-encoding":["gzip","deflate","br"],"host":"localhost:8080","accept-language":["en-GB","en;q=0.5"],"sec-fetch-mode":"cors","accept":"*/*","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/112.0"}}}}
{"timestamp":"2023-05-09 21:28:00.829188","level":"DEBUG","name":"Application","pid":65118,"msg":"","context":{"request_id":"fe17904c-eea7-11ed-8461-ae6922d47386","response":{"status_code":200,"headers":{"Server":"RestRserve/1.2.1; Rserve/1.8.11","Access-Control-Allow-Origin":"*"}}}}

Environment information

> sessionInfo()
R version 4.2.1 (2022-06-23)
Platform: aarch64-apple-darwin20 (64-bit)
Running under: macOS Ventura 13.3.1

Matrix products: default
LAPACK: /Library/Frameworks/R.framework/Versions/4.2-arm64/Resources/lib/libRlapack.dylib

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

loaded via a namespace (and not attached):
[1] compiler_4.2.1 tools_4.2.1    renv_0.15.5   

On further exploration, this is what I think is happening:

  1. Authentication middleware looks at the request, and since it doesn't have the authorization header in it (because it is a pre-flight request) it sets the status to 401 (when the token is parsed).
  2. The CORS middleware will only add the headers if the status is <300, so it doesn't get any CORS headers (when the CORS middleware runs).
  3. RestRserve sends the response to the pre-flight with 401 and no CORS headers.

In order to (temporarily?) work around this, I wrote a middleware to send 200 for specific OPTIONS requests:

handle_preflight_401_mw = Middleware$new(
   process_response = function(request, response){
     if(
       request$method == 'OPTIONS' &&
       'authorization' %in% request$headers[['access-control-request-headers']] &&
       request$headers[['access-control-request-method']] == 'GET'
     )
       response$set_status_code(200)
   },
   id = "handle_preflight_401"
 ) # Put between CORS and auth middlewares

I think the thing with Firefox was a red herring: a CORS cache issue. At any rate, with the above middleware it seems to work in Chrome. If this is a bad idea, or there is some other solution, I'd love to know.

Thanks for reporting, looks like we need to update authorization middleware to not check authorization header for OPTIONS requests - https://stackoverflow.com/a/52072116/1069256