diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index c51bb7cde32..f4ae0feb03a 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -121,8 +121,12 @@ private function createResponse(Request $request, BridgeAbstract $bridge, string $items = $feedItems; } $feed = $bridge->getFeed(); - } catch (\Exception $e) { - // Probably an exception inside a bridge + } catch (\Throwable $e) { + if ($e instanceof RateLimitException) { + // These are internally generated by bridges + $this->logger->info(sprintf('RateLimitException in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); + return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 429); + } if ($e instanceof HttpException) { // Reproduce (and log) these responses regardless of error output and report limit if ($e->getCode() === 429) { @@ -165,7 +169,7 @@ private function createResponse(Request $request, BridgeAbstract $bridge, string 'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT', 'content-type' => $format->getMimeType() . '; charset=' . $format->getCharset(), ]; - return new Response($format->stringify(), 200, $headers); + return new Response($format->stringify($request), 200, $headers); } private function createFeedItemFromException($e, BridgeAbstract $bridge): FeedItem @@ -213,7 +217,7 @@ private function logBridgeError($bridgeName, $code) return $report['count']; } - private static function createGithubIssueUrl(BridgeAbstract $bridge, \Exception $e, string $message): string + private static function createGithubIssueUrl(BridgeAbstract $bridge, \Throwable $e, string $message): string { $maintainer = $bridge->getMaintainer(); if (str_contains($maintainer, ',')) { diff --git a/bridges/NvidiaDriverBridge.php b/bridges/NvidiaDriverBridge.php new file mode 100644 index 00000000000..595411bc8cf --- /dev/null +++ b/bridges/NvidiaDriverBridge.php @@ -0,0 +1,49 @@ + [ + 'name' => 'Version', + 'type' => 'list', + 'values' => [ + 'All' => '', + 'Beta' => '0', + 'New Feature Branch' => '5', + 'Recommended/Certified' => '1', + ], + ], + ], + ]; + + public function collectData() + { + $whql = $this->getInput('whql'); + + $parameters = [ + 'lid' => 1, // en-us + 'psid' => 129, // GeForce + 'osid' => 12, // Linux 64-bit + 'whql' => $whql, + ]; + + $url = 'https://www.nvidia.com/Download/processFind.aspx?' . http_build_query($parameters); + $dom = getSimpleHTMLDOM($url); + + foreach ($dom->find('tr#driverList') as $element) { + $id = str_replace('img_', '', $element->find('img', 0)->id); + + $this->items[] = [ + 'timestamp' => $element->find('td.gridItem', 3)->plaintext, + 'title' => sprintf('NVIDIA Linux Driver %s', $element->find('td.gridItem', 2)->plaintext), + 'uri' => 'https://www.nvidia.com/Download/driverResults.aspx/' . $id, + 'content' => $dom->find('tr#tr_' . $id . ' span', 0)->innertext, + ]; + } + } +} diff --git a/bridges/RedditBridge.php b/bridges/RedditBridge.php index ef74fdcdf34..03f279d87c0 100644 --- a/bridges/RedditBridge.php +++ b/bridges/RedditBridge.php @@ -93,12 +93,12 @@ public function collectData() { $forbiddenKey = 'reddit_forbidden'; if ($this->cache->get($forbiddenKey)) { - throw new HttpException('403 Forbidden', 403); + throw new RateLimitException(); } $rateLimitKey = 'reddit_rate_limit'; if ($this->cache->get($rateLimitKey)) { - throw new HttpException('429 Too Many Requests', 429); + throw new RateLimitException(); } try { @@ -108,8 +108,10 @@ public function collectData() // 403 Forbidden // This can possibly mean that reddit has permanently blocked this server's ip address $this->cache->set($forbiddenKey, true, 60 * 61); + throw new RateLimitException(); } elseif ($e->getCode() === 429) { $this->cache->set($rateLimitKey, true, 60 * 61); + throw new RateLimitException(); } throw $e; } diff --git a/bridges/Vk2Bridge.php b/bridges/Vk2Bridge.php index 6fecba84c54..62ba8e05178 100644 --- a/bridges/Vk2Bridge.php +++ b/bridges/Vk2Bridge.php @@ -194,7 +194,7 @@ protected function getImageURLWithLargestWidth($items) public function collectData() { if ($this->cache->get($this->rateLimitCacheKey)) { - throw new HttpException('429 Too Many Requests', 429); + throw new RateLimitException(); } $u = $this->getInput('u'); diff --git a/bridges/VkBridge.php b/bridges/VkBridge.php index 22957f26c4a..0d62305b923 100644 --- a/bridges/VkBridge.php +++ b/bridges/VkBridge.php @@ -519,7 +519,7 @@ private function getContents() $uri = urljoin(self::URI, $headers['location'][0]); if (str_contains($uri, '/429.html')) { - returnServerError('VK responded "Too many requests"'); + throw new RateLimitException(); } if (!preg_match('#^https?://vk.com/#', $uri)) { diff --git a/bridges/YoutubeBridge.php b/bridges/YoutubeBridge.php index af14c856f2c..647b1c4202a 100644 --- a/bridges/YoutubeBridge.php +++ b/bridges/YoutubeBridge.php @@ -82,13 +82,14 @@ public function collectData() { $cacheKey = 'youtube_rate_limit'; if ($this->cache->get($cacheKey)) { - throw new HttpException('429 Too Many Requests', 429); + throw new RateLimitException(); } try { $this->collectDataInternal(); } catch (HttpException $e) { if ($e->getCode() === 429) { $this->cache->set($cacheKey, true, 60 * 16); + throw new RateLimitException(); } throw $e; } diff --git a/config.default.ini.php b/config.default.ini.php index c6b0779da9f..b91ebe708f3 100644 --- a/config.default.ini.php +++ b/config.default.ini.php @@ -27,6 +27,16 @@ ;enabled_bridges[] = YouTubeCommunityTabBridge enabled_bridges[] = * +; Encrypted URL key. A random string between 16 and 64 characters long which is used to generate +; compressed and encrypted feed URLs, to keep private information secret. +; +; NEVER SHARE THIS KEY. +; A password generator should be used to create this string. Whitespace is NOT ALLOWED. +; +; If this value is empty (default), then encrypted URLs cannot be used. +; Example key (DO NOT USE THIS): "b3c7@hsLqk)P(SJvjCBDUy]GMg6RamdHxEWV8K9nA4QN.p_5" +enc_url_key = "" + ; Defines the timezone used by RSS-Bridge ; Find a list of supported timezones at ; https://www.php.net/manual/en/timezones.php diff --git a/formats/AtomFormat.php b/formats/AtomFormat.php index 5c9f2b6acfb..e2a431b2256 100644 --- a/formats/AtomFormat.php +++ b/formats/AtomFormat.php @@ -14,7 +14,7 @@ class AtomFormat extends FormatAbstract protected const ATOM_NS = 'http://www.w3.org/2005/Atom'; protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; - public function stringify() + public function stringify(?Request $request) { $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; diff --git a/formats/HtmlFormat.php b/formats/HtmlFormat.php index 37ef3a930db..23bee4b3d7e 100644 --- a/formats/HtmlFormat.php +++ b/formats/HtmlFormat.php @@ -4,7 +4,7 @@ class HtmlFormat extends FormatAbstract { const MIME_TYPE = 'text/html'; - public function stringify() + public function stringify(?Request $request) { // This query string is url encoded $queryString = $_SERVER['QUERY_STRING']; @@ -13,6 +13,12 @@ public function stringify() $formatFactory = new FormatFactory(); $formats = []; + if (str_contains(strtolower($queryString), strtolower(UrlEncryptionService::PARAMETER_NAME . '='))) { + $encryptionToken = 'yes'; + } else { + $encryptionToken = null; + } + // Create all formats (except HTML) $formatNames = $formatFactory->getFormatNames(); foreach ($formatNames as $formatName) { @@ -20,7 +26,14 @@ public function stringify() continue; } // The format url is relative, but should be absolute in order to help feed readers. - $formatUrl = '?' . str_ireplace('format=Html', 'format=' . $formatName, $queryString); + if (str_contains(strtolower($queryString), 'format=html')) { + $formatUrl = '?' . str_ireplace('format=Html', 'format=' . $formatName, $queryString); + } else { + // If we're viewing the HtmlFormat and the 'format' GET parameter isn't here, this is likely an + // encrypted URL being viewed. Handle this by reconstructing the raw URL with the new format. + $formatUrl = '?' . http_build_query($request->toArray()); + $formatUrl .= (strlen($formatUrl) > 1 ? '&' : '') . 'format=' . $formatName; + } $formatObject = $formatFactory->create($formatName); $formats[] = [ 'url' => $formatUrl, @@ -48,12 +61,14 @@ public function stringify() } $html = render_template(__DIR__ . '/../templates/html-format.html.php', [ - 'charset' => $this->getCharset(), - 'title' => $feedArray['name'], - 'formats' => $formats, - 'uri' => $feedArray['uri'], - 'items' => $items, - 'donation_uri' => $donationUri, + 'charset' => $this->getCharset(), + 'title' => $feedArray['name'], + 'formats' => $formats, + 'uri' => $feedArray['uri'], + 'items' => $items, + 'donation_uri' => $donationUri, + 'encryption_token' => $encryptionToken, + 'bridge_name' => $request->get('bridge'), ]); // Remove invalid characters ini_set('mbstring.substitute_character', 'none'); diff --git a/formats/JsonFormat.php b/formats/JsonFormat.php index 586aae0afba..c844d0190f8 100644 --- a/formats/JsonFormat.php +++ b/formats/JsonFormat.php @@ -23,7 +23,7 @@ class JsonFormat extends FormatAbstract 'uid', ]; - public function stringify() + public function stringify(?Request $request) { $feedArray = $this->getFeed(); diff --git a/formats/MrssFormat.php b/formats/MrssFormat.php index aaa1d0cd1b8..dcbe6fe95d1 100644 --- a/formats/MrssFormat.php +++ b/formats/MrssFormat.php @@ -32,7 +32,7 @@ class MrssFormat extends FormatAbstract protected const ATOM_NS = 'http://www.w3.org/2005/Atom'; protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; - public function stringify() + public function stringify(?Request $request) { $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; diff --git a/formats/PlaintextFormat.php b/formats/PlaintextFormat.php index 4e18caa6058..0bc95c64fa0 100644 --- a/formats/PlaintextFormat.php +++ b/formats/PlaintextFormat.php @@ -4,7 +4,7 @@ class PlaintextFormat extends FormatAbstract { const MIME_TYPE = 'text/plain'; - public function stringify() + public function stringify(?Request $request) { $feed = $this->getFeed(); foreach ($this->getItems() as $item) { diff --git a/formats/SfeedFormat.php b/formats/SfeedFormat.php index 33740aaa863..bc59b7b7c93 100644 --- a/formats/SfeedFormat.php +++ b/formats/SfeedFormat.php @@ -4,7 +4,7 @@ class SfeedFormat extends FormatAbstract { const MIME_TYPE = 'text/plain'; - public function stringify() + public function stringify(?Request $request) { $text = ''; foreach ($this->getItems() as $item) { diff --git a/lib/BridgeAbstract.php b/lib/BridgeAbstract.php index 2467dec60e1..a0c87e6d96e 100644 --- a/lib/BridgeAbstract.php +++ b/lib/BridgeAbstract.php @@ -104,9 +104,27 @@ public function getMaintainer(): string /** * A more correct method name would have been "getContexts" */ - public function getParameters(): array + final public function getParameters(): array { - return static::PARAMETERS; + $parameters = static::PARAMETERS; + + if (UrlEncryptionService::enabled()) { + // A parameter cannot be defined which collides with the special encryption parameter. + $illegalToken = array_key_exists(UrlEncryptionService::PARAMETER_NAME, $parameters); + foreach ($parameters as $k => $v) { + $illegalToken |= array_key_exists(UrlEncryptionService::PARAMETER_NAME, $v); + } + + if ($illegalToken) { + throw new \Exception( + 'The parameter name "' . UrlEncryptionService::PARAMETER_NAME + . '" is reserved for encrypted URLs. Remove this from the PARAMETERS definition in bridge "' + . $this->getName() . '".' + ); + } + } + + return $parameters; } public function getItems() diff --git a/lib/FormatAbstract.php b/lib/FormatAbstract.php index 28eb4bbfa7a..dc6590c9591 100644 --- a/lib/FormatAbstract.php +++ b/lib/FormatAbstract.php @@ -12,7 +12,7 @@ abstract class FormatAbstract protected array $feed = []; - abstract public function stringify(); + abstract public function stringify(?Request $request); public function setFeed(array $feed) { diff --git a/lib/ParameterValidator.php b/lib/ParameterValidator.php index e2783586210..8a2aa3cb7c3 100644 --- a/lib/ParameterValidator.php +++ b/lib/ParameterValidator.php @@ -10,7 +10,13 @@ public function validateInput(array &$input, $contexts): array $errors = []; foreach ($input as $name => $value) { + if ($name === UrlEncryptionService::PARAMETER_NAME && UrlEncryptionService::enabled()) { + // Do not validate against encrypted URL tokens. + continue; + } + $registered = false; + foreach ($contexts as $contextName => $contextParameters) { if (!array_key_exists($name, $contextParameters)) { continue; diff --git a/lib/RssBridge.php b/lib/RssBridge.php index e80e6f0a553..0083f96409b 100644 --- a/lib/RssBridge.php +++ b/lib/RssBridge.php @@ -18,21 +18,8 @@ public function __construct( public function main(array $argv = []): Response { - if ($argv) { - parse_str(implode('&', array_slice($argv, 1)), $cliArgs); - $request = Request::fromCli($cliArgs); - } else { - $request = Request::fromGlobals(); - } - - foreach ($request->toArray() as $key => $value) { - if (!is_string($value)) { - return new Response(render(__DIR__ . '/../templates/error.html.php', [ - 'message' => "Query parameter \"$key\" is not a string.", - ]), 400); - } - } - + // The check for maintenance mode should always occur first since it has no + // dependencies, and nothing else needs to come before it for bootstrapping. if (Configuration::getConfig('system', 'enable_maintenance_mode')) { return new Response(render(__DIR__ . '/../templates/error.html.php', [ 'title' => '503 Service Unavailable', @@ -40,6 +27,13 @@ public function main(array $argv = []): Response ]), 503); } + if ($argv) { + parse_str(implode('&', array_slice($argv, 1)), $cliArgs); + $request = Request::fromCli($cliArgs); + } else { + $request = Request::fromGlobals(); + } + // HTTP Basic auth check if (Configuration::getConfig('authentication', 'enable')) { if (Configuration::getConfig('authentication', 'password') === '') { @@ -65,6 +59,33 @@ public function main(array $argv = []): Response // At this point the username and password was correct } + // If the URL contains an encrypted token, then the rest of the current URL + // parameters are discarded and the encrypted token is decrypted, decompressed, + // and expanded into the Request object's 'get' container. The user should NEVER + // be redirected to another URL that would expose what params that are in the + // current page. + if ( + $request->get(UrlEncryptionService::PARAMETER_NAME) + && UrlEncryptionService::enabled() + ) { + try { + $request->tryDecryptUrl(); + } catch (\Exception $e) { + return new Response( + render(__DIR__ . '/../templates/error.html.php', ['message' => $e->getMessage()]), + 401 + ); + } + } + + foreach ($request->toArray() as $key => $value) { + if (!is_string($value)) { + return new Response(render(__DIR__ . '/../templates/error.html.php', [ + 'message' => "Query parameter \"$key\" is not a string.", + ]), 400); + } + } + // Add token as attribute to request $request = $request->withAttribute('token', $request->get('token')); diff --git a/lib/UrlEncryptionService.php b/lib/UrlEncryptionService.php new file mode 100644 index 00000000000..f175730bac0 --- /dev/null +++ b/lib/UrlEncryptionService.php @@ -0,0 +1,173 @@ +rawTokenFromRequest = $rawToken; + $this->key = self::getKey(); + } + + public static function generateFromQueryString(string $q): string + { + if (!self::enabled()) { + throw new \Exception('URL encryption is not enabled (an empty key cannot be used).'); + } + + // Always trim off leading '?' marks if they appear in the input. + if (str_starts_with($q, '?')) { + $q = substr($q, 1); + } + + if (!$q) { + throw new \Exception('The incoming query string to encrypt cannot be empty.'); + } + + if (!in_array(self::CIPHER, openssl_get_cipher_methods())) { + throw new \Exception('The cipher "' . self::CIPHER . '" is not supported for this RSS-Bridge instance.'); + } + + $ivLength = openssl_cipher_iv_length(self::CIPHER); + $iv = openssl_random_pseudo_bytes($ivLength); + + // Encrypt the compressed data. + $cipherText = openssl_encrypt( + $q, + self::CIPHER, + self::getKey(), + 0, + $iv + ); + + if (!$cipherText) { + throw new \Exception('Failed to generate an encrypted URL (invalid ciphertext).'); + } + + // The object to marshal later is in the concatenated format below. + // $raw[:1] = one-byte length of the init vector (n) + // $raw[1:n] = raw init vector + // $raw[n:] = decoded (raw) ciphertext + // + // This same structure is used when decoding and decrypting incoming tokens to + // rebuild the original query string. + $raw = chr($ivLength & 0xFF); + $raw .= $iv; + $raw .= base64_decode($cipherText); + + return base64_encode($raw); + } + + public static function enabled(): bool + { + return !!self::getKey(); + } + + public static function getKey(): ?string + { + $key = trim(Configuration::getConfig('system', 'enc_url_key', '')); + + if (!$key) { + // No key means the URL encryption feature is disabled. + return null; + } + + if ($key === 'b3c7@hsLqk)P(SJvjCBDUy]GMg6RamdHxEWV8K9nA4QN.p_5') { + throw new \Exception('You cannot use the example URL encryption key... Don\'t be lazy.'); + } + + if (strlen($key) > 64 || strlen($key) < 16) { + throw new \Exception('The URL encryption key must be between 16 and 64 characters long.'); + } + + if (preg_match('#\s#', $key)) { + throw new \Exception('The URL encryption key cannot contain whitespace.'); + } + + return $key; + } + + public static function fromRequest(Request &$request): ?self + { + if (!self::enabled()) { + return null; + } + + self::$instance = new self($request->get(self::PARAMETER_NAME)); + self::$instance->decrypt(); + + return self::$instance; + } + + public function toArray(): array + { + return $this->extractedContext; + } + + private function decrypt(): void + { + if (!$this->key) { + throw new \Exception('URL encryption is not enabled (an empty key cannot be used).'); + } + + if (!$this->rawTokenFromRequest) { + throw new \Exception('The request does not contain a decrypt-able token.'); + } + + $t = $this->rawTokenFromRequest; + $ivLength = ord($t[0]); + + if (!$t) { + throw new \Exception('Invalid token base64 value.'); + } elseif (!$ivLength || $ivLength > 32) { + throw new \Exception('Invalid initialization vector length.'); + } elseif ($ivLength >= strlen($t)) { + throw new \Exception('No payload to decrypt.'); + } + + $iv = substr($t, 1, $ivLength); + $cipherText = base64_encode(substr($t, $ivLength + 1)); + + $originalQuery = openssl_decrypt( + $cipherText, + self::CIPHER, + self::getKey(), + 0, + $iv + ); + + if (!$originalQuery) { + throw new \Exception('Failed to decrypt the given token.'); + } + + $result = []; + parse_str($originalQuery, $result); + + if (!count($result)) { + throw new \Exception('The encrypted token did not result in a parseable query string.'); + } + + // Finally, set the extracted context store and put the _eut back in. + // This gets bubbled up to the 'get' container of a Request instance later. + $this->extractedContext = $result; + } +} \ No newline at end of file diff --git a/lib/http.php b/lib/http.php index 39f0c72710f..a1eca5a5c43 100644 --- a/lib/http.php +++ b/lib/http.php @@ -1,5 +1,15 @@ get; } + + public function tryDecryptUrl(): void + { + $urlEncryptionService = UrlEncryptionService::fromRequest($this); + if (!$urlEncryptionService) { + throw new \Exception('The encrypted URL token is not valid.'); + } + + $this->get = $urlEncryptionService->toArray(); + } } final class Response diff --git a/scalingo.json b/scalingo.json deleted file mode 100644 index 9b1d51e6e23..00000000000 --- a/scalingo.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "RSS Bridge", - "description": "rss-bridge is a PHP project capable of generating ATOM feeds for websites which don't have one.", - "repository": "https://github.com/sebsauvage/rss-bridge", - "website": "https://github.com/sebsauvage/rss-bridge" -} diff --git a/templates/html-format.html.php b/templates/html-format.html.php index bc95c5d04e7..a9f019a6805 100644 --- a/templates/html-format.html.php +++ b/templates/html-format.html.php @@ -29,19 +29,62 @@ = e($title) ?> + +
Obscure the parameters used to create this feed. This is essential if your + feed includes private data like API tokens, passphrases, etc.
++ If you specifically want an encrypted link to another format, click the button below and choose + a format from the generated page. +
++ + + + +
+✓ Encrypted
+