mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-05 04:02:56 -05:00
Two ways a System=View account could read secrets through the Config API. The oracle: ConfigsController::index() passed the caller's named filter to buildFilter() and returned the matched rows. redactSecret() blanked a secret Value in the response but not in the WHERE clause, so a filter such as Name:ZM_AUTH_HASH_SECRET/Value LIKE:a% returned the row for a correct prefix and nothing otherwise - the row count leaked the secret a character at a time, and that secret signs auth tokens. index() now detects a filter on the Value column and drops the secret rows from the result, so their presence reveals nothing; a filter on any other column is unaffected. The flags: ZM_JANUS_SECRET, ZM_UPLOAD_PASS and ZM_MQTT_PASSWORD held secrets but were not marked Private, so the API returned them to System=View and skin.js.php wrote them into every page as JavaScript constants. Mark them private in ConfigData.pm.in (fresh installs) and add a Config UPDATE to the untagged 1.39.41 migration (existing installs). No web page reads the three; zmfilter, zmc and the Janus integration read them server side. Tested: tests/php/test_config_value_filter.php (11 cases) covers the Value detection, including Model.Value, case, and the DefaultValue non-match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
8.6 KiB
PHP
276 lines
8.6 KiB
PHP
<?php
|
|
App::uses('AppController', 'Controller');
|
|
/**
|
|
* Configs Controller
|
|
*
|
|
* @property Config $Config
|
|
*/
|
|
class ConfigsController extends AppController {
|
|
|
|
/**
|
|
* Components
|
|
*
|
|
* @var array
|
|
*/
|
|
public $components = array('RequestHandler');
|
|
|
|
/**
|
|
* Reading configuration requires the same System permission the web ui's
|
|
* Options page requires. Without it any enabled, API-enabled account could read
|
|
* the effective configuration, which includes secrets (GHSA-m896-3fc6-2jf5).
|
|
* Mutation already required System Edit; this makes reads consistent with it.
|
|
*
|
|
* @throws UnauthorizedException
|
|
* @return void
|
|
*/
|
|
private function requireSystemView() {
|
|
global $user;
|
|
if ($user and ($user->System() == 'None')) {
|
|
throw new UnauthorizedException(__('Insufficient privileges'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Values that must never leave the API, whatever the caller's permission.
|
|
*
|
|
* The Private flag marks the secrets that live in the Config table, such as
|
|
* ZM_AUTH_HASH_SECRET, which is enough to forge an authentication hash for any
|
|
* user. The database credentials are read from zm.conf and conf.d rather than
|
|
* the table, so they have no row and no flag to carry, and are listed by name.
|
|
* Nothing outside the server has any use for either, so they are withheld from
|
|
* administrators too.
|
|
*
|
|
* The Private flag is read from $zm_config, which config.php populates from the
|
|
* table, so callers need not select it and the response keeps its shape.
|
|
*
|
|
* @param array $config a Config array, optionally carrying Name and Private
|
|
* @param string $name the config name, for callers whose row omits it
|
|
* @return array the same array, with any secret Value emptied
|
|
*/
|
|
private function isSecretConfig($config, $name = null) {
|
|
$secret_names = array('ZM_DB_HOST', 'ZM_DB_NAME', 'ZM_DB_USER', 'ZM_DB_PASS', 'ZM_DB_TYPE');
|
|
if ($name === null) $name = isset($config['Name']) ? $config['Name'] : '';
|
|
|
|
global $zm_config;
|
|
$private = !empty($config['Private'])
|
|
|| (isset($zm_config[$name]) && !empty($zm_config[$name]['Private']));
|
|
|
|
return $private or in_array($name, $secret_names, true);
|
|
}
|
|
|
|
private function redactSecret($config, $name = null) {
|
|
if ($this->isSecretConfig($config, $name)) {
|
|
$config['Value'] = '';
|
|
}
|
|
return $config;
|
|
}
|
|
|
|
// Whether the caller's named filter tests the Value column (as Value, or
|
|
// Model.Value, with any operator). buildFilter() parses the field the same
|
|
// way. refs GHSA-8g63-hqg8-82cv
|
|
private function filterReferencesValue($namedParams) {
|
|
foreach (array_keys($namedParams) as $attribute) {
|
|
if (preg_match('/^(?P<field>[a-z0-9.]+)/i', trim($attribute), $m)) {
|
|
$field = $m['field'];
|
|
$bare = (false !== strpos($field, '.'))
|
|
? substr($field, strrpos($field, '.') + 1)
|
|
: $field;
|
|
if (strcasecmp($bare, 'Value') === 0) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* resolves the issue of not returning all config parameters
|
|
* refer https://github.com/ZoneMinder/ZoneMinder/issues/953
|
|
* index method
|
|
*
|
|
* @return void
|
|
*/
|
|
public function index() {
|
|
$this->requireSystemView();
|
|
$this->Config->recursive = -1; // Configs have no associations anyways
|
|
|
|
$conditions = [];
|
|
$filters_value = false;
|
|
if ( $this->request->params['named'] ) {
|
|
$this->FilterComponent = $this->Components->load('Filter');
|
|
$conditions = $this->FilterComponent->buildFilter($this->request->params['named']);
|
|
$filters_value = $this->filterReferencesValue($this->request->params['named']);
|
|
}
|
|
|
|
$configs = $this->Config->find('all', ['conditions' => &$conditions]);
|
|
|
|
// redactSecret() blanks a secret Value in the response, but the caller's
|
|
// WHERE clause is applied first, so a filter on Value (e.g. Value LIKE 'a%')
|
|
// turns the returned row count into an oracle that leaks a secret such as
|
|
// ZM_AUTH_HASH_SECRET one character at a time. When the filter tests Value,
|
|
// drop the secret rows so their presence reveals nothing; a filter on any
|
|
// other column is unaffected. refs GHSA-8g63-hqg8-82cv
|
|
if ($filters_value) {
|
|
$visible = array();
|
|
foreach ($configs as $c) {
|
|
if (!$this->isSecretConfig($c['Config'])) $visible[] = $c;
|
|
}
|
|
$configs = $visible;
|
|
}
|
|
|
|
$config_by_name = [];
|
|
foreach ($configs as $c) {
|
|
$config_by_name[$c['Config']['Name']] = &$c['Config'];
|
|
}
|
|
|
|
global $zm_config;
|
|
foreach ( $zm_config as $k=>$c ) {
|
|
if (isset($config_by_name[$k])) {
|
|
$config_by_name[$k]['Value'] = $c['Value'];
|
|
} else if (!count($conditions)) {
|
|
$configs[] = ['Config'=>$c];
|
|
}
|
|
}
|
|
foreach ($configs as &$config) {
|
|
$config['Config'] = $this->redactSecret($config['Config']);
|
|
}
|
|
unset($config);
|
|
$this->set(array(
|
|
'configs' => $configs,
|
|
'_serialize' => array('configs')
|
|
));
|
|
}
|
|
|
|
/**
|
|
* view method
|
|
*
|
|
* @throws NotFoundException
|
|
* @param string $id
|
|
* @return void
|
|
*/
|
|
public function view($id = null) {
|
|
$this->requireSystemView();
|
|
if (!$this->Config->exists($id)) {
|
|
throw new NotFoundException(__('Invalid config'));
|
|
}
|
|
$options = array('conditions' => array('Config.' . $this->Config->primaryKey => $id));
|
|
$config = $this->Config->find('first', $options);
|
|
|
|
# Value might be overriden in /etc/zm/conf.d
|
|
global $zm_config;
|
|
$config['Config']['Value'] = $zm_config[$config['Config']['Name']]['Value'];
|
|
$config['Config'] = $this->redactSecret($config['Config']);
|
|
$this->set(array(
|
|
'config' => $config,
|
|
'_serialize' => array('config')
|
|
));
|
|
}
|
|
|
|
public function viewByName($name = null) {
|
|
$this->requireSystemView();
|
|
$config = $this->Config->findByName($name, array('fields' => 'Value'));
|
|
|
|
global $zm_config;
|
|
if (!$config) {
|
|
if ( isset($zm_config[$name]) ) {
|
|
$config = array('Config'=>$zm_config[$name]);
|
|
} else {
|
|
# Logging $zm_config here copied the whole effective configuration,
|
|
# secrets included, into the log and anything collecting it.
|
|
ZM\Debug('No such config '.$name);
|
|
throw new NotFoundException(__('Invalid config'));
|
|
}
|
|
} else if ( isset($zm_config[$name]) ) {
|
|
$config['Config']['Value'] = $zm_config[$name]['Value'];
|
|
}
|
|
|
|
$this->set(array(
|
|
'config' => $this->redactSecret($config['Config'], $name),
|
|
'_serialize' => array('config')
|
|
));
|
|
}
|
|
|
|
/**
|
|
* edit method
|
|
*
|
|
* @throws NotFoundException
|
|
* @param string $id
|
|
* @return void
|
|
*/
|
|
public function edit($id = null) {
|
|
global $user;
|
|
$canEdit = (!$user) || ($user->System() == 'Edit');
|
|
if (!$canEdit) {
|
|
throw new UnauthorizedException(__('Insufficient privileges'));
|
|
return;
|
|
}
|
|
|
|
$this->Config->id = $id;
|
|
|
|
if (!$this->Config->exists($id)) {
|
|
throw new NotFoundException(__('Invalid config'));
|
|
}
|
|
if ($this->request->is(array('post', 'put'))) {
|
|
$options = array('conditions' => array('Config.' . $this->Config->primaryKey => $id));
|
|
$config = $this->Config->find('first', $options);
|
|
if (!empty($config['Config']['System'])) {
|
|
throw new ForbiddenException(__('Cannot edit a system Config entry. Must be changed in /etc/zm/zm.conf'));
|
|
}
|
|
if (!empty($config['Config']['Readonly'])) {
|
|
throw new ForbiddenException(__('Cannot edit a readonly Config entry'));
|
|
}
|
|
if ($this->Config->save($this->request->data)) {
|
|
return $this->flash(__('The config has been saved.'), array('action' => 'index'));
|
|
}
|
|
} else {
|
|
$options = array('conditions' => array('Config.' . $this->Config->primaryKey => $id));
|
|
$this->request->data = $this->Config->find('first', $options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* delete method
|
|
*
|
|
* @throws NotFoundException
|
|
* @param string $id
|
|
* @return void
|
|
*/
|
|
public function delete($id = null) {
|
|
global $user;
|
|
$canEdit = (!$user) || ($user->System() == 'Edit');
|
|
if (!$canEdit) {
|
|
throw new UnauthorizedException(__('Insufficient privileges'));
|
|
return;
|
|
}
|
|
|
|
$this->Config->id = $id;
|
|
if (!$this->Config->exists()) {
|
|
throw new NotFoundException(__('Invalid config'));
|
|
}
|
|
$this->request->allowMethod('post', 'delete');
|
|
if ($this->Config->delete()) {
|
|
return $this->flash(__('The config has been deleted.'), array('action' => 'index'));
|
|
} else {
|
|
return $this->flash(__('The config could not be deleted. Please, try again.'), array('action' => 'index'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* categories method
|
|
*
|
|
* return a list of distinct categories
|
|
*/
|
|
|
|
public function categories($category = null) {
|
|
$this->requireSystemView();
|
|
$categories = $this->Config->find('all', array(
|
|
'fields' => array('DISTINCT Config.Category'),
|
|
'conditions' => array('Config.Category !=' => 'hidden'),
|
|
'recursive' => 0
|
|
));
|
|
$this->set(array(
|
|
'categories' => $categories,
|
|
'_serialize' => array('categories')
|
|
));
|
|
}
|
|
}
|
|
|