Allow the webui to run as wwwonly and move related (temp) files to their own directories so we limit the choice of mangling rights.

When trying to transition back from wwwonly to root, require a file (/var/run/www_non_root) to be removed via the console as an extra barrier.

When captive portal is used, the api dispatcher is forced to use wwwonly in all situations as the number of endpoints used is small and easy to validate (no legacy impact)
This commit is contained in:
Ad Schellevis 2025-05-07 20:58:23 +02:00
parent 196943e302
commit 5f9fcabe06
10 changed files with 122 additions and 23 deletions

1
plist
View file

@ -563,6 +563,7 @@
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/LiveMediaStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/MonitOverrideStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/OpensshOverrideStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/RootLockStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/SystemBootingStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/Status/UnboundOverrideStatus.php
/usr/local/opnsense/mvc/app/library/OPNsense/System/SystemStatus.php

View file

@ -101,6 +101,7 @@ function webgui_configure_do($verbose = false, $interface_map = null)
}
chdir('/usr/local/www');
chown('/var/run/booting', 'wwwonly'); /* booting flag should be owned by wwwonly user*/
/* defaults */
$portarg = '80';
@ -148,8 +149,14 @@ function webgui_configure_do($verbose = false, $interface_map = null)
/* regenerate the php.ini files in case the setup has changed */
configd_run('template reload OPNsense/WebGui');
/* arrange configuration ownership, migh need to arrange this elsewhere eventually */
$todo = array_merge(glob('/conf/backup/*.xml'), ['/conf', '/conf/config.xml', '/conf/backup']);
foreach ($todo as $item) {
chown($item, 'wwwonly');
}
/* flush Phalcon volt templates */
foreach (glob('/usr/local/opnsense/mvc/app/cache/*.php') as $filename) {
foreach (glob('/var/lib/php/cache/*.php') as $filename) {
unlink($filename);
}
@ -251,8 +258,12 @@ EOD;
if (!empty($config['system']['webgui']['httpaccesslog'])) {
$lighty_use_syslog .= 'accesslog.use-syslog="enable"' . "\n";
}
$fast_cgi_path = "/tmp/php-fastcgi.socket";
$change_user = '';
if (!empty($config['system']['webgui']['noroot']) || file_exists('/var/run/www_non_root')) {
$change_user .= "server.username = \"wwwonly\"\n";
$change_user .= "server.groupname = \"www\"\n";
touch('/var/run/www_non_root'); /* create file as root, lock going back to root */
}
$fastcgi_config = <<<EOD
#### fastcgi module
@ -260,11 +271,12 @@ EOD;
fastcgi.server = ( ".php" =>
( "localhost" =>
(
"socket" => "{$fast_cgi_path}",
"socket" => "/var/lib/php/tmp/php-fastcgi.socket",
"max-procs" => 8,
"bin-environment" => (
"PHP_FCGI_CHILDREN" => "5",
"PHP_FCGI_MAX_REQUESTS" => "100"
"PHP_FCGI_MAX_REQUESTS" => "100",
"TMPDIR" => "/var/lib/php/tmp"
),
"bin-path" => "/usr/local/bin/php-cgi"
)
@ -295,6 +307,8 @@ include "{$confdir}/conf.d/*.conf"
server.max-keep-alive-requests = 15
server.max-keep-alive-idle = 30
{$change_user}
## a static document-root, for virtual-hosting take look at the
## server.virtual-* options
server.document-root = "/usr/local/www/"

View file

@ -64,11 +64,17 @@ for RUNDIR in /var/run /var/dhcpd/var/run /var/unbound/var/run; do
fi
done
# setup output directory for php sessions
mkdir -p /var/lib/php/sessions
chown root:wheel /var/lib/php/sessions
chmod 750 /var/lib/php/sessions
rm -f /var/lib/php/sessions/sess_*
# setup output directory for various php ui components
for PHPDIR in /var/lib/php/sessions /var/lib/php/tmp /var/lib/php/cache; do
mkdir -p ${PHPDIR}
chown wwwonly:wheel ${PHPDIR}
chmod 750 ${PHPDIR}
rm -f ${PHPDIR}/*
done
# tmp needs sticky
chmod +t /var/lib/php/tmp
if [ ${USE_MFS_VAR} -ne 0 ]; then
MAX_MFS_VAR=$(grep 'max_mfs_var' /conf/config.xml | sed 's/[^>]*>\([^<]*\)<.*/\1/')

View file

@ -35,7 +35,7 @@ return new OPNsense\Core\AppConfig([
'viewsDir' => __DIR__ . '/../../app/views/',
'pluginsDir' => __DIR__ . '/../../app/plugins/',
'libraryDir' => __DIR__ . '/../../app/library/',
'cacheDir' => __DIR__ . '/../../app/cache/',
'cacheDir' => '/var/lib/php/cache/',
'contribDir' => __DIR__ . '/../../../contrib/',
'baseUri' => '/opnsense_gui/',
],

View file

@ -348,6 +348,7 @@ class Config extends Singleton
// in case there are no backups, restore defaults.
$logger->error(gettext('No valid config.xml found, attempting to restore factory config.'));
$this->restoreBackup('/usr/local/etc/config.xml');
chown('/conf/config.xml', 'wwwonly'); /* frontend owns file */
}
}

View file

@ -0,0 +1,57 @@
<?php
/*
* Copyright (C) 2025 Deciso B.V.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
use OPNsense\Core\Config;
class RootLockStatus extends AbstractStatus
{
public function __construct()
{
$this->internalPriority = 2;
$this->internalPersistent = true;
$this->internalIsBanner = true;
$this->internalTitle = gettext('Root lock');
}
public function collectStatus()
{
$file = '/var/run/www_non_root';
if (!(file_exists($file) && empty(Config::getInstance()->object()->system->webgui->noroot))) {
return;
}
$this->internalStatus = SystemStatusCode::ERROR;
$this->internalMessage = sprintf(gettext(
'Strict security mode disabled, but requires "%s" to be removed manually from disk. ' .
'Refusing to run as root in the meantime.'
), $file);
}
}

View file

@ -8,6 +8,8 @@ server.modules = ( "mod_access", "mod_expire", "mod_deflate", "mod_
"mod_cgi", "mod_fastcgi","mod_alias", "mod_rewrite"
)
server.username = "wwwonly"
server.groupname = "www"
server.max-keep-alive-requests = 15
server.max-keep-alive-idle = 30
@ -55,7 +57,7 @@ server.max-request-size = 2097152
fastcgi.server = ( ".php" =>
( "localhost" =>
(
"socket" => "/tmp/php-fastcgi-cp.socket",
"socket" => "/var/lib/php/tmp/php-fastcgi-cp.socket",
"max-procs" => 4,
"bin-environment" => (
"PHP_FCGI_CHILDREN" => "2",

View file

@ -31,7 +31,7 @@ error_reporting = E_ALL
display_errors=on
display_startup_errors=off
log_errors=on
error_log=/tmp/PHP_errors.log
error_log=/var/lib/php/tmp/PHP_errors.log
date.timezone="{{system.timezone|default('Etc/UTC')}}"
session.save_path=/var/lib/php/sessions
session.gc_maxlifetime={{system.webgui.session_timeout|default(240)|int * 60}}

View file

@ -33,10 +33,10 @@ require_once 'guiconfig.inc';
function has_crash_report()
{
$skip_files = ['.', '..', 'minfree', 'bounds', ''];
$PHP_errors_log = '/tmp/PHP_errors.log';
$PHP_errors_log = '/var/lib/php/tmp/PHP_errors.log';
$count = 0;
if (file_exists($PHP_errors_log) && !is_link('/tmp/PHP_errors.log')) {
if (file_exists($PHP_errors_log) && !is_link($PHP_errors_log)) {
if (intval(shell_safe('/bin/cat %s | /usr/bin/wc -l | /usr/bin/awk \'{ print $1 }\'', $PHP_errors_log))) {
$count++;
}
@ -150,10 +150,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
file_put_contents('/var/crash/crashreport_header.txt', $crash_report_header);
if (file_exists('/tmp/PHP_errors.log')) {
if (file_exists('/var/lib/php/tmp/PHP_errors.log')) {
// limit PHP_errors to send to 1MB
exec('/usr/bin/tail -c 1048576 /tmp/PHP_errors.log > /var/crash/PHP_errors.log');
@unlink('/tmp/PHP_errors.log');
exec('/usr/bin/tail -c 1048576 /var/lib/php/tmp/PHP_errors.log > /var/crash/PHP_errors.log');
@unlink('/var/lib/php/tmp/PHP_errors.log');
}
@copy('/var/run/dmesg.boot', '/var/crash/dmesg.boot');
exec('/usr/bin/gzip /var/crash/*');
@ -171,7 +171,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
foreach ($files_to_upload as $file_to_upload) {
@unlink($file_to_upload);
}
@unlink('/tmp/PHP_errors.log');
@unlink('/var/lib/php/tmp/PHP_errors.log');
} elseif ($pconfig['Submit'] == 'new') {
/* force a crash report generation */
$has_crashed = true;
@ -184,21 +184,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($has_crashed) {
$crash_files = glob("/var/crash/*");
$crash_reports['System Information'] = trim($crash_report_header);
if (file_exists('/tmp/PHP_errors.log') && !is_link('/tmp/PHP_errors.log')) {
$php_errors_size = @filesize('/tmp/PHP_errors.log');
if (file_exists('/var/lib/php/tmp/PHP_errors.log') && !is_link('/var/lib/php/tmp/PHP_errors.log')) {
$php_errors_size = @filesize('/var/lib/php/tmp/PHP_errors.log');
$max_php_errors_size = 1 * 1024 * 1024;
// limit reporting for PHP_errors.log to $max_php_errors_size characters
if ($php_errors_size > $max_php_errors_size) {
// if file is to large, only display last $max_php_errors_size characters
$php_errors .= @file_get_contents(
'/tmp/PHP_errors.log',
'/var/lib/php/tmp/PHP_errors.log',
NULL,
NULL,
($php_errors_size - $max_php_errors_size),
$max_php_errors_size
);
} else {
$php_errors = @file_get_contents('/tmp/PHP_errors.log');
$php_errors = @file_get_contents('/var/lib/php/tmp/PHP_errors.log');
}
if (!empty($php_errors)) {
$crash_reports['PHP Errors'] = trim($php_errors);

View file

@ -68,6 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$pconfig['user_allow_gen_token'] = isset($config['system']['user_allow_gen_token']) ? explode(",", $config['system']['user_allow_gen_token']) : [];
$pconfig['nodnsrebindcheck'] = isset($config['system']['webgui']['nodnsrebindcheck']);
$pconfig['nohttpreferercheck'] = isset($config['system']['webgui']['nohttpreferercheck']);
$pconfig['noroot'] = isset($config['system']['webgui']['noroot']);
$pconfig['althostnames'] = $config['system']['webgui']['althostnames'] ?? null;
$pconfig['serialspeed'] = $config['system']['serialspeed'];
$pconfig['serialusb'] = !empty($config['system']['serialusb']);
@ -171,6 +172,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$config['system']['webgui']['compression'] != $pconfig['compression'] ||
$config['system']['webgui']['ssl-ciphers'] != $newciphers ||
$config['system']['webgui']['interfaces'] != $newinterfaces ||
empty($config['system']['webgui']['noroot']) != empty($pconfig['noroot']) ||
empty($pconfig['httpaccesslog']) != empty($config['system']['webgui']['httpaccesslog']) ||
empty($pconfig['ssl-hsts']) != empty($config['system']['webgui']['ssl-hsts']) ||
!empty($pconfig['disablehttpredirect']) != !empty($config['system']['webgui']['disablehttpredirect']) ||
@ -280,6 +282,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
unset($config['system']['webgui']['nohttpreferercheck']);
}
if (!empty($pconfig['noroot'])) {
$config['system']['webgui']['noroot'] = true;
} elseif (isset($config['system']['webgui']['noroot'])) {
unset($config['system']['webgui']['noroot']);
}
if (!empty($pconfig['althostnames'])) {
$config['system']['webgui']['althostnames'] = $pconfig['althostnames'];
} elseif (isset($config['system']['webgui']['althostnames'])) {
@ -1079,6 +1087,16 @@ $(document).ready(function() {
</div>
</td>
</tr>
<tr>
<td><a id="help_for_noroot" href="#" class="showhelp"><i class="fa fa-info-circle"></i></a> <?=gettext("Strict security"); ?></td>
<td>
<input name="noroot" type="checkbox" value="yes" <?= empty($pconfig['noroot']) ? '' : 'checked="checked"' ?> />
<?=gettext("(Experimental)"); ?>
<div class="hidden" data-for="help_for_noroot">
<?=gettext("Stricten security by running the webserver as non root user, not all components may be compatible with this feature.") ?>
</div>
</td>
</tr>
</table>
</div>
<div class="content-box tab-content table-responsive">