Select Git revision
index.blade.php
ProxyController.php 22.54 KiB
<?php
namespace App\Http\Controllers;
use App\CssDocument;
use App\HtmlDocument;
use Cache;
use finfo;
use Log;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use App\Console\Commands\RequestFetcher;
use Carbon\Carbon;
class ProxyController extends Controller
{
const PROXY_CACHE = 5; # Cache duration in minutes
const PROXYLINKVALIDHOURS = 1;
public function proxyPage(Request $request)
{
if(!$request->filled("url") || !$request->filled("password")){
if (env("APP_ENV", "") !== "production") {
return view("development");
} else {
return redirect("https://metager.de");
}
}
$targetUrl = $request->input("url", "https://metager.de");
$password = $request->input("password", "");
# Check For URL-Parameters that don't belong to the Proxy but to the URL that needs to be proxied
$params = $request->except(['url', 'password']);
if (sizeof($params) > 0) {
# There are Params that need to be passed to the page
# Most of the times this happens due to forms that are submitted on a proxied page
# Let's redirect to the correct URI
$proxyParams = $request->except(array_keys($params));
$redirProxyUrl = $targetUrl;
$redirParams = [];
if (strpos($redirProxyUrl, "?") === false) {
$redirProxyUrl .= "?";
} else {
# There are already Params for this site which need to get updated
$tmpParams = substr($redirProxyUrl, strpos($redirProxyUrl, "?") + 1);
$tmpParams = explode("&", $tmpParams);
foreach ($tmpParams as $param) {
$tmp = explode("=", $param);
if (sizeof($tmp) === 2) {
$redirParams[$tmp[0]] = $tmp[1];
}
}
}
foreach ($params as $key => $value) {
$redirParams[$key] = $value;
}
foreach ($redirParams as $key => $value) {
$redirProxyUrl .= $key . "=" . urlencode($value) . "&";
}
$redirProxyUrl = rtrim($redirProxyUrl, "&");
$pw = md5(env('PROXY_PASSWORD') . $redirProxyUrl);
$redirProxyUrl = base64_encode(str_rot13($redirProxyUrl));
$redirProxyUrl = urlencode(str_replace("/", "<<SLASH>>", $redirProxyUrl));
$proxyParams['url'] = $redirProxyUrl;
$proxyParams['password'] = $pw;
$newLink = action('ProxyController@proxyPage', $proxyParams);
return redirect($newLink);
}
// Check Password
if(!self::checkPassword($targetUrl, null, $password)){
abort(400, "Invalid Request");
}
// Deny Loading internal URLs and check if URL syntax is correct
$host = parse_url($targetUrl, PHP_URL_HOST);
$selfHost = $request->getHttpHost();
// The target URL couldn't be parsed. This is probably a malformed URL
if($host === false){
abort(404, "Invalid Request");
}
// The URL to load itself is a URL to our proxy
// We will just redirect to that URL
if ($host === $selfHost) {
return redirect($targetUrl);
}
$this->writeLog($targetUrl, $request->ip());
$urlToProxy = self::generateProxyUrl($targetUrl);
return view('ProxyPage')
->with('iframeUrl', $urlToProxy)
->with('targetUrl', $targetUrl);
}
public function proxy(Request $request)
{
if(!$request->filled("url") || !$request->filled("password") || !$request->filled("valid-until")){
Log::info("Request with missing url, password or valid-until");
abort(400, "Invalid Request");
}
$targetUrl = $request->input("url", "https://metager.de");
$password = $request->input("password", "");
$validUntil = $request->input("valid-until", "");
// Check Password
if(!self::checkPassword($targetUrl, $validUntil, $password)){
Log::info("Password incorrect");
abort(400, "Invalid Request");
}
try {
$validUntil = Carbon::createFromFormat("d-m-Y H:i:s P", $validUntil);
} catch (InvalidFormatException $e) {
abort(400, "Invalid Request");
}
if ($validUntil->isBefore(Carbon::now()->setTimezone("UTC"))) {
Log::info("URL expired");
abort(400, "Invalid Request");
}
// Deny Loading internal URLs and check if URL syntax is correct
$host = parse_url($targetUrl, PHP_URL_HOST);
$selfHost = $request->getHttpHost();
// The target URL couldn't be parsed. This is probably a malformed URL
// The URL to load itself is a URL to our proxy
if($host === false || $host === $selfHost){
Log::info("URL to myself");
abort(404, "Invalid Request");
}
// Hash Value under which a possible cached file would've been stored
$hash = md5($targetUrl);
$httpcode = 200;
if (!Cache::has($hash) || env("CACHE_ENABLED") === false) {
$useragent = $_SERVER['HTTP_USER_AGENT'];
if (preg_match('/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i', $useragent) || preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i', substr($useragent, 0, 4))) {
// Mobile Browser Dummy Mobile Useragent
$useragent = 'Mozilla/5.0 (Android 10; Mobile; rv:83.0) Gecko/83.0 Firefox/83.0';
} else {
// Not Mobile Dummy Desktop useragent
$useragent = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:83.0) Gecko/20100101 Firefox/83.0';
}
$mission = [
"resulthash" => $hash,
"url" => $targetUrl,
"useragent" => $useragent,
"cacheDuration" => $this::PROXY_CACHE,
];
$mission = json_encode($mission);
Redis::rpush(RequestFetcher::FETCHQUEUE_KEY, $mission);
$answer = Redis::brpoplpush($hash, $hash, 10);
Redis::expire($hash, 15);
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 ($answer === null) {
abort(400, "Couldn't fetch response", [
"url" => $targetUrl
]);
} else {
$httpcode = $answer["http-code"];
extract(parse_url($targetUrl));
$base = $scheme . "://" . $host;
$headerArray = [];
foreach ($answer["headers"] as $index => $value) {
if (strtolower($index) === "location") {
$redLink = $value;
if (strpos($redLink, "/") === 0) {
$parse = parse_url($targetUrl);
$redLink = $parse["scheme"] . "://" . $parse["host"] . $redLink;
} elseif (preg_match("/^\w+\.\w+$/si", $redLink)) {
$parse = parse_url($targetUrl);
$redLink = $parse["scheme"] . "://" . $parse["host"] . "/" . $redLink;
}
$key = md5($request->ip() . microtime(true));
$headerArray[trim($index)] = self::generateProxyUrl($redLink);
} elseif (strtolower($index) === "content-disposition") {
$headerArray[strtolower(trim($index))] = strtolower(trim($value));
} else {
$headerArray[trim($index)] = trim($value);
}
}
$answer["headers"] = $headerArray;
# It might happen that a server doesn't give Information about file Type.
# Let's try to generate one in this case
if (!isset($answer["headers"]["content-type"])) {
$finfo = new finfo(FILEINFO_MIME);
$answer["headers"]["content-type"] = $finfo->buffer(base64_decode($answer["body"]));
}
# We will parse whether we have a parser for this document type.
# If not, we will not Proxy it:
$contentTypeHeader = $answer["headers"]["content-type"];
$contentType = strpos($answer["headers"]["content-type"], ";") !== false ? trim(substr($answer["headers"]["content-type"], 0, strpos($answer["headers"]["content-type"], ";"))) : trim($answer["headers"]["content-type"]);
$contentEncoding = stripos($contentTypeHeader, "charset=") !== false ? trim(substr($contentTypeHeader, stripos($contentTypeHeader, "charset=") + 8)) : null;
$contentEncoding = rtrim($contentEncoding, ";");
if (isset($answer["headers"]["content-disposition"]) && stripos(trim($answer["headers"]["content-type"]), "image/") !== 0) {
// 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) {
case 'text/html':
# It's an html document
$htmlDocument = new HtmlDocument($password, $targetUrl, $body, $contentEncoding);
$htmlDocument->proxifyContent();
$answer['headers']['content-type'] = $contentType . "; charset=" . $htmlDocument->getEncoding();
$body = $htmlDocument->getResult();
break;
case 'application/pdf':
// We will download all PDF Files
// 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);
break;
// no break
case 'image/png':
case 'image/jpeg':
case 'image/gif':
case 'image/webp':
case 'image/vnd.microsoft.icon':
case 'application/font-woff':
case 'application/x-font-woff':
case 'application/x-empty':
case 'font/woff2':
case 'image/svg+xml':
case 'application/octet-stream':
case 'text/plain':
case 'image/x-icon':
case 'font/eot':
case 'application/vnd.ms-fontobject':
case 'application/x-font-ttf':
case 'application/x-www-form-urlencoded':
case 'application/zip':
case 'binary/octet-stream':
case 'application/vnd.android.package-archive':
case 'image/svg+xml':
case 'font/woff':
case 'font/woff2':
# Nothing to do with Images: Just return them
break;
case 'text/css':
# Css Documents might contain references to External Documents that need to get Proxified
$cssDocument = new CssDocument($password, $targetUrl, $body);
$cssDocument->proxifyContent();
$body = $cssDocument->getResult();
break;
default:
# We have no Parser for this one. Let's respond:
Log::error("Couldn't find parser for content type " . $contentType . " on URL " . $targetUrl);
abort(500, $contentType . " " . $targetUrl);
break;
}
}
if ($body === false) {
$body = "";
}
$answer["headers"]["mgproxy-targeturl"] = $targetUrl;
return response($body, $httpcode)
->withHeaders($answer["headers"]);
}
/**
* This function is called if a proxied page submits a form
* It should take the submitted parameters and add them to the url
* After that it should redirect to the correct page with the correct parameters
*/
public function formget(Request $request, $password, $validUntil, $url){
if(empty($password) || empty($validUntil) || empty($url)){
abort(400, "Invalid Request");
}
// Check Password
if(!self::checkPassword($url, $validUntil, $password)){
abort(400, "Invalid Request");
}
try {
$validUntil = Carbon::createFromFormat("d-m-Y H:i:s P", $validUntil);
} catch (InvalidFormatException $e) {
abort(400, "Invalid Request");
}
if ($validUntil->isBefore(Carbon::now()->setTimezone("UTC"))) {
abort(400, "Invalid Request");
}
// Deny Loading internal URLs and check if URL syntax is correct
$host = parse_url($url, PHP_URL_HOST);
$selfHost = $request->getHttpHost();
// The target URL couldn't be parsed. This is probably a malformed URL
// The URL to load itself is a URL to our proxy
if($host === false || $host === $selfHost){
abort(404, "Invalid Request");
}
// All Checks passed we can generate a url where the submitted data is included
$submittedParameters = $request->all();
// The URL itself might contain query parameters
$containedParameters = array();
$parts = parse_url($url);
if(!empty($parts["query"])){
parse_str($parts["query"], $containedParameters);
}
$urlParameters = array_merge($submittedParameters, $containedParameters);
if(empty($parts["scheme"]) || empty($parts["host"])){
abort(400, "Invalid Request");
}
// Build the url
$targetUrl = $parts["scheme"] . "://" .
((!empty($parts["user"]) && !empty($parts["pass"])) ? $parts["user"] . ":" . $parts["pass"] . "@" : "") .
$parts["host"] .
(!empty($parts["port"]) ? ":" . $parts["port"] : "") .
(!empty($parts["path"]) ? $parts["path"] : "") .
(!empty($urlParameters) ? "?" . http_build_query($urlParameters, "", "&", PHP_QUERY_RFC3986) : "") .
(!empty($parts["fragment"]) ? "#" . $parts["fragment"] : "");
return redirect(self::generateProxyWrapperUrl($targetUrl));
}
/**
* This function generates a URL to a proxied page
* including the proxy header.
*/
public static function generateProxyWrapperUrl($url){
$password = self::generatePassword($url, null);
$sanitizedUrl = self::sanitizeUrl($url);
$sanitizedParts = parse_url($sanitizedUrl);
$host = null;
$path = null;
if(!empty($sanitizedParts["host"])){
$host = $sanitizedParts["host"];
}
if(!empty($sanitizedParts["path"])){
$path = trim($sanitizedParts["path"], "/");
}
$parameters = [
"host" => $host,
"path" => $path,
"url" => $url,
"password" => $password,
];
return route('proxy-wrapper-page', $parameters);
}
/**
* This function generates a URL to a proxied page
* excluding the proxy header.
*/
public static function generateProxyUrl($url){
$validUntil = self::generateValidUntilDate();
$password = self::generatePassword($url, $validUntil);
$sanitizedUrl = self::sanitizeUrl($url);
$sanitizedParts = parse_url($sanitizedUrl);
$host = null;
$path = null;
if(!empty($sanitizedParts["host"])){
$host = $sanitizedParts["host"];
}
if(!empty($sanitizedParts["path"])){
$path = trim($sanitizedParts["path"], "/");
}
$parameters = [
"host" => $host,
"path" => $path,
"url" => $url,
"valid-until" => $validUntil,
"password" => $password,
];
try{
return route('proxy', $parameters);
}catch (\Exception $e){
$test = "test";
}
}
/**
* This function generates a URL to a page that takes submitted form data
* excluding the proxy header.
*/
public static function generateFormgetUrl($url){
$validUntil = self::generateValidUntilDate();
$password = self::generatePassword($url, $validUntil);
$parameters = [
"url" => $url,
"validUntil" => $validUntil,
"password" => $password,
];
return route('proxy-formget', $parameters);
}
/**
* 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::PROXYLINKVALIDHOURS);
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 or just $url when it should not expire
*/
private static function generatePassword($url, $validUntil)
{
$data = rtrim($url, "/");
if(!empty($validUntil)){
$data .= $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 = rtrim($url, "/");
if(!empty($validUntil)){
$data .= $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);
}
private function writeLog($targetUrl, $ip)
{
$logFile = env('PROXY_LOG_LOCATION');
$dateString = date('D M d H:i:s Y');
$logString = $dateString . "\t" . $targetUrl . "\t" . $ip . "\n";
if (file_exists($logFile)) {
file_put_contents($logFile, $logString, FILE_APPEND);
}
}
private static function sanitizeUrl($url){
$parts = parse_url($url);
// Optional but we only sanitize URLs with scheme and host defined
if($parts === false || empty($parts["scheme"]) || empty($parts["host"])){
return $url;
}
$sanitizedPath = null;
if(!empty($parts["path"])){
$pathParts = explode("/", $parts["path"]);
foreach($pathParts as $index => $pathPart){
if($index === 0) continue;
// The Path part might already be urlencoded
$sanitizedPath .= "/" . rawurlencode(rawurldecode($pathPart));
}
}
// Build the url
$targetUrl = $parts["scheme"] . "://" .
((!empty($parts["user"]) && !empty($parts["pass"])) ? $parts["user"] . ":" . $parts["pass"] . "@" : "") .
rtrim($parts["host"], ".") .
(!empty($parts["port"]) ? ":" . $parts["port"] : "") .
(!empty($sanitizedPath) ? $sanitizedPath : "") .
(!empty($parts["query"]) ? "?" . $parts["query"] : "") .
(!empty($parts["fragment"]) ? "#" . $parts["fragment"] : "");
return $targetUrl;
}
}