From 16575878551167d3a9899d8dec54ad26e8c77f53 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Fri, 6 Aug 2021 10:04:23 +0200 Subject: [PATCH] Add proxy_sourcemap.php --- wcfsetup/install/files/proxy_sourcemap.php | 230 +++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 wcfsetup/install/files/proxy_sourcemap.php diff --git a/wcfsetup/install/files/proxy_sourcemap.php b/wcfsetup/install/files/proxy_sourcemap.php new file mode 100644 index 0000000000..83ca8dcc4c --- /dev/null +++ b/wcfsetup/install/files/proxy_sourcemap.php @@ -0,0 +1,230 @@ + + * @package WoltLabSuite\Core + */ + +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\ServerRequest; +use GuzzleHttp\Psr7\Stream; +use GuzzleHttp\RequestOptions; +use ParagonIE\ConstantTime\Base64UrlSafe; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +\set_error_handler(static function ($severity, $message, $file, $line) { + if (!(\error_reporting() & $severity)) { + return; + } + throw new \ErrorException($message, 0, $severity, $file, $line); +}); + +require(__DIR__ . '/lib/system/api/autoload.php'); + +/** + * Verifies the given $signature is a valid signature for the given $map. + */ +function verifySignature(string $map, string $signature): bool +{ + $publicKey = "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA45HcF7xOmGe0TSwVwCHE +hFYYxD5SgxchDwQSbX4Wa0XrvhJpo7yKy2QWlfc3CfXZDTLIqPMqAvJEd7xTP9Ny +5OF6cR2NDPjR/ilGN26txhTc2BNzOSXBMZyIwhKHYZZ2JThMT2MRmsVjFeJjbPrX +d8ttf4rEox+ARY/Vaoq+1nx8ZB2B/SwiOS18ESVKRbtKcGtJvenMYxQRx+n7iqy3 +ALh9/pSWX5iucJvmW4bBx+RZAn9eTrAjWf5y8Yadc7sMOFWNg2zCgIqqPpsA5ccq +Di1RWwi7vdSudukBovTVfhQ1CkTQ4/r6YxQTYJE2JvyRCMeTEsUHa2Kmwndj/nX3 +xQIDAQAB +-----END PUBLIC KEY-----"; + + return \openssl_verify( + $map, + $signature, + $publicKey, + \OPENSSL_ALGO_SHA256 + ) === 1; +} + +/** + * Extracts the map name from the given $query. + * + * Returns a string if a map name could be extracted. Returns a class implementing + * ResponseInterface if the $query is invalid. + */ +function getMapFromQuery(string $query) +{ + try { + // Step 1: Extract the signature and map name. + + $parts = \explode('/', $query, 2); + if (\count($parts) !== 2) { + throw new \UnexpectedValueException('Expected exactly 2 parts within the query string.'); + } + + [$signature, $map] = $parts; + + $signature = Base64UrlSafe::decode($signature, false); + + // Step 2: Verify the signature. + if (!verifySignature($map, $signature)) { + throw new \UnexpectedValueException('Failed to verify the signature.'); + } + + // Step 3: Perform a safety check on the map name. + if (!\preg_match('/^[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)*\\/[a-f0-9]{64}$/', $map)) { + // This should be unreachable in real world, because an invalid map is not + // going to be correctly signed. + return new Response( + 400, + [ + 'content-type' => 'text/plain', + 'cache-control' => [ + 'public', + ], + ], + 'Invalid map.' + ); + } + + // Step 4: Return the extracted map name. + return $map; + } catch (\UnexpectedValueException | \RangeException $e) { + return new Response( + 400, + [ + 'content-type' => 'text/plain', + 'cache-control' => [ + 'public', + ], + ], + 'Invalid signature.' + ); + } +} + +/** + * Processes the given request and returns a response to send to the browser. + */ +function handle(ServerRequestInterface $request): ResponseInterface +{ + $mapOrErrorResponse = getMapFromQuery($request->getUri()->getQuery()); + + if ($mapOrErrorResponse instanceof ResponseInterface) { + return $mapOrErrorResponse; + } + + $map = $mapOrErrorResponse; + + $cacheFilename = \sprintf( + '%s/tmp/sourcemap_%s.map', + __DIR__, + \md5($map) + ); + + if (!\file_exists($cacheFilename)) { + $cacheDir = \dirname($cacheFilename); + if (!\is_writable($cacheDir)) { + throw new \RuntimeException(\sprintf("'%s' is not writable", $cacheDir)); + } + + $remoteRequest = new Request( + 'GET', + \sprintf( + 'https://assets.woltlab.com/sourcemap/%s.map', + $map + ), + [ + 'accept-encoding' => 'gzip', + ] + ); + + try { + $client = new Client([ + RequestOptions::PROXY => \PROXY_SERVER_HTTP, + RequestOptions::TIMEOUT => 5, + RequestOptions::HEADERS => [ + 'user-agent' => 'WoltLabSuite (SourceMap Proxy)', + ], + ]); + $remoteResponse = $client->send($remoteRequest); + $target = new Stream(\fopen($cacheFilename, 'w')); + + while (!$remoteResponse->getBody()->eof()) { + $target->write($remoteResponse->getBody()->read(8192)); + } + } catch (ClientExceptionInterface $e) { + \file_put_contents($cacheFilename, ''); + } + } + + $body = new Stream(\fopen($cacheFilename, 'r')); + + if ($body->getSize() === 0) { + return new Response( + 503, + [ + 'content-type' => 'text/plain', + 'cache-control' => [ + 'public', + ], + ], + 'Failed to download the source map.' + ); + } + + return new Response( + 200, + [ + 'content-type' => 'application/json', + 'cache-control' => [ + 'public', + 'immutable', + 'max-age=2592000', + ], + 'etag' => \sprintf('"%s"', $map), + ], + $body + ); +} + +// Below this point the actual request handling is performed. + +try { + $request = ServerRequest::fromGlobals(); + + try { + require_once(__DIR__ . '/options.inc.php'); + + $response = handle($request); + } catch (\Exception $e) { + $response = new Response( + 500, + [], + 'Internal Server Error' + ); + } + + \http_response_code($response->getStatusCode()); + + foreach ($response->getHeaders() as $name => $values) { + $isFirst = true; + foreach ($values as $value) { + \header( + \sprintf('%s: %s', $name, $value), + $isFirst + ); + $isFirst = false; + } + } + + while (!$response->getBody()->eof()) { + echo $response->getBody()->read(8192); + } +} catch (\Exception $e) { + echo 'Unhandled exception'; +} -- 2.20.1