CORS implementation

Problem this snippet solves:

Pretty self-explanatory - we had to implement CORS (Cross-Origin-Resource-Sharing) where we had multiple domains, all of which had to be able to make AJAX calls to API's in our 'api.example.com' subdomain. Additionally, we had some partners who also need to be able to call our API's. In some cases, we had to pass cookies in the request.

In the past, various developers had created backend Java code to return the CORS response headers, but almost invariably they did an incomplete job - either returning an invalid value or not returning all the required headers or writing the code such that it wasn't portable across applications. Therefore, I decided to write some 'common' CORS handling code, which would have the benefit of doing 'proper' origin checking and would also immediately return the OPTIONS preflight response directly from F5, thus improving performance.

After much hacking around, here is what I came up with.

We used a class to define multiple top-level domains as 'allowed' origins - this would contain both your domains and also those of any partners whom you want to allow to make CORS requests to your site.

If you just have multiple subdomains on a single domain (e.g. www.example.com, api.example.com, code.example.com), you could simply use [HTTP::header Origin] ends_with ".example.com" - it's a little simpler.

I'm always returning the Access-Control-Allow-Credentials: true response header for 'valid' non-OPTIONS (e.g. GET/POST) CORS requests, even if it's not required (i.e. even if the withCredentials flag was not set in the request) - unfortunately, there is no way to know whether it is needed simply by looking at the request, so it's the only way to ensure client errors don't occur.

I'm passing the value of the Access-Control-Request-Method request header in the Access-Control-Allow-Methods response header (e.g. a single value of 'GET' or 'POST' or whatever) - in most implementations, you'll see people returning somethign like a string like Access-Control-Allow-Methods: GET, POST, PUT, but there's really no significant benefit to doing this - much simpler to only return what is passed. In either case, it will be cached by the browser because of the Access-Control-Max-Age response header.

Note that because you will be returning a specific Access-Control-Allow-Origin value, rather than '*', you should also return the Vary: Origin response header. This may have issues with browser caching or if you use a CDN like Akamai or Cloudflare - you should consult any CDN product documentation. There are multiple good sources for explaining the Vary header - Google is your friend.

If anyone has any comments, please add them, good or bad! I would love to know if someone finds this snippet useful...

Code :

# Domains that are allowed to make cross-domain calls to example.com
class allowed_origins {
    ".example.com"
    ".example2.com"
    ".goodpartner.com"
}

when HTTP_REQUEST {
    unset -nocomplain cors_origin
    if { [class match [HTTP::header Origin] ends_with allowed_origins] } {
        if { ( [HTTP::method] equals "OPTIONS" ) and ( [HTTP::header exists "Access-Control-Request-Method"] ) } {
            # CORS preflight request - return response immediately
            HTTP::respond 200 "Access-Control-Allow-Origin" [HTTP::header "Origin"] \
                              "Access-Control-Allow-Methods" [HTTP::header "Access-Control-Request-Method"] \
                              "Access-Control-Allow-Headers" [HTTP::header "Access-Control-Request-Headers"] \
                              "Access-Control-Max-Age" "86400" \
                              "Vary" "Origin"
        } else {
            # CORS GET/POST requests - set cors_origin variable
            set cors_origin [HTTP::header "Origin"]
        }
    }
}

when HTTP_RESPONSE {
    # CORS GET/POST response - check cors_origin variable set in request
    if { [info exists cors_origin] } {
        HTTP::header insert "Access-Control-Allow-Origin" $cors_origin
        HTTP::header insert "Access-Control-Allow-Credentials" "true"
        HTTP::header insert "Vary" "Origin"
    }
}

Tested this on version:

11.0
Published Nov 03, 2015
Version 1.0
  • Hello, I would like to Apply this Irules, but i have an error when I create this.

     

    01070151:3: Rule [/Common/IR_CROSS_DOMAIN_XXXXX.XXX] error: /Common/IR_CROSS_DOMAIN_XXXXX.XXX:2: error: [command is not valid in the current scope][class allowed_origins { ".XXXXX.XXX" }]

     

    Anyone have a idea ?

     

    Version : BIG-IP 11.5.3 Build 1.0.167 Hotfix HF1

     

  • @Kevin_Dyer note that the reference you cite for latest CORS specification is actually significantly out-of-date.

    The responsibility for CORS was taken over by WHATWG, as part of the fetch spec (https://fetch.spec.whatwg.org/). The examples aren't great, but fetch now does allow (in theory) some enhancements, such as allowing an asterisk as the value for the

    Access-Control-Allow-Headers
    ,
    Access-Control-Allow-Methods
    and
    Access-Control-Expose-Headers
    CORS response headers. I don't know whether all browsers have implemented support for these special values yet, however.

  • I have not noticed any further comment about responses to set-cookie, so I thought I would respond. Section 7.1.5 Cross-Origin Request with Preflight of the latest CORS specification (https://www.w3.org/TR/cors/) states the client shall make the OPTIONS request with the "block cookie" flag set. For newer HTTP clients this means any response to an OPTIONS request with Origin header must ignore all set-cookie headers. But as others on this thread have noted, not all HTTP clients are up to date on handling set-cookie for a preflight response. Therefore, to err on the side of caution, I too strongly recommend handling the CORS preflight requests as close to the edge of the network as possible.

     

    To piggyback on security settings I've also taken to add the header Content-Security-Policy: frame-ancestors 'self' '$cors_origin'; when a Origin header was found otherwise respond with the header Content-Security-Policy: frame-ancestors 'self';

     

  • Code snippet updated to include additional square brackets in the

    class match
    statement, as per the comment from @DrLemongelo.

  • After hours of struggling with syntax errors, support helped identify a correction I'll pass along.

    1) I wasn't able to define the class inside of the iRule and had to create a Data Group object (type: string) that contained the allowed origins as String with a blank value.

    If the though of using a GUI brings a tear to your eye, here's the CLI:

    ltm data-group internal DG-CORS-ALLOWED-ORIGINS {
        records {
            .authorizeddomain.com { }
            localhost:3456 { }
        }
        type string
    }
    

    2) The syntax in OP's iRule required the addition of more brackets.

    This lines gives me an error: /Common/IRULE-CORS:10: error: [parse error: PARSE syntax 228 {syntax error in expression " class match [HTTP::header Origin] ends_with allowed_origins...": variable references require preceding $}][{ class match [HTTP::header Origin] ends_with allowed_origins }]

    if { class match [HTTP::header Origin] ends_with DG-CORS-ALLOWED-ORIGINS }

    Enclosed class in brackets and received no syntax errors:

    if { [class match [HTTP::header Origin] ends_with DG-CORS-ALLOWED-ORIGINS] }

    This was tested in LTM 11.6.0

  • I didn't challenge the F5 support yet to know if this whole "disable set-cookie when replying to an OPTIONS request" is something that can be done out of the box (and what firmware ?) or if using an iRule is mandatory ...

     

  • @Cyril, your comment about new request pooling messing with cookies hadn't occurred to me - very good point!

     

  • Very useful code, and you can add that it solves an issue with the cookie persistence : when a browser sends a pre-flight request, by definition in RFC2616 it doesn't send any cookie with the OPTIONS method. So when the bigip receives the OPTIONS request, without cookie, it considers it as a new request, round robin the pool member, and in the http_reply the bigip includes a set_cookie ... The browser receives the set_cookie, thus drops the one it already had ... And voilà you lost your session :(

     

    So with this iRule, the bigip doesn't send the pre-flight request to the pool member, and doesn't send a set_cookie back => your browser will keep the session cookie

     

    Maybe F5 should consider a feature to not reply with a set_cookie to an OPTIONS request ?

     

  • Looks great. Reading some of the KB articles, it seems to indicate that I need the ASM module to use CORS headers. Do you know if this is true? (I have a pending ticket, but, unfortunately, this question is actively holding me up.

     

    EDIT: ASM module is not required. This can be done via iRule. F5 support pointed me specifically to this page.