Files
zoneminder/tests/php/test_api_error_log_redaction.php
T
Nic Boet 91b06f6d02 fix(api): keep credentials out of cake_error.log
Cake logs every uncaught exception, 4xx included, with "Request URL:"
and the full query string. API clients may authenticate with ?token=,
?auth= or ?user=&pass=, so a 401, 404 or 500 wrote a working access
token, auth hash or password to cake_error.log and error.log in
ZM_LOGDIR, both world-readable.

ZmErrorHandler extends Cake's ErrorHandler and replaces the values of
token, auth, pass and password with [redacted] in the logged message.
core.php uses it for both errors and exceptions.

Credentials in the query string still reach web server and proxy
access logs; that is outside the API code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b80568847d)
2026-10-30 18:53:27 -04:00

76 lines
2.8 KiB
PHP

<?php
// Tests what the API's error handler writes to cake_error.log:
// ZmErrorHandler::redact() - credential values in a URL or log text
// ZmErrorHandler::_getMessage() - the logged "Request URL:" must not carry
// token=, auth=, pass= or password= values
//
// Cake's ErrorHandler is stubbed, so this runs from a source checkout:
// php tests/php/test_api_error_log_redaction.php
class App {
public static function uses($class, $package) {}
}
// What Cake's ErrorHandler::_getMessage() builds: class, message, request URL.
class ErrorHandler {
public static $url = '';
protected static function _getMessage($exception) {
return '['.get_class($exception).'] '.$exception->getMessage()
."\nRequest URL: ".static::$url."\nStack Trace:\n#0 {main}";
}
}
require_once __DIR__.'/../../web/api/app/Lib/Error/ZmErrorHandler.php';
class TestErrorHandler extends ZmErrorHandler {
public static function message($exception) { return static::_getMessage($exception); }
}
$failures = 0;
$passes = 0;
function check($name, $got, $expected) {
global $failures, $passes;
if ($got === $expected) {
$passes++;
echo "ok - $name\n";
} else {
$failures++;
echo "not ok - $name (got ".var_export($got, true)." expected ".var_export($expected, true).")\n";
}
}
// ---- ZmErrorHandler::redact() ----
check('token value is redacted',
ZmErrorHandler::redact('/zm/api/monitors.json?token=eyJ0.eyJp.sig'),
'/zm/api/monitors.json?token=[redacted]');
check('user is kept, pass is redacted',
ZmErrorHandler::redact('/zm/api/host/login.json?user=admin&pass=s3cret&x=1'),
'/zm/api/host/login.json?user=admin&pass=[redacted]&x=1');
check('auth and password are redacted',
ZmErrorHandler::redact('/a?auth=abc123&password=pw'),
'/a?auth=[redacted]&password=[redacted]');
check('HTML-escaped separator is matched',
ZmErrorHandler::redact('/a?p=%2Fa&amp;token=eyJ0.x.y'),
'/a?p=%2Fa&amp;token=[redacted]');
check('names are matched whole, not as a suffix',
ZmErrorHandler::redact('/a?mytoken=1&passive=2&bypass=3'),
'/a?mytoken=1&passive=2&bypass=3');
check('text without credentials is unchanged',
ZmErrorHandler::redact('/zm/api/monitors/view/1.json?p=%2Fmonitors'),
'/zm/api/monitors/view/1.json?p=%2Fmonitors');
// ---- the logged message ----
ErrorHandler::$url = '/zm/api/monitors/view/9999.json?p=%2Fx&user=viewer&pass=s3cret&token=eyJ0.eyJp.sig';
$logged = TestErrorHandler::message(new Exception('Invalid monitor'));
check('logged message has no password', strpos($logged, 's3cret'), false);
check('logged message has no token', strpos($logged, 'eyJ0'), false);
check('logged message keeps the path and user',
strpos($logged, 'Request URL: /zm/api/monitors/view/9999.json?p=%2Fx&user=viewer&pass=[redacted]') !== false, true);
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);
?>