From 5f9fcabe0656a9d366e7b4d4c70276d5b97abd62 Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Wed, 7 May 2025 20:58:23 +0200 Subject: [PATCH] webui: "non root" user mode. closes https://github.com/opnsense/core/issues/8521 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) --- plist | 1 + src/etc/inc/plugins.inc.d/webgui.inc | 24 ++++++-- src/etc/rc.subr.d/var | 16 ++++-- src/opnsense/mvc/app/config/config.php | 2 +- .../mvc/app/library/OPNsense/Core/Config.php | 1 + .../OPNsense/System/Status/RootLockStatus.php | 57 +++++++++++++++++++ .../lighttpd-api-dispatcher.conf | 4 +- .../service/templates/OPNsense/WebGui/php.ini | 2 +- src/www/crash_reporter.php | 20 +++---- src/www/system_advanced_admin.php | 18 ++++++ 10 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 src/opnsense/mvc/app/library/OPNsense/System/Status/RootLockStatus.php diff --git a/plist b/plist index 5d1773c8a1..531de6908c 100644 --- a/plist +++ b/plist @@ -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 diff --git a/src/etc/inc/plugins.inc.d/webgui.inc b/src/etc/inc/plugins.inc.d/webgui.inc index 612b803533..04f3d81ced 100644 --- a/src/etc/inc/plugins.inc.d/webgui.inc +++ b/src/etc/inc/plugins.inc.d/webgui.inc @@ -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 = << ( "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/" diff --git a/src/etc/rc.subr.d/var b/src/etc/rc.subr.d/var index 9ad6744a24..d3a0874e95 100755 --- a/src/etc/rc.subr.d/var +++ b/src/etc/rc.subr.d/var @@ -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/') diff --git a/src/opnsense/mvc/app/config/config.php b/src/opnsense/mvc/app/config/config.php index afe9287f11..2fa57e5982 100644 --- a/src/opnsense/mvc/app/config/config.php +++ b/src/opnsense/mvc/app/config/config.php @@ -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/', ], diff --git a/src/opnsense/mvc/app/library/OPNsense/Core/Config.php b/src/opnsense/mvc/app/library/OPNsense/Core/Config.php index 8b57cc0132..587f7af590 100644 --- a/src/opnsense/mvc/app/library/OPNsense/Core/Config.php +++ b/src/opnsense/mvc/app/library/OPNsense/Core/Config.php @@ -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 */ } } diff --git a/src/opnsense/mvc/app/library/OPNsense/System/Status/RootLockStatus.php b/src/opnsense/mvc/app/library/OPNsense/System/Status/RootLockStatus.php new file mode 100644 index 0000000000..1cd92b09d9 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/System/Status/RootLockStatus.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf index c3e9b3b839..150ab80f16 100644 --- a/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf +++ b/src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-api-dispatcher.conf @@ -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", diff --git a/src/opnsense/service/templates/OPNsense/WebGui/php.ini b/src/opnsense/service/templates/OPNsense/WebGui/php.ini index 051432cc3a..a8cbcf9dc9 100644 --- a/src/opnsense/service/templates/OPNsense/WebGui/php.ini +++ b/src/opnsense/service/templates/OPNsense/WebGui/php.ini @@ -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}} diff --git a/src/www/crash_reporter.php b/src/www/crash_reporter.php index 3bb705ce8a..c50ba5041b 100644 --- a/src/www/crash_reporter.php +++ b/src/www/crash_reporter.php @@ -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); diff --git a/src/www/system_advanced_admin.php b/src/www/system_advanced_admin.php index 7105001ae9..70c2f1bc99 100644 --- a/src/www/system_advanced_admin.php +++ b/src/www/system_advanced_admin.php @@ -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() { + + + + /> + + + +