Files
zoneminder/web/api/app/Controller/AppController.php
T
Isaac Connor 9f3f6c6770 fix: only trust X-Forwarded-For from configured proxies for auth hash IPs
With ZM_AUTH_HASH_IPS on, the client address bound into the auth hash was
taken from the left-most X-Forwarded-For value whenever the header was
present, both when PHP generated the hash (getRemoteAddr()) and when PHP or
zms validated it (getAuthUser(), zmLoadAuthUser()). The header is client
controlled, so anyone holding a leaked hash could replay it from anywhere by
sending the address it was bound to.

Add ZM_AUTH_TRUSTED_PROXIES, a list of exact reverse proxy addresses.
X-Forwarded-For is now used only when REMOTE_ADDR is one of them, and is read
from the right, skipping hops that are themselves listed proxies, so values a
client prepends are never chosen. With the option empty, the default, the
header is ignored and REMOTE_ADDR is used.

PHP (web/includes/Network.php getRemoteAddr()) and C++ (ClientAddress() in
zm_utils, used by zmLoadAuthUser()) implement the same rule so generation and
validation continue to agree. Every PHP caller already routes through
getRemoteAddr(), so session.php and auth.php need no change.

Reverse proxy users who enable ZM_AUTH_HASH_IPS must list their proxy in the
new option; until they do, hashes bind to the proxy address, which still
validates but no longer distinguishes clients. This is the behaviour change
that the #4921 work avoided by trusting the header.

The option is added through ConfigData only, like other recent options;
zmupdate.pl --freshen inserts it, zms falls back to the compiled-in default and
PHP treats an undefined constant as empty, so no schema migration is needed.

Tests: ClientAddress Catch2 case; tests/php/test_remote_addr.php updated for
the trusted-proxy rule.

refs GHSA-72rf-54rm-798c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit acea5889dec0826f595cb736147a5fdc9c94a2a9)
2026-09-24 19:51:27 -04:00

193 lines
7.5 KiB
PHP

<?php
/**
* Application level Controller
*
* This file is application-wide controller file. You can put all
* application-wide controller-related methods here.
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @package app.Controller
* @since CakePHP(tm) v 0.2.9
* @license https://www.opensource.org/licenses/mit-license.php MIT License
*/
App::uses('Controller', 'Controller');
App::uses('CrudControllerTrait', 'Crud.Lib');
/**
* Application Controller
*
* Add your application-wide methods in the class below, your controllers
* will inherit them.
*
* @package app.Controller
* @link https://book.cakephp.org/2.0/en/controllers.html#the-app-controller
*/
class AppController extends Controller {
use CrudControllerTrait;
public $components = [
'RequestHandler',
'Crud.Crud' => [
'actions' => [
'index' => 'Crud.Index',
'add' => 'Crud.Add',
'edit' => 'Crud.Edit',
'view' => 'Crud.View',
'keyvalue' => 'Crud.List',
'category' => 'Crud.Category'
],
'listeners' => ['Api', 'ApiTransformation']
#],
#'DebugKit.Toolbar' => [
# 'bootstrap' => true, 'routes' => true
]
];
// Global beforeFilter function
//Zoneminder sets the username session variable
// to the logged in user. If this variable is set
// then you are logged in
// its pretty simple to extend this to also check
// for role and deny API access in future
// Also checking to do this only if ZM_OPT_USE_AUTH is on
public function beforeFilter() {
if ( ! ZM_OPT_USE_API ) {
throw new UnauthorizedException(__('API Disabled'));
return;
}
# For use throughout the app. If not logged in, this will be null.
global $user;
require_once __DIR__ .'/../../../includes/auth.php';
# This will auto-login if username=&password= are set, or auth=
zm_authenticate_request();
if ( ZM_OPT_USE_AUTH ) {
if ( ZM_OPT_USE_LEGACY_API_AUTH or !strcasecmp($this->params->action, 'login') ) {
# This is here because historically we allowed user=&pass= in the api. web-ui auth uses username=&password=
$username = $this->request->query('user') ? $this->request->query('user') : $this->request->data('user');
$password = $this->request->query('pass') ? $this->request->query('pass') : $this->request->data('pass');
if ( $username and $password ) {
$ret = validateUser($username, $password);
$user = $ret[0];
$retstatus = $ret[1];
if ( !$user ) {
throw new UnauthorizedException(__($retstatus));
return;
}
ZM\Debug("Login successful for user \"$username\"");
}
}
if ( ZM_OPT_USE_LEGACY_API_AUTH ) {
require_once __DIR__ .'/../../../includes/session.php';
$stateful = $this->request->query('stateful') ? $this->request->query('stateful') : $this->request->data('stateful');
if ( $stateful ) {
// zm_session_start() already populates $_SESSION['remoteAddr'] from
// getRemoteAddr() (X-Forwarded-For via a trusted proxy), matching what
// getAuthUser() uses for validation. Don't overwrite it with bare
// REMOTE_ADDR here — that bound the hash to the proxy IP and broke
// validation behind a reverse proxy.
zm_session_start();
if ($user) {
$_SESSION['username'] = $user->Username();
if ( ZM_AUTH_RELAY == 'plain' ) {
// Need to save this in session, can't use the value in User because it is hashed
$_SESSION['password'] = $_REQUEST['password'];
}
generateAuthHash(ZM_AUTH_HASH_IPS);
}
session_write_close();
} else if ( isset($_COOKIE['ZMSESSID']) and !$user ) {
# Have a cookie set, try to load user by session
if ( ! is_session_started() )
zm_session_start();
ZM\Debug(print_r($_SESSION, true));
$user = userFromSession();
session_write_close();
}
}
# NON LEGACY, token based access
$token = $this->request->query('token') ? $this->request->query('token') : $this->request->data('token');
if ( $token ) {
// if you pass a token to login, we should only allow
// refresh tokens to regenerate new access and refresh tokens
if ( !strcasecmp($this->params->action, 'login') ) {
$only_allow_token_type = 'refresh';
} else {
// for any other methods, don't allow refresh tokens
// they are supposed to be infrequently used for security
// purposes
$only_allow_token_type = 'access';
}
$ret = validateToken($token, $only_allow_token_type, true);
$user = $ret[0];
$retstatus = $ret[1];
if ( !$user ) {
throw new UnauthorizedException(__($retstatus));
return;
}
} # end if token
if ( $user and ( $user->APIEnabled() != 1 ) ) {
ZM\Error('API disabled for: '.$user->Username());
throw new UnauthorizedException(__('API disabled for: '.$user->Username()));
$user = null;
}
// We need to reject methods that are not authenticated
// besides login and logout
if ( strcasecmp($this->params->action, 'logout') ) {
if ( !( $user and $user->Username() ) ) {
throw new UnauthorizedException(__('Not Authenticated'));
return;
} else if ( !( $user and $user->Enabled() ) ) {
throw new UnauthorizedException(__('User is not enabled'));
return;
}
} # end if ! login or logout
} # end if ZM_OPT_AUTH
// make sure populated user object has APIs enabled
if (isset($_SERVER['HTTP_ORIGIN'])) {
global $Servers;
if ( sizeof($Servers) < 1 ) {
# Only need CORSHeaders in the event that there are multiple servers in use.
# ICON: Might not be true. multi-port?
if ( ZM_MIN_STREAMING_PORT ) {
ZM\Debug('Setting default Access-Control-Allow-Origin from ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Credentials: true');
$this->response->header('Access-Control-Allow-Headers: x-requested-with,x-request');
}
return;
}
foreach ($Servers as $Server) {
if (
preg_match('/^(https?:\/\/)?'.preg_quote($Server->Hostname(),'/').'/i', $_SERVER['HTTP_ORIGIN'])
or
preg_match('/^(https?:\/\/)?'.preg_quote($Server->Name(),'/').'/i', $_SERVER['HTTP_ORIGIN'])
) {
ZM\Debug('Setting Access-Control-Allow-Origin from '.$_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Credentials: true');
$this->response->header('Access-Control-Allow-Headers: x-requested-with,x-request');
break;
}
}
}
} # end function beforeFilter()
}