-
Couldn't load subscription status.
- Fork 864
interfaces.php: remove unsafe unserialize from /tmp; add safe read/write and strict permissions #9259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
interfaces.php: remove unsafe unserialize from /tmp; add safe read/write and strict permissions #9259
Conversation
…ite and strict permissions\n\n- Replace unserialize() reads with opnsense_safe_tmp_unserialize()\n- Enforce ownership check (www), 1MiB size cap, allowed_classes=false\n- Atomic writes with LOCK_EX and chmod 0600, then rename()\n- Affects /tmp/regdomain.cache and /tmp/.interfaces.apply\n\nSigned-off-by: Serhat Ömer Rençber <[email protected]>
|
these legacy files are bound to be removed at some point, I don't expect we want to add more glue in the meantime with a risk of regressions (unless there there is an active exploit option using the gui). |
…ite and strict permissions\n\n- Replace unserialize() reads with opnsense_safe_tmp_unserialize()\n- Enforce ownership check (www), 1MiB size cap, allowed_classes=false\n- Atomic writes with LOCK_EX and chmod 0600, then rename()\n- Affects /tmp/regdomain.cache and /tmp/.interfaces.apply\n\nSigned-off-by: Serhat Ömer Rençber <[email protected]>
2a36d96 to
c6074c3
Compare
|
I see the point but also have the suspicion this regresses more than it should by nature of defensive approach. The locking in the static PHP pages is almost nonexistent and MVC conversion is a good way forward for multiple reasons, however, it’s also one of the most critical and overloaded pages that still exist in the project. I wonder what we did for gateway_watcher.php and if that approach is safe and can be adapted to provide a safer way of introducing locking and sanity checking as a general concept? Cheers, |
|
Hi Franco, Thanks! |
|
That sounds like a good direction. Although gateway_watcher is a monolithic process we can do write and move, but use a temporary name instead of a fixed name. This will be more apparent in interfaces code where multiple processes could write the file. |
…y locks\n\n- Replace unserialize() with allowed_classes=false inline and array guard\n- Switch writes to unique temp name + rename() (no LOCK_EX/0600)\n- Keep behavior otherwise unchanged; aligns with gateway_watcher style\n\nSigned-off-by: Serhat Ömer Rençber <[email protected]>
|
Hi Franco, Ad, If further trimming is preferred, I can adjust accordingly. Thanks! |
|
Thanks! one more remark/change request is about tempnam() function use which is more or less the idiom we’re using in such cases, e.g. Line 241 in f4f67d0
|
…nam('/tmp', 'regdomain.cache.') and tempnam('/tmp','interfaces.apply.')\n- Keep unique temp file + rename() pattern; no other behavior changes\n\nSigned-off-by: Serhat Ömer Rençber <[email protected]>
|
Switched to tempnam('/tmp', ...) for temporary files as suggested; still using write-then-rename with unique names. No other behavior changes. best regards. |
|
Ok, we will discuss on Monday how to proceed, but for me this looks reasonable (minus easily fixable code style issues you don’t need tu worry about). Cheers, |
… file ops into separate lines with explicit vars\n- Avoid @ suppression where guarded by checks\n- No functional changes intended\n\nSigned-off-by: Serhat Ömer Rençber <[email protected]>
|
Applied tempnam()+rename() idiom, inlined allowed_classes=false, and cleaned up temp file writes into explicit steps. No functional changes intended. Thanks! |
|
Hi @fichtner , given this page’s legacy footprint and concurrency concerns, a robust solution should be an MVC migration. I’d like to propose a focused discussion on: |
|
Scoping this is complicated for historic reasons and wide ranging impact of „interface“ modeling. I think the prerequisites to this are PPP and wireless conversion to MVC/API. Not sure if all the others are done now? The regdomain issue is likely only related to ancient wireless code for example. In such singular instances and also because we aim for privilege separation (which is currently optional) all such instances of writing management data should be abstracted through configd backend actions. This is regardless of MVC conversion which would require this too. |
|
Thanks @fichtner — makes sense. Phase 0 —
Phase 1 —
Phase 2 —
Phase 3 —
Best Regards. |
|
In practice migration is the easiest when there's less code to skim through, for which reason I'm still not a huge fan of adding a lot of failsafe code when there's no clear problem to be fixed. It would make sense to try to move calls to configd if we could make then sustainable for future steps, but as this item is not on our roadmap at the moment (https://opnsense.org/roadmap/), I still believe we have to choose our battles wisely. Time spend on legacy pages can not be spend elsewhere. Very minor additions (like adding |
|
@SORencber I wrote 215b723 for the occasion. This helps to simplify the code here. I don't see much value in checking bytes written by file_put_contents() as it should land an error in the log when unmuted (no |
|
|
||
| if (file_exists('/tmp/regdomain.cache')) { | ||
| $parsed_xml = unserialize(file_get_contents('/tmp/regdomain.cache')); | ||
| $parsed_xml = @unserialize(@file_get_contents('/tmp/regdomain.cache'), ['allowed_classes' => false]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wouldn't mute file_get_contents() as we know the file exists in 99.9% if the cases since we checked for it :)
for unserialize() I'm more or less leaning the same way as it has never caused harm before that I could think of
| $parsed_xml = @unserialize(@file_get_contents('/tmp/regdomain.cache'), ['allowed_classes' => false]); | ||
| if (!is_array($parsed_xml)) { | ||
| $parsed_xml = array(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| $parsed_xml = @unserialize(@file_get_contents('/tmp/regdomain.cache'), ['allowed_classes' => false]); | |
| if (!is_array($parsed_xml)) { | |
| $parsed_xml = array(); | |
| } | |
| $parsed_xml = unserialize(file_get_contents('/tmp/regdomain.cache'), ['allowed_classes' => false]) ?? []; |
if it's not an array beforehand it should still error later IMO
| $tmpFile = tempnam('/tmp', 'regdomain.cache.'); | ||
| if ($tmpFile !== false) { | ||
| $bytesWritten = file_put_contents($tmpFile, serialize($parsed_xml)); | ||
| if ($bytesWritten !== false) { | ||
| rename($tmpFile, '/tmp/regdomain.cache'); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| $tmpFile = tempnam('/tmp', 'regdomain.cache.'); | |
| if ($tmpFile !== false) { | |
| $bytesWritten = file_put_contents($tmpFile, serialize($parsed_xml)); | |
| if ($bytesWritten !== false) { | |
| rename($tmpFile, '/tmp/regdomain.cache'); | |
| } | |
| } | |
| file_safe('/tmp/regdomain.cache', serialize($parsed_xml)); |
etc
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
util.inc: add optional hardening to file_safe() while keeping defaults
- Add opt-in options: durable (fsync), base_dir, reject_symlink,
require_regular, force_path/force_data, source_base_dir, max_bytes,
owner/group, tmp_in_target/tmp_dir - Default behaviour unchanged; existing call sites unaffected
- Improves safety for callers that need path hardening and durable writes
Refs: 215b723 (introduce file_safe helper), discussion in PR #9259
function file_safe($file, $text_or_source = '', $mode = 0644, array $options = [])
{
/*
* Safe file write helper with optional hardening.
*
* Default behaviour matches the original minimal helper:
* - create temp file
* - if $text_or_source is an existing file path, copy it; otherwise write contents
* - chmod() and atomic rename() to $file
*
* Options (all optional, default off):
* - durable (bool): use fflush()+fsync() for stronger persistence guarantees
* - base_dir (string): enforce that target directory resolves under this base
* - reject_symlink (bool): reject when $file is a symbolic link
* - require_regular (bool): when $file exists, require it to be a regular file
* - force_path (bool): treat $text_or_source strictly as a path to copy
* - force_data (bool): treat $text_or_source strictly as data to write
* - source_base_dir (string): if copying from a path, enforce it resides under this base
* - max_bytes (int): if copying from a path, reject when source size exceeds this limit
* - owner (int|string): chown() on the temp file before rename
* - group (int|string): chgrp() on the temp file before rename
* - tmp_in_target (bool): create temp file in target directory when possible
* - tmp_dir (string): fallback temp directory (default '/tmp')
*/
$durable = !empty($options['durable']);
$baseDir = isset($options['base_dir']) ? $options['base_dir'] : null;
$rejectSymlink = !empty($options['reject_symlink']);
$requireRegular = !empty($options['require_regular']);
$forcePath = !empty($options['force_path']);
$forceData = !empty($options['force_data']);
$sourceBaseDir = isset($options['source_base_dir']) ? $options['source_base_dir'] : null;
$maxBytes = isset($options['max_bytes']) ? (int)$options['max_bytes'] : null;
$owner = array_key_exists('owner', $options) ? $options['owner'] : null;
$group = array_key_exists('group', $options) ? $options['group'] : null;
$tmpInTarget = !empty($options['tmp_in_target']);
$tmpDir = isset($options['tmp_dir']) ? $options['tmp_dir'] : '/tmp';
/* basic path validation */
if (!is_string($file) || $file === '' || $file[0] !== DIRECTORY_SEPARATOR) {
return false;
}
$targetDir = dirname($file);
$targetBaseName = basename($file);
$realTargetDir = realpath($targetDir);
if ($realTargetDir === false) {
return false;
}
if ($baseDir !== null) {
$realBaseDir = realpath($baseDir);
if ($realBaseDir === false) {
return false;
}
if (strncmp($realTargetDir . DIRECTORY_SEPARATOR, $realBaseDir . DIRECTORY_SEPARATOR, strlen($realBaseDir) + 1) !== 0) {
return false;
}
}
if ($rejectSymlink && is_link($file)) {
return false;
}
if ($requireRegular && file_exists($file)) {
$st = @lstat($file);
if (!is_array($st)) {
return false;
}
/* ensure target is a regular file if it exists */
if (($st['mode'] & 0170000) !== 0100000) {
return false;
}
}
/* choose temp directory */
$tempDir = $tmpInTarget ? $realTargetDir : $tmpDir;
if (!is_dir($tempDir) || !is_writable($tempDir)) {
/* fallback to target directory if specified temp dir is not usable */
$tempDir = $realTargetDir;
}
$temp = @tempnam($tempDir, str_replace('/', '_', $file) . '-');
if ($temp === false) {
return false;
}
/* write data or copy from source path */
$treatAsPath = ($forcePath || (!$forceData && is_string($text_or_source) && file_exists($text_or_source)));
if ($treatAsPath) {
$sourcePath = $text_or_source;
if ($sourceBaseDir !== null) {
$realSourceBase = realpath($sourceBaseDir);
$realSource = realpath($sourcePath);
if ($realSourceBase === false || $realSource === false ||
strncmp(dirname($realSource) . DIRECTORY_SEPARATOR, $realSourceBase . DIRECTORY_SEPARATOR, strlen($realSourceBase) + 1) !== 0) {
@unlink($temp);
return false;
}
}
if ($maxBytes !== null) {
$size = @filesize($sourcePath);
if ($size !== false && $size > $maxBytes) {
@unlink($temp);
return false;
}
}
if (!@copy($sourcePath, $temp)) {
@unlink($temp);
return false;
}
} elseif (is_string($text_or_source) && strlen($text_or_source)) {
$fp = @fopen($temp, 'wb');
if ($fp === false) {
@unlink($temp);
return false;
}
if (@fwrite($fp, $text_or_source) === false) {
fclose($fp);
@unlink($temp);
return false;
}
if (!@fflush($fp)) {
fclose($fp);
@unlink($temp);
return false;
}
if ($durable && function_exists('fsync') && !@fsync($fp)) {
fclose($fp);
@unlink($temp);
return false;
}
fclose($fp);
} else {
/* empty content: truncate to zero; tempnam already created the file */
if ($durable && function_exists('fsync')) {
$fp = @fopen($temp, 'ab');
if ($fp !== false) {
@fflush($fp);
@fsync($fp);
fclose($fp);
}
}
}
/* apply permissions/ownership before rename */
@chmod($temp, $mode);
if ($owner !== null) {
@chown($temp, $owner);
}
if ($group !== null) {
@chgrp($temp, $group);
}
if (!@rename($temp, $file)) {
@unlink($temp);
return false;
}
if ($durable && function_exists('fsync')) {
$fp2 = @fopen($file, 'rb');
if ($fp2 !== false) {
@fsync($fp2);
fclose($fp2);
}
}
return true;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Errr, we can talk about improving file_safe() but I don't want to bloat it as it becomes less and less interesting for quick and easy file creation. From experience most bad things never happen or they happen in spades but then the system has other issues.
As for rolling back changes and file creations it doesn't really matter in the grand scheme of things and it also makes debugging harder. Ideally a lot of logging would help, but further bloat the code. Fsync can also impact performance on larger files and I don't know of many instances where it was very strictly necessary (one exception is the update procedure where worst case hundreds of thousands of files are created which may make one unavailable for a split second causing an error here or there.
Let's keep the scope of the PR as it was and do a separate PR for file_safe()?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback. I agree to keep this PR focused. I’ll revert/remove any file_safe() changes from this PR and push an update. I’ll open a separate issue/PR to discuss file_safe() improvements (e.g., optional fsync for critical paths, clearer error surfacing) with some measurements, keeping overhead minimal. If you have specific constraints or a preferred direction, happy to follow.
Summary
Key changes
Updated reads:
Updated writes:
Why this is needed
Security impact
Signed-off-by: Serhat Ömer Rençber [email protected]