CORS with Symfony, jQuery, FOSRestBundle and NelmioCorsBundle

Alex Rock picture Alex Rock · Jun 29, 2015 · Viewed 8.7k times · Source

⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
TL;DR: Edit (before reading the whole thing):

This issue is solved because I made a very stupid mistake in my code, not related to CORS or anything.
If you want to read this issue anyway, just note that it has a working CORS configuration, if you might want to take a good example.

⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️

(end of note)

Situation

I have a multi-domain Symfony application, and the back-end part updates data with web-services, not classic forms, for some reasons.

Back-end: back.mydomain.dev Webservices domain: api.mydomain.dev

I am using jQuery to make AJAX calls to these webservices, and if I want to modify or create objects, I also send AJAX requests, the objects are merged with Doctrine entities and persisted.

I have been fighting for an entire year to make GET, PUT, POST and DELETE requests that would work properly on this application, and just because they're on different domains, I am forced to setup CORS on my different environments.

Setup

All jQuery AJAX request look like this:

ajaxObject = {
    url: 'http://api.mydomain.dev/' + uri,
    type: method, // Can be GET, PUT, POST or DELETE only
    dataType: 'json',
    xhrFields: {
         withCredentials: true
    },
    crossDomain: true,
    contentType: "application/json",
    jsonp: false,
    data: method === 'GET' ? data : JSON.stringify(data) // Here, "data" is ALWAYS containing a plain object. If empty, it equals to "{}"
};

// ... Add callbacks depending on requests

$.ajax(ajaxObject);

Behind, the routes are managed with Symfony.

For CORS configuration, I am using NelmioCorsBundle with this configuration:

nelmio_cors:
    paths:
       "^/":
          allow_credentials: true
          origin_regex: true
          allow_origin:
              - "^(https?://)?(back|api)\.mydomain.dev/?"
          allow_headers: ['Origin','Accept','Content-Type']
          allow_methods: ['POST','GET','DELETE','PUT','OPTIONS']
          max_age: 3600
          hosts:
              - "^(https?://)?(back|api)\.mydomain.dev/?"

The controller used is extending FOSRestBundle's one, has some security (for example, cannot POST/PUT/DELETE when you don't have the correct role), can update objects and returns only JSON data (there is a listener for that).

Ideal behavior

Ideally, I want this:

  1. Run the jQuery AJAX POST/PUT/DELETE request
  2. It has to send an OPTIONS request with all CORS headers
  3. NelmioCorsBundle should return the correct CORS headers accepting the request to be done, even before running any controller inside the app (made by the bundle's request listener)
  4. If accepted, the proper HTTP request is sent to the controller with all request data as a serialized JSON string (in the request payload), and Symfony retrieves it and interprets the correct JSON string as an array object
  5. The controller then gets the data, does its stuff, and returns an application/json response.

Problem

But here, I tried maaaaany combinations, and I can't seem to make it work.

The point n°3 and 4 are failing, completely or partially.

Tried workarounds

  1. When I don't serialize the data with JSON.stringify (see the Setup part above), the OPTIONS request is sent, but FOSRestBundle sends a BadRequestHttpException saying Invalid json message received, and it's totally normal because the "request payload" (as seen in Chrome's developer tools) is the classic application/x-www-form-urlencoded content, even if I specified contentType: "application/json" in the jQuery AJAX request, whereas it should be a serialized JSON string.

  2. However, if I do serialize the data var, the "request payload" is valid, but the OPTIONS request is not send, making the whole request fail because of the lack of CORS acceptance.

  3. If I replace contentType: "application/json" with application/x-www-form-urlencoded or multipart/form-data, and don't serialize the data, then, the request payload is valid, but the OPTIONS request is not sent. It should also be normal, as explained in jQuery's docs under the contentType parameter:

    Note: For cross-domain requests, setting the content type to anything other than application/x-www-form-urlencoded, multipart/form-data, or text/plain will trigger the browser to send a preflight OPTIONS request to the server.

    But then, how to send a correct CORS request?

Questions

  • Where does this issue comes from?
  • Is this a problem with my setup? With jQuery?
  • With AJAX itself?
  • What can be the different solutions to solve this damn issue?

Edits (after comments)

2015-06-29 17:39

Conditions:

In AJAX, the contentType is set to application/json. The data option is set to a serialized JSON string.

Preflight REQUEST headers

OPTIONS http://api.mydomain.dev/fr/object/1 HTTP/1.1
Access-Control-Request-Method: POST
Origin: http://back.mydomain.dev
Access-Control-Request-Headers: accept, content-type
Referer: http://back.mydomain.dev/...

Preflight RESPONSE headers

HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST, GET, DELETE, PUT, OPTIONS
Access-Control-Allow-Headers: origin, accept, content-type
Access-Control-Max-Age: 3600
Access-Control-Allow-Origin: http://back.mydomain.dev

POST REQUEST headers (after preflight)

POST http://api.mydomain.dev/fr/object/1 HTTP/1.1
Origin: http://back.mydomain.dev
Content-Type: application/json
Referer: http://back.mydomain.dev/...
Cookie: mydomainPortal=v5gjedn8lsagt0uucrhshn7ck1
Accept: application/json, text/javascript, */*; q=0.01

Request payload (raw): {"json":{"id":1,"name":"object"}}

Must be noted that this throws a javascript error:

XMLHttpRequest cannot load http://api.mydomain.dev/fr/object/1. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://back.mydomain.dev' is therefore not allowed access.

POST RESPONSE headers (after preflight)

HTTP/1.1 200 OK
Date: Mon, 29 Jun 2015 15:35:07 GMT
Server: Apache/2.4.12 (Unix) OpenSSL/1.0.2c Phusion_Passenger/5.0.11
Keep-Alive: timeout=5, max=91
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: text/html; charset=UTF-8

2015-06-29 17:39

I also tried using vanilla javascript to make the XMLHttpRequest, the problem is still the same:

var xhr = new XMLHttpRequest;
xhr.open('POST', 'http://api.mydomain.dev/fr/objects/1', true);
xhr.setRequestHeader('Content-type', 'application/json');
xhr.withCredentials = true; // Sends cookie
xhr.onreadystatechange = function(e) {
    // A simple callback
    console.info(JSON.parse(e.target.response));
};
// Now send the request with the serialized payload:
xhr.send('{"id":1,"name":"Updated test object"}');

Then, the browser sends an OPTIONS request:

OPTIONS http://api.mydomain.dev/fr/objects/1 HTTP/1.1
Connection: keep-alive
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST
Origin: http://back.mydomain.dev

Which returns the correct Response headers:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST, GET, DELETE, PUT, OPTIONS
Access-Control-Allow-Headers: origin, accept, content-type
Access-Control-Max-Age: 3600
Access-Control-Allow-Origin: http://back.mydomain.dev

But then, the browser sends the POST request without any "Access-Control-*" header.

Answer

Alex Rock picture Alex Rock · Jul 1, 2015

Actually, my setup worked well.
Absolutely perfectly, if I might say it.

It was just some kind of... Stupidity.

An exit; was hidden deep in a PHP file.

Sorry for annoyance. I guess it's kind of a record in a text/quality ratio on SO.

Edit: I still hope that this issue can be a proper example of a fully CORS-compatible Symfony project.