Using HTTPS over a reverse proxy in Laravel

Jan 22, 2021 by Zacharia | 988 views

Laravel PHP

https://cylab.be/blog/122/using-https-over-a-reverse-proxy-in-laravel

Have you ever encountered problems trying to deploy a Laravel HTTPS website using a reverse proxy ? Then read the following to quickly learn where this problem comes from and how to simply thwart it.

Using HTTP or HTTPS based on your .env to make requests

If you use a proxy, here is how the website host server, the proxy server and the client machine probably communicate:

This means that whenever the client requests a page from the website, the page will be fetched from the proxy through HTTPS. That proxy will receive the HTTPS request from the client and then fetch the page from the website host through HTTP to send it back to the client through HTTPS. But before being sent, the page is assembled by Laravel (by the website host) that writes the location of the resources (JS scripts, CSS templates...) on that page using HTTP URLs, as asked by the proxy. When the page is retrieved by the client, it will try to retrieve those resources from their HTTP locations that will not be attainable because the proxy expects HTTPS requests from the client.

Here is however a very simple way to counter that.

Add before all the routes of routes/web.php:

$app_url = config("app.url");
if (!empty($app_url)) {
    URL::forceRootUrl($app_url);
    $schema = explode(':', $app_url)[0];
    URL::forceScheme($schema);
}

// Your routes

This will retrieve the application URL from the .env file and force all the incoming requests to use HTTP or HTTPS depending on the APP_URL written in your .env file. Speaking of the devil, you must now precise in APP_URL which port you're using if you use a port different than the default ones (80 for HTTP and 443 for HTTPS). Concretely, here could be the value of APP_URL in your development .env file:

APP_URL=http://localhost:8000

You can see that the port 8000 is precised. And here could be the value of APP_URL in your production .env file:

APP_URL=https://my-https-website.org

Now everything should work as intended! But you might still want to read the next section...

Using HTTP or HTTPS based on your .env to sign URLs

If you use the Laravel UI authentication and got signature problems, know that they're also probably due to the use of HTTPS over a reverse proxy communicating with the website host using HTTP.

In fact, the first time I encountered the "HTTPS over a reverse proxy" problem was when generating the signature of the link written into the Laravel account validation e-mail. The server would always respond "403 Invalid Signature" when browsing that link. Here is why:

  • Laravel signs the website URL before sending the account validation e-mail;
  • Laravel sends the e-mail;
  • When a user clicks on the validation link in the e-mail, he's directed to the website validation page that gives access to the home page only if the URL the user clicked verifies the previously created signature, otherwise an invalid signature error appears.

That error thus comes from the fact that the signature is generated with the HTTP URL and then verified with the HTTPS URL. Therefore Laravel considers the signature verification as failing and sends an error.

Here are the three steps to solve that error (based on this StackOverflow post and adapted to what we've done in the previous section):

  1. Create a ValidHttpsSignature middleware:

    php artisan make:middleware ValidHttpsSignature

    and put the following as its content:

    <?php
    namespace App\Http\Middleware;
    use Closure;
    use Illuminate\Routing\Exceptions\InvalidSignatureException;
    use Illuminate\Http\Request;
    use Illuminate\Support\Arr;
    use Illuminate\Support\Facades\App;
    use Illuminate\Support\Carbon;
    class ValidateHttpsSignature
    {
    var $keyResolver;
    
    public function __construct()
    {
        $this->keyResolver = function () {
            return App::make('config')->get('app.key');
        };
    }
    
    /**
     * Based in/laravel/framework/src/Illuminate/Routing/Middleware/ValidateSignature.php.
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($this->hasValidSignature($request)) {
            return $next($request);
        }
        throw new InvalidSignatureException;
    
    }
    
    /**
     * Determine if the given request has a valid signature.
     * copied and modified from
     * vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php:363
     * @param  \Illuminate\Http\Request  $request
     * @param  bool  $absolute
     * @return bool
     */
    public function hasValidSignature(Request $request, $absolute = true)
    {
        $url = $absolute ? $request->url() : '/'.$request->path();
    
        // THE FIX:
        $url = str_replace("http://","https://", $url);
    
        $original = rtrim($url.'?'.Arr::query(
                Arr::except($request->query(), 'signature')
            ), '?');
    
        $expires = $request->query('expires');
    
        $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));
    
        return  hash_equals($signature, (string) $request->query('signature', '')) &&
            ! ($expires && Carbon::now()->getTimestamp() > $expires);
        }
    }
  2. Register the middleware in the app/Http/Kernel.php file by adding
    'signedhttps' => \App\Http\Middleware\ValidateHttpsSignature::class,

    below the following line:

    'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
  3. Into the app/Http/Controllers/Auth/VerificationController file, replace the line
    $this->middleware('signed')->only('verify');

    by:

    $app_url = config("app.url");
    if (!empty($app_url)) {
        $schema = explode(':', $app_url)[0];
        if ($schema == 'https') {
            $this->middleware('signedhttps')->only('verify');
        } else {
            $this->middleware('signed')->only('verify');
        }
    }

And you're done! That was easy, wasn't it?