jeudi 26 novembre 2015

Programmatically add exception from CSRF check from Laravel package

The Problem in a Nutshell

I'm looking for a way to remove VerifyCsrfToken from the global middleware pipeline from within a package without the user having to modify App\Http\Middleware\VerifyCsrfToken. Is this possible?

The Use Case

I'm developing a package that would make it easy to securely add push-to-deploy functionality to any Laravel project. I'm starting with Github. Github uses webhooks to notify 3rd party apps about events, such as pushes or releases. In other words, I would register a URL like http://myapp.com/deploy at Github, and Github will send a POST request to that URL with a payload containing details about the event whenever it happens, and I could use that event to trigger a new deployment. Obviously, I don't want to trigger a deployment on the off chance that some random (or perhaps malicious) agent other than the Github service hits that URL. As such, Github has a process for securing your webhooks. This involves registering a secret key with Github that they will use to send a special, securely hashed header along with the request that you can use to verify it.

My approach to making this secure involves:

Random Unique URL/Route and Secret Key

First, I automatically generate two random, unique strings, that are stored in the .env file and used to create a secret key route within my app. In the .env file this looks like:

AUTODEPLOY_SECRET=BHBfCiC0bjIDCAGH2I54JACwKNrC2dqn
AUTODEPLOY_ROUTE=UG2Yu8QzHY6KbxvLNxcRs0HVy9lQnKsx

The config for this package creates two keys, auto-deploy.secret and auto-deploy.route that I can access when registering the route so that it never gets published in any repo:

Route::post(config('auto-deploy.route'),'MyController@index');

I can then go to Github and register my webook like this:

Github webhook registration screen

In this way, both the deployment URL and the key used to authenticate the request will remain secret, and prevent a malicious agent from triggering random deployments on the site.

Global Middleware for Authenticating Webhook Requests

The next part of the approach involves creating a piece of global middleware for the Laravel app that would catch and authenticate the webhook requests. I am able to make sure that my middleware gets executed near the beginning of the queue by using an approach demonstrated in this Laracasts discussion thread. In the ServiceProvider for my package, I can prepend a new global middleware class as follows:

public function boot(Illuminate\Contracts\Http\Kernel $kernel)
{
    // register the middleware
    $kernel->prependMiddleware(Middleware\VerifyWebhookRequest::class);
    // load my route
    include __DIR__.'/routes.php';
}

My Route looks like:

Route::post(
    config('auto-deploy.route'), [
        'as' => 'autodeployroute',
        'uses' => 'MyPackage\AutoDeploy\Controllers\DeployController@index',
    ]
);

And then my middleware would implement a handle() method that looks something like:

public function handle($request, Closure $next)
{
    if ($request->path() === config('auto-deploy.route')) {
        if ($request->secure()) {
            // handle authenticating webhook request
            if (/* webhook request is authentic */) {
                // continue on to controller
                return $next($request);
            } else {
                // abort if not authenticated
                abort(403);
            }
        } else {
            // request NOT submitted via HTTPS
            abort(403);
        }
    }
    // Passthrough if it's not our secret route
    return $next($request);
}

This function works right up until the continue on to controller bit.

The Problem in Detail

Of course the problem here is that since this is a POST request, and there is no session() and no way to get a CSRF token in advance, the global VerifyCsrfToken middleware generates a TokenMismatchException and aborts. I have read through numerous forum threads, and dug through the source code, but I can't find any clean and easy way to disable the VerifyCsrfToken middleware for this one request. I have tried several workarounds, but I don't like them for various reasons.

Workaround Attempt #1: Have user modify VerifyCsrfToken middleware

The documented and supported method for solving this problem is to add the URL to the $except array in the App\Http\Middleware\VerifyCsrfToken class, e.g.

// The URIs that should be excluded from CSRF verification
protected $except = [
    'UG2Yu8QzHY6KbxvLNxcRs0HVy9lQnKsx',
];

The problem with this, obviously, is that when this code gets checked into the repo, it will be visible to anyone who happens to look. To get around this I tried:

protected $except = [
    config('auto-deploy.route'),
];

But PHP didn't like it. I also tried using the route name here:

protected $except = [
    'autodeployroute',
];

But this doesn't work either. It has to be the actual URL. The thing that actually does work is to override the constructor:

protected $except = [];

public function __construct(Illuminate\Contracts\Encryption\Encrypter $encrypter)
{
    parent::__construct($encrypter);
    $this->except[] = config('auto-deploy.route');
}

But this would have to be part of the installation instructions, and would be an unusual install step for a Laravel package. I have a feeling this is the solution I'll end up adopting, as I guess it's not really that difficult to ask users to do this. And it has the upside of at least possibly making them conscious that the package they're about to install circumvents some of Laravel's built in security.

Workaround Attempt #2: catch the TokenMismatchException

The next thing I tried was to see if I could just catch the exception, then ignore it and move on, i.e.:

public function handle($request, Closure $next)
{
    if ($request->secure() && $request->path() === config('auto-deploy.route')) {
        if ($request->secure()) {
            // handle authenticating webhook request
            if (/* webhook request is authentic */) {
                // try to continue on to controller
                try {
                    // this will eventually trigger the CSRF verification
                    $response = $next($request);
                } catch (TokenMismatchException $e) {
                    // but, maybe we can just ignore it and move on...
                    return $response;
                }
            } else {
                // abort if not authenticated
                abort(403);
            }
        } else {
            // request NOT submitted via HTTPS
            abort(403);
        }
    }
    // Passthrough if it's not our secret route
    return $next($request);
}

Yeah, go ahead and laugh at me now. Silly wabbit, that's not how try/catch works! Of course $response is undefined within the catch block. And If I try doing $next($request) in the catch block, it just bangs up against the TokenMismatchException again.

Workaround Attempt #3: Run ALL of my code in the middleware

Of course, I could just forget about using a Controller for the deploy logic and trigger everything from the middleware's handle() method. The request lifecycle would end there, and I would never let the rest of the middleware propagate. I can't help feeling that there's something inelegant about that, and that it departs from the overall design patterns upon which Laravel is built so much that it would end up making maintenance and collaboration difficult moving forward. At least I know it would work.

Workaround Attempt #4: Modify the Pipeline

Philip Brown has an excellent tutorial describing the Pipeline pattern and how it gets implemented in Laravel. Laravel's middleware uses this pattern. I thought maybe, just maybe, there was a way to get access to the Pipeline object that queues up the middleware packages, loop through them, and remove the CSRF one for my route. Best I can tell, there are ways to add new elements to the pipeline, but no way to find out what's in it or to modify it in any way. If you know of a way, please let me know!!!

Workaround Attempt #5: Use the WithoutMiddleware trait

I haven't investigated this one quite as thoroughly, yet, but it appears that this trait was added recently to allow testing routes without having to worry about middleware. It's clearly NOT meant for production, and disabling the middleware would mean that I'd have to come up with a whole new solution for figuring out how to get my package to do its thing. I decided this was not the way to go.

Workaround Attempt #6: Give up. Just use Forge or Envoyer

Why reinvent the wheel? Why not just pay for one or both of these service that already supports push-to-deploy rather than go to the trouble of rolling my own package? Well, for one, I only pay $5/month for my server, so somehow the economics of paying another $5 or $10 per month for one of these services doesn't feel right. I'm a teacher who builds apps to support my teaching. None of them generate revenue, and although I could probably afford it, this kinda thing adds up over time.

Discussion

Okay, so I've spent the better part of two solid days banging my head against this problem, which is what brought me here looking for help. Do you have a solution? If you've read this far, perhaps you'll indulge a couple of closing thoughts.

Thought #1: Bravo to the Laravel guys for taking security seriously!

I'm really impressed with how difficult it is to write a package that circumvents the built-in security mechanisms. I'm not talking about "circumvention" in the I'm-trying-to-do-something-bad way, but in the sense that I'm trying to write a legitimate package that would save me and lots of other people time, but would, in effect, be asking them to "trust me" with the security of their applications by potentially opening them up to malicious deployment triggers. This should be tough to get right, and it is.

Thought #2: Maybe I shouldn't be doing this

Frequently if something is hard or impossible to implement in code, that is by design. Maybe it's Bad Design™ on my part to want to automate the entire installation process for this package. Maybe this is the code telling me, "Don't do that!" What do you think?

In summary, here are two questions:

  1. Do you know a way to do this that I haven't thought of?
  2. Is this bad design? Should I not do it?

Thanks for reading, and thank you for your thoughtful answers.

P.S. Before someone says it, I know this might be a duplicate, but I provided much more detail than the other poster, and he never found a solution, either.



via Chebli Mohamed

Aucun commentaire:

Enregistrer un commentaire