PHP script can't open certain URLs

1k views Asked by At

I'm calling through Axios a PHP script checking whether a URL passed to it as a parameter can be embedded in an iframe. That PHP script starts with opening the URL with $_GET[].

Strangely, a page with cross-origin-opener-policy: same-origin (like https://twitter.com/) can be opened with $_GET[], whereas a page with Referrer Policy: strict-origin-when-cross-origin (like https://calia.order.liven.com.au/) cannot.

I don't understand why, and it's annoying because for the pages that cannot be opened with $_GET[] I'm unable to perform my checks on them - the script just fails (meaning I get no response and the Axios call runs the catch() block).

So basically there are 3 types of pages: (1) those who allow iframe embeddability, (2) those who don't, and (3) the annoying ones who not only don't but also can't even be opened to perform this check.

Is there a way to open any page with PHP, and if not, what can I do to prevent my script from failing after several seconds?

PHP script:

$source = $_GET['url'];
$response = true;

try {
  $headers = get_headers($source, 1);
  $headers = array_change_key_case($headers, CASE_LOWER);

  if (isset($headers['content-security-policy'])) {
    $response = false;
  }
  else if (isset($headers['x-frame-options']) &&
    $headers['x-frame-options'] == 'DENY' ||
    $headers['x-frame-options'] == 'SAMEORIGIN'
  ) {
    $response = false;
  }
} catch (Exception $ex) {
  $response = $ex;
}

echo $response;

EDIT: below is the console error.

Access to XMLHttpRequest at 'https://path.to.cdn/iframeHeaderChecker?url=https://calia.order.liven.com.au/' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
CustomLink.vue?b495:61 Error: Network Error
    at createError (createError.js?2d83:16)
    at XMLHttpRequest.handleError (xhr.js?b50d:84)
VM4758:1 GET https://path.to.cdn/iframeHeaderChecker?url=https://calia.order.com.au/ net::ERR_FAILED
2

There are 2 answers

0
Don't Panic On BEST ANSWER

The error you have shown is coming from Javascript, not from PHP. get_headers() returns false on failure, it will not throw an exception - the catch() never happens. get_headers() just makes an http request, like your browser, or curl, and the only reason that would fail is if the URL is malformed, or the remote site is down, etc.

It is the access from http://localhost:3000 to https://path.to.cdn/iframeHeaderChecker with Javascript that has been blocked, not PHP access to the URLs you are passing as parameters in $_GET['url'].

What you're seeing is a standard CORS error when you try to access a different domain than the one the Javascript is running on. CORS means Javascript running on one host cannot make http requests to another host, unless that other host explicitly allows it. In this case, the Javascript running at http://localhost:3000 is making an http request to a remote site https://path.to.cdn/. That's a cross-origin request (localhost !== path.to.cdn), and the server/script receiving that request on path.to.cdn is not returning any specific CORS headers allowing that request, so the request is blocked.

Note though that if the request is classed as "simple", it will actually run. So your PHP is working already, always, but bcs the right headers aren't returned, the result is blocked from being displayed in your browser. This can lead to confusion bcs for eg you might notice a delay while it gets the headers from a slow site, whereas it is super fast for a fast site. Or maybe you have logging which you see is working all the time, despite nothing showing up in your browser.

My understanding is that https://path.to.cdn/iframeHeaderChecker is your PHP script, some of the code of which you have shown in your question? If so, you have 2 choices:

  1. Update iframeHeaderChecker to return the appropriate CORS headers, so that your cross-origin JS request is allowed. As a quick, insecure hack to allow access from anyone and anywhere (not a good idea for the long term!) you could add:

    header("Access-Control-Allow-Origin: *");
    

    But it would be better to update that to more specifically restrict access to only your app, and not everyone else. You'll have to evaluate the best way to do that depending on the specifics of your application and infrastructure. There many questions here on SO about CORS/PHP/AJAX to check for reference. You could also configure this at the web server level, rather than the application level, eg here's how to configure Apache to return those headers.

  2. If iframeHeaderChecker is part of the same application as the Javascript calling it, is it also available locally, on http://localhost:3000? If so, update your JS to use the local version, not the remote one on path.to.cdn, and you avoid the whole problem!

7
Jimmix On

This is just my rough guess about what wrong with your code can be.

I noticed you do:

a comparison of values from $headers but without ensuring they have the same CAPITAL CASE as the values you compare against. Applied: strtoupper().

check with isset() but not test if key_exist before Applied: key_exist()

check with isset() but perhaps you should use !empty() instead of isset() compare result:

$value = "";
var_dump(isset($value)); // (bool) true
var_dump(!empty($value)); // (bool) false

$value = "something";
var_dump(isset($value)); // (bool) true
var_dump(!empty($value)); // (bool) true

unset($value);
var_dump(isset($value)); // (bool) false
var_dump(!empty($value)); // (bool) false

The code with applied changes:

<?php

error_reporting(E_ALL);
declare(strict_types=1);

header('Access-Control-Allow-Origin: *');

ob_start();

try {

    $response = true;

    if (!key_exists('url', $_GET)) {
        $msg = '$_GET does not have a key "url"';
        throw new \RuntimeException($msg);
    }
    $source = $_GET['url'];

    if ($source !== filter_var($source, \FILTER_SANITIZE_URL)) {
        $msg = 'Passed url is invaid, url: ' . $source;
        throw new \RuntimeException($msg);
    }

    if (filter_var($source, \FILTER_VALIDATE_URL) === FALSE) {
        $msg = 'Passed url is invaid, url: ' . $source;
        throw new \RuntimeException($msg);
    }

    $headers = get_headers($source, 1);

    if (!is_array($headers)) {
        $msg = 'Headers should be array but it is: ' . gettype($headers);
        throw new \RuntimeException($msg);
    }

    $headers = array_change_key_case($headers, \CASE_LOWER);

    if (  key_exists('content-security-policy', $headers) &&
            isset($headers['content-security-policy'])
        ) {
            $response = false;
    }
    elseif (  key_exists('x-frame-options', $headers) && 
                (
                    strtoupper($headers['x-frame-options']) == 'DENY' ||
                    strtoupper($headers['x-frame-options']) == 'SAMEORIGIN'
                )
    ) {
            $response = false;
    }

} catch (Exception $ex) {
    $response = "Error: " . $ex->getMessage() . ' at: ' . $ex->getFile() . ':' . $ex->getLine();
}

$phpOutput = ob_get_clean();
if (!empty($phpOutput)) {
    $response .= \PHP_EOL . 'PHP Output: ' . $phpOutput;
}

echo $response;

Using Throwable instead of Exception will also catch Errors in PHP7.

Keep in mind that:

$response = true;
echo $response; // prints "1"

but

$response = false;
echo $response; // prints ""

so for the $response = false you'll get an empty string, not 0 if you want to have 0 for false and 1 for true then change the $response = true; to $response = 1; for true and $response = false; to $response = 0; for false everywhere.

I hope that somehow helps