May 1, 2015 | laravel, symfony, CORS, Angular, API

Laravel/Symfony, Angular, CORS, Blocked PUT/PATCH/DELETE, X-HTTP-Method-Override, and net::ERR_EMPTY_RESPONSE

!
Warning: This post is over a year old. I don't always update old posts with new information, so some of this information may be out of date.

If you couldn't tell from the title, this is going to be a very technical post that I hope saves someone from some of the pain my team just went through.

TL;DR Make your API SSL/HTTPS and you're good to go.

The backstory

Tighten built an app for a client recently using an Angular frontend and a Laravel API backend. They were running on separate domains, and worked fine for us. But when the client tried to use them, they could list and show their content but not create, edit, or delete it.

We originally thought the issue was CORS, so we wasted far too much time on that. See the Postscript about CORS to learn more about that.

Finding the real problem

I was finally able to get on a screenshare with the client, so I had them open the Chrome Web Inspector. We saw that the XHR (AJAX) requests that had the header of GET and OPTIONS were working fine, but PUT (create), PATCH (edit), and DELETE (delete) were failing. They were showing up in Chrome as red with an error saying net::ERR_EMPTY_RESPONSE, which means there was nothing coming back. No error message, no status code, nothing.

I downloaded the HAR of each to make sure there was nothing different in our OPTIONS, diffed them in Kaleidoscope, and found there was nothing of significance. The browser sent an OPTIONS request, the server sent back a reply saying it was OK to do all of those things, the browser sent a PATCH/PUT/DELETE request, and then the response on my machine (not behind firewall) was fine and the response on the client's (behind firewall) was completely empty:

        "response": {
          "status": 0,
          "statusText": "",
          "httpVersion": "unknown",
          "headers": [],
          "cookies": [],
          "content": {
            "size": 0,
            "mimeType": "x-unknown"
          },
          "redirectURL": "",
          "headersSize": -1,
          "bodySize": -1,
          "_transferSize": 0,
          "_error": "net::ERR_EMPTY_RESPONSE"
        },

You don't get any more nothing than that. So we began to suspect that this client's server was disallowing PUT/PATCH/DELETE requests, since they're a tiny bit more advanced and less common.

X-HTTP-METHOD-OVERRIDE

Since Laravel's request and response objects are extensions of Symfony's, I could take advantage of Symfony's X-HTTP-Method-Override header. If you use Laravel or Symfony, you might be familiar with how this works on a web form: You add a hidden field named _method with a value of PUT or PATCH or DELETE, submit the form via POST, and then Symfony/Laravel treat it as a PUT/PATCH/DELETE request.

If you're making a request that's not a form--for example, an AJAX request to an API--you want to do things a little differently. You want to add a header named X-HTTP-method-Override with the value of your desired method.

So, we went into Angular and changed our requests from this:

        factory.patch = function(form) {
            return $http({
                url: AppSettings.base + AppSettings.dataType.all + '/' + form.id,
                data: form,
                method: "PATCH"
            });
        }

to this:

        factory.patch = function(form) {
            return $http({
                url: AppSettings.base + AppSettings.dataType.all + '/' + form.id,
                data: form,
                method: "POST",
                headers: {
                    "X-HTTP-Method-Override": "PATCH"
                }
            });
        }

At that point, we were now sending POST requests, which means they were able to make it past the client's PUT/PATCH-stripping proxy.

Learning why

I asked on Twitter the next morning, "Has anyone ever heard of a corporate web proxy that disallows PUT and PATCH requests? Think we might be running into one but not sure." I received quite a bit of affirmative support--this is, indeed, a thing that happens often.

https://twitter.com/stauffermatt/status/586510133296934913

The HTTPS trick

Several folks pointed out that, if you use HTTPS, the proxy can't even see the method, so it can't reject certain methods. (StackOverflow)

All APIs should be HTTPS anyway, so this is why this is something I've never run into before. The only reason the site we were working on wasn't HTTPS was because it was a staging server. I've now learned my lesson. Even our staging servers are getting HTTPS.

Postscript: CORS

Our Laravel configuration

At first we thought this was a CORS error, so we fussed with CORS for ages. This is a Laravel 4 app, so this is what our configuration looked like. First, the Middleware:

<?php namespace App\Http;

use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;

class Cors implements HttpKernelInterface
{
    protected $app;

    public function __construct(HttpKernelInterface $app)
    {
        $this->app = $app;
    }

    public function handle(SymfonyRequest $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
    {
        // Handle on passed down request
        $response = $this->app->handle($request, $type, $catch);

        $response->headers->set('Access-Control-Allow-Origin' , '*', true);
        $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD', true);
        $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization, X-Requested-With', true);

        if ($request->getMethod() == 'OPTIONS') {
            $response->setStatusCode(200);
            $response->setContent(null);
        }

        return $response;
    }
}

I'm still unsure of whether the "if request is OPTIONS" block is entirely necessary, but I was trying everything I could here.

Then, the binding:

<?php namespace App\Http;

use Illuminate\Support\ServiceProvider;

class CorsProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->middleware(Cors::class);
    }
}

Finally, registering the Service Provider in app/config/app.php--I added SocialPack\Http\CorsProvider::class, to the bottom of the providers array.

Now we could sniff our response headers and see that we were getting them back just like we would want for correct CORS-ification.

Same-domain

When verifying our CORS settings didn't fix it, we tried putting the admin panel on the same domain. We moved the Angular app to api.ourServer.com/admin so that it was making calls from the same server.

Even that didn't fix it, which was when I realized it wasn't CORS at all.\

Benso-matic

Quick note: The primary developer on this project was Benson Lee, and he was with me every step of the way of discovering this solution. Gotta give credit where credit's due. :)


Comments? I'm @stauffermatt on Twitter


Tags: laravel  •  symfony  •  CORS  •  Angular  •  API

Subscribe

For quick links to fresh content, and for more thoughts that don't make it to the blog.