diff --git a/app/Console/Commands/RequestFetcher.php b/app/Console/Commands/RequestFetcher.php index 2e71f9d1d1daad6f1c51116418cfa5fc0a888890..c0f41f6897d560935283ab5fe7a23110d7e6e4a0 100644 --- a/app/Console/Commands/RequestFetcher.php +++ b/app/Console/Commands/RequestFetcher.php @@ -154,7 +154,7 @@ class RequestFetcher extends Command $error = curl_error($info["handle"]); if (!empty($error)) { Log::error($error); - } + } $result = $this->parseResponse($info["handle"]); @@ -179,10 +179,18 @@ class RequestFetcher extends Command return [$answersRead, $messagesLeft]; } - private function parseResponse($ch){ + private function parseResponse($ch) + { + $errorNumber = curl_errno($ch); + if ($errorNumber === CURLE_ABORTED_BY_CALLBACK) { + return [ + "error" => $errorNumber, + "message" => curl_error($ch), + ]; + } $httpResponse = \curl_multi_getcontent($ch); - if(empty($httpResponse)){ + if (empty($httpResponse)) { return null; } $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); @@ -195,16 +203,16 @@ class RequestFetcher extends Command $headers_indexed_arr = explode("\r\n", $headers); array_shift($headers_indexed_arr); foreach ($headers_indexed_arr as $value) { - if(false !== ($matches = explode(':', $value, 2)) && sizeof($matches) === 2) { - $headers_arr[strtolower("{$matches[0]}")] = trim($matches[1]); - } + if (false !== ($matches = explode(':', $value, 2)) && sizeof($matches) === 2) { + $headers_arr[strtolower("{$matches[0]}")] = trim($matches[1]); + } } $sanitizedHeaders = array(); - foreach($headers_arr as $key => $value){ - if(stripos($key, "content-encoding") === false && + foreach ($headers_arr as $key => $value) { + if (stripos($key, "content-encoding") === false && stripos($key, "x-frame-options") === false && - stripos($key, "content-length") === false){ + stripos($key, "content-length") === false) { $sanitizedHeaders[$key] = $value; } } diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php new file mode 100644 index 0000000000000000000000000000000000000000..c9bc239a576e4d4d4a857eabc001c20eef976580 --- /dev/null +++ b/app/Http/Controllers/DownloadController.php @@ -0,0 +1,142 @@ +<?php + +namespace App\Http\Controllers; + +use Illuminate\Http\Request; +use Carbon\Carbon; +use Carbon\Exceptions\InvalidFormatException; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Log; + +class DownloadController extends Controller +{ + // How many hours is a Download link valid + const DOWNLOADLINKVALIDHOURS = 1; + + public function iframeBeakout(Request $request) + { + return view('download-iframe-breakout') + ->with('url', $request->input('url', 'https://metager.de')) + ->with('validUntil', $request->input('valid-until', '')) + ->with('password', $request->input('password', '')) + ->with('key', md5($request->ip() . microtime(true))); + } + + public function download(Request $request) + { + $url = $request->input("url", ""); + $validUntil = $request->input('valid-until', ''); + $password = $request->input('password'); + + // Deny Loading internal URLs and check if URL syntax is correct + $host = parse_url($url, PHP_URL_HOST); + $selfHost = $request->getHttpHost(); + if ($host === false || $host === $selfHost) { + abort(404, "Invalid Request"); + } + + // Check the integrity of the data + if (!self::checkPassword($url, $validUntil, $password)) { + abort(404, "Invalid Request"); + } + + try { + $validUntil = Carbon::createFromFormat("d-m-Y H:i:s P", $validUntil); + } catch (InvalidFormatException $e) { + abort(404, "Invalid Request"); + } + + if ($validUntil->isBefore(Carbon::now()->setTimezone("UTC"))) { + abort(404, "Invalid Request"); + } + + $headers = get_headers($url, 1); + + $filename = basename($url); + + # From the headers we need to remove the first Element since it's the status code: + $status = $headers[0]; + $status = intval(preg_split("/\s+/si", $status)[1]); + array_forget($headers, 0); + + # Add the Filename if it's not set: + if (!isset($headers["Content-Disposition"])) { + $headers["Content-Disposition"] = "inline; filename=\"" . $filename . "\""; + } elseif (preg_match("/filename=\"{0,1}(.*?)(\"|\s|$)/", $headers["Content-Disposition"], $matches)) { + $filename = $matches[1]; + } + + $response = new StreamedResponse(function () use ($url) { + # We are gonna stream a large file + $wh = fopen('php://output', 'r+'); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_setopt($ch, CURLOPT_BUFFERSIZE, 256); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 50000); + curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 5); + curl_setopt($ch, CURLOPT_FILE, $wh); // Data will be sent to our stream ;-) + + curl_exec($ch); + + curl_close($ch); + + // Don't forget to close the "file" / stream + fclose($wh); + }, 200, $headers); + $response->send(); + return $response; + } + + public static function generateDownloadLinkParameters($url) + { + $validUntil = self::generateValidUntilDate(); + $password = self::generatePassword($url, $validUntil); + return [ + "url" => $url, + "valid-until" => $validUntil, + "password" => $password + ]; + } + + /** + * This function generates a Date/Time String which is used by our Download Controller + * to check if a download link is valid. + * This Date/Time is used in the password hash, too to make sure it is not altered + */ + private static function generateValidUntilDate() + { + $validUntil = Carbon::now()->setTimezone("UTC"); + $validUntil->addHours(self::DOWNLOADLINKVALIDHOURS); + + return $validUntil->format("d-m-Y H:i:s P"); + } + + /** + * This function generates the password for Download Links + * The password is an hmac with the proxy password. + * Algo is SHA256 + * Data is $url . $validUntil + * When verifying the password we can verify integrity of the supplied valid-until argument + */ + private static function generatePassword($url, $validUntil) + { + $data = $url . $validUntil; + if (!is_string($data) || strlen($data) === 0) { + return null; + } + return hash_hmac("sha256", $data, env("PROXY_PASSWORD", "unsecure_password")); + } + + private static function checkPassword($url, $validUntil, $password) + { + $data = $url . $validUntil; + if (!is_string($data) || strlen($data) === 0) { + return false; + } + $excpectedHash = hash_hmac("sha256", $data, env("PROXY_PASSWORD", "unsecure_password")); + return hash_equals($excpectedHash, $password); + } +} diff --git a/app/Http/Controllers/ProxyController.php b/app/Http/Controllers/ProxyController.php index b82b7dccdf6fdece19cfca04a9f295468f2bdc70..a186bd4fccd6af4e000da3c0b9801d748f297007 100644 --- a/app/Http/Controllers/ProxyController.php +++ b/app/Http/Controllers/ProxyController.php @@ -12,6 +12,7 @@ use URL; use Illuminate\Support\Facades\Redis; use App\Console\Commands\RequestFetcher; use App\Models\HttpParser; +use Carbon\Carbon; class ProxyController extends Controller { @@ -174,13 +175,26 @@ class ProxyController extends Controller Redis::rpush(RequestFetcher::FETCHQUEUE_KEY, $mission); $answer = Redis::brpoplpush($hash, $hash, 10); Redis::expire($hash, 15); - if($answer){ + if ($answer) { $answer = json_decode($answer, true); } } else { $answer = Cache::get($hash); } + if (!empty($answer["error"])) { + if ($answer["error"] === CURLE_ABORTED_BY_CALLBACK) { + // File Downloads aren't working anymore within an IFrame. + // We will show the user a page to download the File + $postData = \App\Http\Controllers\DownloadController::generateDownloadLinkParameters($targetUrl); + return response(view("errors.413")->with([ + "url" => $postData["url"], + "validuntil" => $postData["valid-until"], + "password" => $postData["password"] + ]), 413); + } + } + if ($result === null) { return $this->streamFile($targetUrl); } else { @@ -225,14 +239,14 @@ class ProxyController extends Controller $contentEncoding = stripos($contentTypeHeader, "charset=") !== false ? trim(substr($contentTypeHeader, stripos($contentTypeHeader, "charset=") + 8)) : null; $contentEncoding = rtrim($contentEncoding, ";"); if (isset($answer["headers"]["content-disposition"])) { - if (stripos($answer["headers"]["content-disposition"], "filename=") === false) { - $basename = basename(parse_url($targetUrl, PHP_URL_PATH)); - $newHeader = $answer["headers"]["content-disposition"]; - $newHeader = trim($newHeader); - $newHeader = rtrim($newHeader, ";"); - $newHeader .= "; filename=" . $basename; - $result["headers"]["content-disposition"] = $newHeader; - } + // File Downloads aren't working anymore within an IFrame. + // We will show the user a page to download the File + $postData = \App\Http\Controllers\DownloadController::generateDownloadLinkParameters($targetUrl); + return response(view("downloadrequired")->with([ + "url" => $postData["url"], + "validuntil" => $postData["valid-until"], + "password" => $postData["password"] + ]), 413); } $body = base64_decode($answer["body"]); switch ($contentType) { @@ -271,6 +285,7 @@ class ProxyController extends Controller case 'application/x-www-form-urlencoded': case 'application/zip': case 'binary/octet-stream': + case 'application/vnd.android.package-archive': # Nothing to do with Images: Just return them break; case 'text/css': @@ -293,7 +308,25 @@ class ProxyController extends Controller ->withHeaders($answer["headers"]); } - private function streamFile($url) + public function streamFile(Request $request, $password, $id, $url) + { + /** + * Forms of proxied webpages might aswell Post to this URL + * Those need to be denied + */ + $check = md5(env('PROXY_PASSWORD') . date('dmy') . $request->ip()); + if (!$request->filled("force-download") && $request->input("check", "") === $check) { + abort(405); + } + $targetUrl = str_replace("<<SLASH>>", "/", $url); + $targetUrl = str_rot13(base64_decode($targetUrl)); + if (strpos($targetUrl, URL::to('/')) === 0) { + return redirect($targetUrl); + } + return $this->streamResponse($targetUrl); + } + + private function streamResponse($url) { $headers = get_headers($url, 1); @@ -307,30 +340,27 @@ class ProxyController extends Controller # Add the Filename if it's not set: if (!isset($headers["Content-Disposition"])) { $headers["Content-Disposition"] = "inline; filename=\"" . $filename . "\""; + } elseif (preg_match("/filename=\"{0,1}(.*?)(\"|\s|$)/", $headers["Content-Disposition"], $matches)) { + $filename = $matches[1]; } - $response = new StreamedResponse(function () use ($url) { + return response()->streamDownload(function () use ($url) { # We are gonna stream a large file - $wh = fopen('php://output', 'r+'); - $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_HEADER, 0); - curl_setopt($ch, CURLOPT_BUFFERSIZE, 256); + curl_setopt($ch, CURLOPT_BUFFERSIZE, 4096); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 50000); curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 5); - curl_setopt($ch, CURLOPT_FILE, $wh); // Data will be sent to our stream ;-) - - curl_exec($ch); + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) { + echo($data); + }); + + curL_exec($ch); curl_close($ch); - - // Don't forget to close the "file" / stream - fclose($wh); - }, 200, $headers); - $response->send(); - return $response; + }, $filename, $headers); } public function proxifyUrl($url, $password = null, $key, $topLevel) diff --git a/resources/lang/en/413.php b/resources/lang/en/413.php new file mode 100644 index 0000000000000000000000000000000000000000..b55a4782d3209423b908de0f88233c39cb5250c5 --- /dev/null +++ b/resources/lang/en/413.php @@ -0,0 +1,7 @@ +<?php + +return [ + 'disclaimer.1' => 'The File size of the requested resource exceeds our configured file size limit. If you want you can chose to download this file. Your request will stay anonymous but we will not try to anonymize the contents.', + 'disclaimer.2' => 'For technical reasons you will need to confirm the download again on the next page.', + 'button' => 'Prepare Download', +]; diff --git a/resources/lang/en/downloadIframeBreakout.php b/resources/lang/en/downloadIframeBreakout.php new file mode 100644 index 0000000000000000000000000000000000000000..8be5ad8c6e049009a87d43103f36d61e1f7ca2f4 --- /dev/null +++ b/resources/lang/en/downloadIframeBreakout.php @@ -0,0 +1,6 @@ +<?php + +return [ + 'header' => 'Your file is ready to be downloaded', + 'downloadFile' => 'Download File' +]; diff --git a/resources/lang/en/downloadrequired.php b/resources/lang/en/downloadrequired.php new file mode 100644 index 0000000000000000000000000000000000000000..86de08f1f0e56cf77e63c388e2e95a5424d0cfb6 --- /dev/null +++ b/resources/lang/en/downloadrequired.php @@ -0,0 +1,7 @@ +<?php + +return [ + 'disclaimer.1' => 'The File you requested is resource for Download. You can proceed to download this file. Your request will stay anonymous but we will not try to anonymize the contents.', + 'disclaimer.2' => 'For technical reasons you will need to confirm the download again on the next page.', + 'button' => 'Prepare Download', +]; diff --git a/resources/views/download-iframe-breakout.blade.php b/resources/views/download-iframe-breakout.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..a6bc3b45f6bf45a8feefb2bb4a7b54919988b7b7 --- /dev/null +++ b/resources/views/download-iframe-breakout.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app', ['targetUrl' => $url]) + +@section('content') + +<style> + main { + display: flex; + align-items: center; + justify-content: center; + padding-top: 30vh; + } + + body > main > div { + max-width: 700px; + } + + body > main > div > a { + border: 1px solid #fbcd79; + max-width: 200px; + padding: 4px 8px; + text-align: center; + color: inherit; + text-decoration: none; + border-radius: 5px; + background-color: orange; + color: white; + } + + body > main > div > a:hover { + text-decoration: none; + color: white; + } +</style> + +<main> + <div> + <h3>@lang('downloadIframeBreakout.header')</h3> + <a href="{{ route('download', ['url' => $url, 'valid-until' => $validUntil, 'password' => $password]) }}">@lang('downloadIframeBreakout.downloadFile')</a> + </div> +</main> + +@endsection diff --git a/resources/views/downloadrequired.blade.php b/resources/views/downloadrequired.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..32a269dcbef6b5b23eb1e097fa13733bbcab3bae --- /dev/null +++ b/resources/views/downloadrequired.blade.php @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>The requested resource is too large</title> + <style> + body > div:nth-child(1) { + max-width: 700px; + } + + body { + display: flex; + justify-content: center; + padding-top: 30vh; + } + + body > div > a { + border: 1px solid #fbcd79; + max-width: 200px; + padding: 4px 8px; + text-align: center; + color: inherit; + text-decoration: none; + border-radius: 5px; + background-color: orange; + color: white; + } + </style> +</head> +<body> + <div> + <p>@lang('downloadrequired.disclaimer.1')</p> + <p>@lang('downloadrequired.disclaimer.2')</p> + <a href="{{ route('download-iframe-breakout', ['url' => $url, 'valid-until' => $validuntil, 'password' => $password]) }}" target="_top"> + @lang('413.button') + </a> + </div> +</body> +</html> \ No newline at end of file diff --git a/resources/views/errors/413.blade.php b/resources/views/errors/413.blade.php new file mode 100644 index 0000000000000000000000000000000000000000..258665b0047c6d40b3411ceb03a2f7d7dd480719 --- /dev/null +++ b/resources/views/errors/413.blade.php @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>The requested resource is too large</title> + <style> + body > div:nth-child(1) { + max-width: 700px; + } + + body { + display: flex; + justify-content: center; + padding-top: 30vh; + } + + body > div > a { + border: 1px solid #fbcd79; + max-width: 200px; + padding: 4px 8px; + text-align: center; + color: inherit; + text-decoration: none; + border-radius: 5px; + background-color: orange; + color: white; + } + </style> +</head> +<body> + <div> + <p>@lang('413.disclaimer.1')</p> + <p>@lang('413.disclaimer.2')</p> + <a href="{{ route('download-iframe-breakout', ['url' => $url, 'valid-until' => $validuntil, 'password' => $password]) }}" target="_top"> + @lang('413.button') + </a> + </div> +</body> +</html> \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 7631aa21e8e3930c647ef8338e8d5a5a7f392683..02a33927647270b875ff3f2882da6329de6eb1f3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,19 +12,17 @@ use Illuminate\Http\Request; | contains the "web" middleware group. Now create something great! | */ -Route::post('/{url}', function ($url) { - abort(405); -}); - -Route::post('{password}/{url}', function ($url) { - abort(405); -}); Route::get('healthz', function () { return response('', 200) ->header('Content-Type', 'text/plain'); }); +Route::group(['prefix' => 'download'], function () { + Route::get('iframe-breakout', 'DownloadController@iframeBeakout')->name('download-iframe-breakout'); + Route::get('/', 'DownloadController@download')->name("download"); +}); + Route::get('/', function () { if (env("APP_ENV", "") !== "production") { return view("development"); @@ -47,5 +45,11 @@ Route::post('/', function (Request $request) { }); Route::get('{password}/{url}', 'ProxyController@proxyPage')->middleware('throttle:60:1')->middleware('checkpw'); - +# Route that triggers streaming of Download Route::get('proxy/{password}/{id}/{url}', 'ProxyController@proxy')->middleware('checkpw:true'); +Route::post('proxy/{password}/{id}/{url}', 'ProxyController@streamFile')->middleware('checkpw:true'); + + +Route::post('/{url}', function ($url) { + abort(405); +});