arblant
firefox-vpn-client / core / scripts / relay / relay.php
relay.php111 lines · 3.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
<?php
// API relay for the VPN client, as one PHP file (PHP 7.3 or later, with
// the curl extension, on any host that serves HTTPS).
//
// When Mozilla's sign-in, Guardian, or server list cannot be reached from
// your network, the client can send those requests (never the VPN traffic)
// through this relay: upload the file, then set its URL in Settings >
// Advanced > "Relay for Mozilla's services", for example
// https://example.com/relay.php?key=SECRET
//
// The client asks for relay.php?url=<target>; the relay sends the same
// method, headers, and body to the target and answers with its response.
// Only the hosts below are relayed, so the relay is not an open proxy for
// others.

// A secret only your client knows; the relay URL then ends in ?key=SECRET.
// Leave empty for no key (anyone who finds the URL could use your relay,
// for these hosts only).
$KEY = '';

$ALLOWED = [
    'mozilla.org', 'mozilla.com', 'firefox.com', // accounts, Guardian, server list
    'github.com', 'githubusercontent.com', 'jsdelivr.net', // geo files, flags
];

// Headers that describe this hop, not the request or the response.
$HOP = ['host', 'connection', 'keep-alive', 'content-length', 'transfer-encoding', 'accept-encoding',
    'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'upgrade', 'te', 'trailer'];

function fail($status, $why)
{
    http_response_code($status);
    header('X-Relay-Error: ' . $why);
    header('Content-Type: text/plain');
    echo $why;
    exit;
}

function ends_with($text, $suffix)
{
    return substr($text, -strlen($suffix)) === $suffix;
}

if ($KEY !== '' && !hash_equals($KEY, (string)($_GET['key'] ?? ''))) {
    fail(403, 'wrong key');
}
$target = (string)($_GET['url'] ?? '');
$parts = parse_url($target);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) {
    fail(400, 'url must be an https URL');
}
$host = strtolower($parts['host']);
$allowed = false;
foreach ($ALLOWED as $domain) {
    if ($host === $domain || ends_with($host, '.' . $domain)) {
        $allowed = true;
    }
}
if (!$allowed) {
    fail(403, 'host not allowed: ' . $host);
}

$headers = [];
foreach (getallheaders() as $name => $value) {
    $lower = strtolower($name);
    if (in_array($lower, $HOP, true) || strpos($lower, 'cf-') === 0) {
        continue;
    }
    $headers[] = $name . ': ' . $value;
}

$method = $_SERVER['REQUEST_METHOD'];
$ch = curl_init($target);
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => $method,
    CURLOPT_HTTPHEADER => $headers,
    // Redirects go back to the client, which follows them through the relay.
    CURLOPT_FOLLOWLOCATION => false,
    CURLOPT_CONNECTTIMEOUT => 20,
    CURLOPT_TIMEOUT => 600,
    // The response's status and headers, as they arrive (a 100 Continue
    // comes first sometimes).
    CURLOPT_HEADERFUNCTION => function ($ch, $line) use ($HOP) {
        $trimmed = trim($line);
        if (preg_match('#^HTTP/\S+\s+(\d{3})#', $trimmed, $m)) {
            http_response_code((int)$m[1]);
        } elseif ($trimmed !== '' && strpos($trimmed, ':') !== false) {
            $name = strtolower(trim(strstr($trimmed, ':', true)));
            if (!in_array($name, $HOP, true)) {
                header($trimmed, false);
            }
        }
        return strlen($line);
    },
    // The body is passed on as it arrives, so large files need no memory.
    CURLOPT_WRITEFUNCTION => function ($ch, $data) {
        echo $data;
        flush();
        return strlen($data);
    },
]);
if ($method !== 'GET' && $method !== 'HEAD') {
    curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input'));
}
if ($method === 'HEAD') {
    curl_setopt($ch, CURLOPT_NOBODY, true);
}
if (curl_exec($ch) === false && !headers_sent()) {
    fail(502, 'the target did not answer: ' . curl_error($ch));
}
curl_close($ch);