Wednesday, November 19, 2014

Handling CORS headers with spray-routing

One of those things that crops up every so often is the need to run a REST API on a different host or port than the client application that consumes it. This frequently arises during development, when you might have a client server on localhost:8080 talking to an API on localhost:8081, and you don't have fancy load-balancing to make them both use the same URL.

The problem here is that modern browsers restrict "cross-origin" requests; that is, XHR / Javascript requests from a web page on one site from accessing another site. This is a good thing - it prevents cross-site request forgery, and makes the internet a better place.

Except for API developers, for which it is a huge pain.

The symptom of this is an error in your javascript console like:

XMLHttpRequest cannot load http://localhost:8081/api. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8080' is therefore not allowed access.

. . . which results in a bunch of Googling. If you're lucky, you'll end up at the CORS specification page.

After a bit of hacking, the end result, in Spray, is frequently code like:

object ApiServer extends SimpleRoutingApp {
  // TODO: Fix this!!! It's totally insecure . . .
  val AccessControlAllowAll = HttpHeaders.RawHeader(
    "Access-Control-Allow-Origin", "*"
  )
  val AccessControlAllowHeadersAll = HttpHeaders.RawHeader(
    "Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"
  )
  startServer(interface = "0.0.0.0", port = 8081) {
    respondWithHeaders(AccessControlAllowAll, AccessControlAllowHeadersAll) {
      options {
        complete {
          ""
        }
      } ~
      myInnerRoute
    }
  }
}

Note the comment - this code is completely open to cross-site attacks! If you're coding defensively, you might only return the headers in development mode - but that doesn't solve the problem if your production servers need to support cross-site requests.

Sadly, spray-routing doesn't offer much help for this - but it DOES offer a fairly easy way to define your own directive to handle the boilerplate correctly.

What we want is to have a set of allowed hosts that can use our API, and we want to echo back the Origin header of the request if the host is in the allowed set. We could also require HTTPS for extra security, but a redirect could do that as well. The other key component (seen above) is a handler for the OPTIONS method, which is used to find out what security settings the API requires.

This is an allowHosts directive that does the right thing:

import spray.http.{ HttpHeaders, HttpOrigin, SomeOrigins }
import spray.routing.Directive0
import spray.routing.Directives._

/** Directive providing CORS header support. This should be included in any application serving
  * a REST API that's queried cross-origin (from a different host than the one serving the API).
  * See http://www.w3.org/TR/cors/ for full specification.
  * @param allowedHostnames the set of hosts that are allowed to query the API. These should
  * not include the scheme or port; they're matched only against the hostname of the Origin
  * header.
  */
def allowHosts(allowedHostnames: Set[String]): Directive0 = mapInnerRoute { innerRoute =>
  // Conditionally responds with "allowed" CORS headers, if the request origin's host is in the
  // allowed set, or if the request doesn't have an origin.
  optionalHeaderValueByType[HttpHeaders.Origin]() { originOption =>
    // If Origin is set and the host is in our allowed set, add CORS headers and pass through.
    originOption flatMap {
      case HttpHeaders.Origin(list) => list.find {
        case HttpOrigin(_, HttpHeaders.Host(hostname, _)) => allowedHostnames.contains(hostname)
      }
    } map { goodOrigin =>
      respondWithHeaders(
        HttpHeaders.`Access-Control-Allow-Headers`(
          Seq("Origin", "X-Requested-With", "Content-Type", "Accept"),
        HttpHeaders.`Access-Control-Allow-Origin`(SomeOrigins(Seq(goodOrigin)))
      ) {
        options {
          complete {
            ""
          }
        } ~
        innerRoute
      }
    } getOrElse {
      // Else, pass through without headers.
      innerRoute
    }
  }
}

2 comments:

Olalekan Elesin said...

Tried your solution. worked for me. but when I post form data, using formFields directive, I get this error No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost' is therefore not allowed access. The response had HTTP status code 415.

What do you think I can do about this? Here is my code:



respondWithHeaders(AccessControlAllowAll, AccessControlAllowHeadersAll, AllowedContentType){
options{
ctx => ctx.complete(StatusCodes.OK)
} ~ post {
formFields ('username, 'password) {(username, password) =>
if(username.isEmpty()){
ctx => ctx.complete(APIresponse.errorResponse("Email field is empty"))
}else if(password.isEmpty()){
ctx => ctx.complete(APIresponse.errorResponse("Password field is empty"))
}else{
response = userLogic.userLogin(username, password)
ctx => ctx.complete(response)
}
}
}
}


Thanks in advance

Josh said...

Thanks for the article. I have a question though about why your directive allows the innerRoute to run even if the Origin header does not specify an allowed domain. I understand that this is fine for GET requests - since the response will not include the CORS headers the browser will not allow the response to be read. But what about requests that modify state, like POST, PUT or DELETE? Your directive allows these methods to run even if the Origin header specifies an incorrect domain. Doesn't that make it possible for an attacker to modify state on the server with a request coming from a bad origin?