Преглед изворни кода

WebSub: Implement support for hub.secret (#9430)

* WebSub: Implement support for hub.secret
https://www.w3.org/TR/websub/#signing-content
Fix https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-498g-vgpq-mr66
`hub.secret` is optional, but improves security.
Reported by @Atharros

* hub.secret SHOULD only be specified when the request was made over HTTPS

* Minor whitespace

* Add legacy support
Alexandre Alapetite пре 1 дан
родитељ
комит
d4964dc1ff
2 измењених фајлова са 59 додато и 8 уклоњено
  1. 39 8
      app/Models/Feed.php
  2. 20 0
      p/api/pshb.php

+ 39 - 8
app/Models/Feed.php

@@ -1469,13 +1469,18 @@ class FreshRSS_Feed extends Minz_Model {
 					(empty($hubJson['lease_start']) || $hubJson['lease_start'] < time() - (3600 * 23))) {	//Do not renew too often
 					$key = $hubJson['key'];	//To renew our lease
 				}
-			} else {
+			} else {	// First-time subscription
 				@mkdir($path, 0770, true);
 				$key = bin2hex(random_bytes(32));
 				$hubJson = [
 					'hub' => $this->hubUrl,
 					'key' => $key,
 				];
+				// hub.secret SHOULD only be specified when the request was made over HTTPS
+				// https://www.w3.org/TR/websub/#subscription-parameter-details
+				if (str_starts_with(strtolower($this->hubUrl), 'https://')) {
+					$hubJson['secret'] = bin2hex(random_bytes(32));	// length < 200
+				}
 				file_put_contents($hubFilename, json_encode($hubJson));
 				@mkdir(PSHB_PATH . '/keys/', 0770, true);
 				file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl);
@@ -1517,17 +1522,32 @@ class FreshRSS_Feed extends Minz_Model {
 				Minz_Log::warning('Invalid callback for WebSub: ' . $this->url);
 				return false;
 			}
-			if (!$state) {	//unsubscribe
+			$postFields = [
+				'hub.verify' => 'sync',
+				'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
+				'hub.topic' => $url,
+				'hub.callback' => $callbackUrl,
+			];
+			$newSecret = null;
+			if ($state) {	// Subscribe
+				// Legacy (before support of hub.secret) case of renewing an unsigned subscription.
+				// hub.secret SHOULD only be specified when the request was made over HTTPS
+				if (str_starts_with(strtolower($hubJson['hub']), 'https://')) {
+					if (empty($hubJson['secret']) || !is_string($hubJson['secret'])) {
+						// We keep the existing subscription (without secret) until the hub confirmation.
+						$newSecret = bin2hex(random_bytes(32));	// length < 200
+						$postFields['hub.secret'] = $newSecret;
+					} else {
+						$postFields['hub.secret'] = $hubJson['secret'];
+					}
+				}
+			}
+			if (!$state) {	// Unsubscribe
 				$hubJson['lease_end'] = time() - 60;
 				file_put_contents($hubFilename, json_encode($hubJson));
 			}
 			$response = FreshRSS_http_Util::httpGet($hubJson['hub'], null, 'html', [], [
-				CURLOPT_POSTFIELDS => http_build_query([
-					'hub.verify' => 'sync',
-					'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
-					'hub.topic' => $url,
-					'hub.callback' => $callbackUrl,
-				]),
+				CURLOPT_POSTFIELDS => http_build_query($postFields),
 				CURLOPT_MAXREDIRS => 10,
 			]);
 
@@ -1536,6 +1556,17 @@ class FreshRSS_Feed extends Minz_Model {
 				' with callback ' . $callbackUrl . ': ' . $response['status'] . ' ' . $response['body'], PSHB_LOG);
 
 			if ($response['method'] === 'POST' && str_starts_with('' . $response['status'], '2')) {
+				if ($newSecret !== null) {	// Legacy (before support of hub.secret) case.
+					// Reload because hub verification (hub.verify=sync) may have updated $hubFile in the meantime
+					$hJson = json_decode((string)@file_get_contents($hubFilename), true);
+					if (is_array($hJson)) {
+						// Save secret only after hub confirmation
+						$hJson['secret'] = $newSecret;
+						file_put_contents($hubFilename, json_encode($hJson));
+					} else {
+						Minz_Log::warning('Could not save hub.secret for WebSub: ' . $this->url, PSHB_LOG);
+					}
+				}
 				return true;
 			} else {
 				$hubJson['lease_start'] = time();	//Prevent trying again too soon

+ 20 - 0
p/api/pshb.php

@@ -98,6 +98,26 @@ if ($ORIGINAL_INPUT == '') {
 	die('Missing XML payload!');
 }
 
+// Authenticated content distribution	// https://www.w3.org/TR/websub/#signing-content
+$hubSecret = $hubJson['secret'] ?? null;
+if (is_string($hubSecret) && $hubSecret !== '') {
+	$signatureOk = false;
+	// The X-Hub-Signature header has the form `method=hexSignature`, e.g. `sha256=09AF`
+	$signature = is_string($_SERVER['HTTP_X_HUB_SIGNATURE'] ?? null) ? $_SERVER['HTTP_X_HUB_SIGNATURE'] : '';
+	// https://www.php.net/function.hash-hmac-algos
+	if (preg_match('/^(?<algo>[a-zA-Z0-9]{4,6})=(?<signature>[0-9a-fA-F]{40,128})$/', $signature, $matches) === 1) {
+		$algo = strtolower('' . $matches['algo']);
+		if (in_array($algo, ['sha1', 'sha256', 'sha384', 'sha512'], true)) {
+			$signatureOk = hash_equals(hash_hmac($algo, $ORIGINAL_INPUT, $hubSecret), '' . $matches['signature']);
+		}
+	}
+	if (!$signatureOk) {
+		Minz_Log::warning('Invalid signature for WebSub push to ' . $canonical, PSHB_LOG);
+		header('HTTP/1.1 422 Unprocessable Entity');
+		die('Invalid hub signature!');
+	}
+}
+
 $simplePie = new FreshRSS_SimplePieCustom();
 $simplePie->enable_cache(false);
 $simplePie->set_raw_data($ORIGINAL_INPUT);