CVE-2023-22481: Insertion of Sensitive Information into Log File (CWE-532)
value: FreshRSS is a self-hosted RSS feed aggregator. When using the greader API, the provided password is logged in clear in `users/_/log_api.txt` in the case where the authentication fails. The issues occurs in `authorizationToUser()` in `greader.php`. If there is an issue with the request or the credentials, `unauthorized()` or `badRequest()` is called. Both these functions are printing the return of `debugInfo()` in the logs.  `debugInfo()` will return the content of the request. By default, this will be saved in `users/_/log_api.txt` and if the const `COPY_LOG_TO_SYSLOG` is true, in syslogs as well. Exploiting this issue requires having access to logs produced by FreshRSS. Using the information from the logs, a malicious individual could get users' API keys (would be displayed if the users fills in a bad username) or passwords.
CWE description: Information written to log files can be of a sensitive nature and give valuable guidance to an attacker or expose sensitive user information.
CVSS v3 base score: 5.5
Published: 2023-03-06T18:15Z
Repository: https://github.com/FreshRSS/FreshRSS
Language: PHP
Files changed: Docker/FreshRSS.Apache.conf, cli/sensitive-log.sh, lib/lib_rss.php, p/api/fever.php, p/api/greader.php
Commit date: 2023-01-11 23:27:14 +0100
Commit message: API avoid logging passwords (#5001)

* API avoid logging passwords
* Strip passwords and tokens from API logs
* Only log failed requests information when in debug mode

* Remove debug SHA

* Clean also Apache logs

* Better comments

* Redact also token parameters

* shfmt

* Simplify whitespace

* redacted
Diff: diff --git a/Docker/FreshRSS.Apache.conf b/Docker/FreshRSS.Apache.conf
index 2cfb9cbf..6281e59e 100644
--- a/Docker/FreshRSS.Apache.conf
+++ b/Docker/FreshRSS.Apache.conf
@@ -2,11 +2,11 @@ ServerName freshrss.localhost
 Listen 0.0.0.0:80
 DocumentRoot /var/www/FreshRSS/p/
 RemoteIPHeader X-Forwarded-For
 RemoteIPTrustedProxy 10.0.0.1/8 172.16.0.1/12 192.168.0.1/16
 LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined_proxy
-CustomLog /dev/stdout combined_proxy
+CustomLog "|/var/www/FreshRSS/cli/sensitive-log.sh" combined_proxy
 ErrorLog /dev/stderr
 AllowEncodedSlashes On
 ServerTokens OS
 TraceEnable Off
 
diff --git a/cli/sensitive-log.sh b/cli/sensitive-log.sh
new file mode 100755
index 00000000..40309b0d
--- /dev/null
+++ b/cli/sensitive-log.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# Strips sensitive passwords from (Apache) logs
+
+# For e.g. GNU systems such as Debian
+# N.B.: `sed -u` is not available in BusyBox and without it there are buffering delays (even with stdbuf)
+sed -Eu 's/([?&])(Passwd|token)=[^& \t]+/\1\2=redacted/ig' 2>/dev/null ||
+
+	# For systems with gawk (not available by default in Docker of Debian or Alpine) or with BuzyBox such as Alpine
+	$(which gawk || which awk) -v IGNORECASE=1 '{ print gensub(/([?&])(Passwd|token)=[^& \t]+/, "\\1\\2=redacted", "g") }'
diff --git a/lib/lib_rss.php b/lib/lib_rss.php
index d0e819d9..cbdfff77 100644
--- a/lib/lib_rss.php
+++ b/lib/lib_rss.php
@@ -221,10 +221,35 @@ function html_only_entity_decode($text): string {
 		));
 	}
 	return $text == '' ? '' : strtr($text, $htmlEntitiesOnly);
 }
 
+/**
+ * Remove passwords in FreshRSS logs.
+ * See also ../cli/sensitive-log.sh for Web server logs.
+ * @param array<string,mixed>|string $log
+ * @return array<string,mixed>|string
+ */
+function sensitive_log($log) {
+	if (is_array($log)) {
+		foreach ($log as $k => $v) {
+			if (in_array($k, ['api_key', 'Passwd', 'T'])) {
+				$log[$k] = '██';
+			} else {
+				$log[$k] = sensitive_log($v);
+			}
+		}
+	} elseif (is_string($log)) {
+		$log = preg_replace([
+				'/\b(auth=.*?\/)[^&]+/i',
+				'/\b(Passwd=)[^&]+/i',
+				'/\b(Authorization)[^&]+/i',
+			], '$1█', $log);
+	}
+	return $log;
+}
+
 /**
  * @param array<string,mixed> $attributes
  */
 function customSimplePie($attributes = array()): SimplePie {
 	$limits = FreshRSS_Context::$system_conf->limits;
diff --git a/p/api/fever.php b/p/api/fever.php
index b7f9b916..13907f16 100644
--- a/p/api/fever.php
+++ b/p/api/fever.php
@@ -16,11 +16,12 @@ require(__DIR__ . '/../../constants.php');
 require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
 FreshRSS_Context::initSystem();
 
 // check if API is enabled globally
 if (!FreshRSS_Context::$system_conf->api_enabled) {
-	Minz_Log::warning('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('Fever API: service unavailable!');
+	Minz_Log::debug('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG);
 	header('HTTP/1.1 503 Service Unavailable');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Service Unavailable!');
 }
 
@@ -43,20 +44,20 @@ function debugInfo() {
 				$ALL_HEADERS[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
 			}
 		}
 	}
 	global $ORIGINAL_INPUT;
-	return print_r(
-		array(
+	$log = sensitive_log([
 			'date' => date('c'),
 			'headers' => $ALL_HEADERS,
 			'_SERVER' => $_SERVER,
 			'_GET' => $_GET,
 			'_POST' => $_POST,
 			'_COOKIE' => $_COOKIE,
-			'INPUT' => $ORIGINAL_INPUT
-		), true);
+			'INPUT' => $ORIGINAL_INPUT,
+		]);
+	return print_r($log, true);
 }
 
 //Minz_Log::debug('----------------------------------------------------------------', API_LOG);
 //Minz_Log::debug(debugInfo(), API_LOG);
 // </Debug>
diff --git a/p/api/greader.php b/p/api/greader.php
index afca1afa..a3dad880 100644
--- a/p/api/greader.php
+++ b/p/api/greader.php
@@ -95,53 +95,58 @@ function debugInfo() {
 				$ALL_HEADERS[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
 			}
 		}
 	}
 	global $ORIGINAL_INPUT;
-	return print_r(
-		array(
+	$log = sensitive_log([
 			'date' => date('c'),
 			'headers' => $ALL_HEADERS,
 			'_SERVER' => $_SERVER,
 			'_GET' => $_GET,
 			'_POST' => $_POST,
 			'_COOKIE' => $_COOKIE,
-			'INPUT' => $ORIGINAL_INPUT
-		), true);
+			'INPUT' => $ORIGINAL_INPUT,
+		]);
+	return print_r($log, true);
 }
 
 function badRequest() {
-	Minz_Log::warning('badRequest() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
+	Minz_Log::debug('badRequest() ' . debugInfo(), API_LOG);
 	header('HTTP/1.1 400 Bad Request');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Bad Request!');
 }
 
 function unauthorized() {
-	Minz_Log::warning('unauthorized() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
+	Minz_Log::debug('unauthorized() ' . debugInfo(), API_LOG);
 	header('HTTP/1.1 401 Unauthorized');
 	header('Content-Type: text/plain; charset=UTF-8');
 	header('Google-Bad-Token: true');
 	die('Unauthorized!');
 }
 
 function notImplemented() {
-	Minz_Log::warning('notImplemented() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
+	Minz_Log::debug('notImplemented() ' . debugInfo(), API_LOG);
 	header('HTTP/1.1 501 Not Implemented');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Not Implemented!');
 }
 
 function serviceUnavailable() {
-	Minz_Log::warning('serviceUnavailable() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
+	Minz_Log::debug('serviceUnavailable() ' . debugInfo(), API_LOG);
 	header('HTTP/1.1 503 Service Unavailable');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Service Unavailable!');
 }
 
 function checkCompatibility() {
-	Minz_Log::warning('checkCompatibility() ' . debugInfo(), API_LOG);
+	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
+	Minz_Log::debug('checkCompatibility() ' . debugInfo(), API_LOG);
 	header('Content-Type: text/plain; charset=UTF-8');
 	if (PHP_INT_SIZE < 8 && !function_exists('gmp_init')) {
 		die('FAIL 64-bit or GMP extension! Wrong PHP configuration.');
 	}
 	$headerAuth = headerVariable('Authorization', 'GoogleLogin_auth');
@@ -170,12 +175,11 @@ function authorizationToUser() {
 					unauthorized();
 				}
 				if ($headerAuthX[1] === sha1(FreshRSS_Context::$system_conf->salt . $user . FreshRSS_Context::$user_conf->apiPasswordHash)) {
 					return $user;
 				} else {
-					Minz_Log::warning('Invalid API authorisation for user ' . $user . ': ' . $headerAuthX[1], API_LOG);
-					Minz_Log::warning('Invalid API authorisation for user ' . $user . ': ' . $headerAuthX[1]);
+					Minz_Log::warning('Invalid API authorisation for user ' . $user);
 					unauthorized();
 				}
 			} else {
 				badRequest();
 			}

Vulnerable code: CustomLog /dev/stdout combined_proxy
	Minz_Log::warning('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG);
	return print_r(
		array(
			'INPUT' => $ORIGINAL_INPUT
		), true);
	return print_r(
		array(
			'INPUT' => $ORIGINAL_INPUT
		), true);
	Minz_Log::warning('badRequest() ' . debugInfo(), API_LOG);
	Minz_Log::warning('unauthorized() ' . debugInfo(), API_LOG);
	Minz_Log::warning('notImplemented() ' . debugInfo(), API_LOG);
	Minz_Log::warning('serviceUnavailable() ' . debugInfo(), API_LOG);
	Minz_Log::warning('checkCompatibility() ' . debugInfo(), API_LOG);
					Minz_Log::warning('Invalid API authorisation for user ' . $user . ': ' . $headerAuthX[1], API_LOG);
					Minz_Log::warning('Invalid API authorisation for user ' . $user . ': ' . $headerAuthX[1]);
Fixed code: CustomLog "|/var/www/FreshRSS/cli/sensitive-log.sh" combined_proxy
#!/bin/sh
# Strips sensitive passwords from (Apache) logs

# For e.g. GNU systems such as Debian
# N.B.: `sed -u` is not available in BusyBox and without it there are buffering delays (even with stdbuf)
sed -Eu 's/([?&])(Passwd|token)=[^& \t]+/\1\2=redacted/ig' 2>/dev/null ||

	# For systems with gawk (not available by default in Docker of Debian or Alpine) or with BuzyBox such as Alpine
	$(which gawk || which awk) -v IGNORECASE=1 '{ print gensub(/([?&])(Passwd|token)=[^& \t]+/, "\\1\\2=redacted", "g") }'
/**
 * Remove passwords in FreshRSS logs.
 * See also ../cli/sensitive-log.sh for Web server logs.
 * @param array<string,mixed>|string $log
 * @return array<string,mixed>|string
 */
function sensitive_log($log) {
	if (is_array($log)) {
		foreach ($log as $k => $v) {
			if (in_array($k, ['api_key', 'Passwd', 'T'])) {
				$log[$k] = '██';
			} else {
				$log[$k] = sensitive_log($v);
			}
		}
	} elseif (is_string($log)) {
		$log = preg_replace([
				'/\b(auth=.*?\/)[^&]+/i',
				'/\b(Passwd=)[^&]+/i',
				'/\b(Authorization)[^&]+/i',
			], '$1█', $log);
	}
	return $log;
}

	Minz_Log::warning('Fever API: service unavailable!');
	Minz_Log::debug('Fever API: serviceUnavailable() ' . debugInfo(), API_LOG);
	$log = sensitive_log([
			'INPUT' => $ORIGINAL_INPUT,
		]);
	return print_r($log, true);
	$log = sensitive_log([
			'INPUT' => $ORIGINAL_INPUT,
		]);
	return print_r($log, true);
	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
	Minz_Log::debug('badRequest() ' . debugInfo(), API_LOG);
	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
	Minz_Log::debug('unauthorized() ' . debugInfo(), API_LOG);
	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
	Minz_Log::debug('notImplemented() ' . debugInfo(), API_LOG);
	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
	Minz_Log::debug('serviceUnavailable() ' . debugInfo(), API_LOG);
	Minz_Log::warning('GReader API: ' . __METHOD__, API_LOG);
	Minz_Log::debug('checkCompatibility() ' . debugInfo(), API_LOG);
					Minz_Log::warning('Invalid API authorisation for user ' . $user);