Files
zoneminder/web/includes/download_functions.php
Isaac ConnorandClaude Opus 4.8 0fd646ef2e fix: reject path traversal in export download and archive views refs GHSA-fvx4-86xp-9v22
views/download.php and views/archive.php sanitized the user-controlled
`file` and `export_root` request parameters with str_replace('/', '')
only, which strips slashes but leaves `..` intact. export_root=.. made the
resolved path climb out of DIR_EXPORTS_DOWNLOAD into ZM_DIR_EXPORTS (and,
with file=.. as well, higher), and download.php then readfile()s it and,
from its shutdown handler, @unlink()s it. Any user with Events or Snapshots
view permission could read and delete files the web user can reach outside
the downloads directory.

Add safeExportComponent() and pathWithinExportDir() in
download_functions.php. The export file and directory names these views
handle are always single path components (zmExport_<connkey>.zip, a merged
'<Monitor> <start> to <end>.mp4'), so safeExportComponent() rejects any
value with a directory separator, a null byte, or a '.'/'..' traversal and
falls back to the generated default. pathWithinExportDir() resolves the
final path with realpath() and is checked in front of readfile() and
unlink(), so a path that still escapes the export directory - including
through a symlink - is refused.

Tests: tests/php/test_download_path_traversal.php

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-01 22:04:58 -05:00

327 lines
12 KiB
PHP

<?php
//
// ZoneMinder web download function library
// Copyright (C) 2023 ZoneMinder Inc
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
//
function downloadEvents(
$eids,
$export_root,
$exportFormat,
$exportCompressed,
$exportStructure = false
) {
if (!(canView('Events') or canView('Snapshots'))) {
ZM\Error('You do not have permission to view events.');
return false;
} else if (empty($eids)) {
ZM\Error('Attempt to export an empty list of events.');
return false;
}
if (!($exportFormat == 'tar' or $exportFormat == 'zip' or $exportFormat == 'noArchive')) {
ZM\Error("None or invalid exportFormat specified $exportFormat.");
return false;
}
# Ensure that we are going to be able to do this.
if (!(@mkdir(ZM_DIR_EXPORTS) or file_exists(ZM_DIR_EXPORTS))) {
ZM\Fatal('Can\'t create exports dir at \''.ZM_DIR_EXPORTS.'\'');
}
chmod(ZM_DIR_EXPORTS, 0700);
if (!(@mkdir(DIR_EXPORTS_DOWNLOAD) or file_exists(DIR_EXPORTS_DOWNLOAD))) {
ZM\Fatal('Can\'t create exports dir at \''.DIR_EXPORTS_DOWNLOAD.'\'');
}
chmod(DIR_EXPORTS_DOWNLOAD, 0700);
$export_dir = DIR_EXPORTS_DOWNLOAD.'/'.$export_root;
define('DOWNLOAD_DIR_EXPORTS', $export_dir);
# Ensure that we are going to be able to do this.
if (!(@mkdir($export_dir) or file_exists($export_dir))) {
ZM\Error("Can't create exports dir at '$export_dir'");
return false;
}
chmod($export_dir, 0700);
if (!chdir($export_dir)) {
ZM\Error("Can't chdir to $export_dir");
return;
}
if (!is_array($eids)) {
$eids = array($eids);
}
$events_by_monitor_id = [];
foreach ($eids as $eid) {
$event = new ZM\Event($eid);
if (!$event->canView()) {
global $user;
ZM\Warning('User '.($user?$user['Username']:'').' cannot view event '.$event->Id());
continue;
}
if (!isset($events_by_monitor_id[$event->MonitorId()])) $events_by_monitor_id[$event->MonitorId()] = [];
$events_by_monitor_id[$event->MonitorId()][] = $event;
if (!$event->DefaultVideo()) $event->GenerateVideo();
}
$exportFileList = [];
$archive_path = '';
if ($exportFormat == 'tar' || $exportFormat == 'zip') {
$archiveFileName = $export_root.'.'.$exportFormat;
$archive_path = DIR_EXPORTS_DOWNLOAD.'/'.$archiveFileName;
}
foreach (array_keys($events_by_monitor_id) as $mid) {
$monitor = ZM\Monitor::find_one(['Id'=>$mid]);
if (!$monitor) {
ZM\Error("No monitor found for id=$mid");
continue;
}
usort($events_by_monitor_id[$mid], function($a, $b) {
return strtotime($a->StartDateTime) <=> strtotime($b->StartDateTime);
});
$eventFileList = '';
$minTimeSecs = -1;
$minTime = '';
$maxTimeSecs = -1;
$maxTime = '';
foreach ($events_by_monitor_id[$mid] as $event) {
$filePath = findVideoEventFile($event, "mp4");
if ($filePath ==='') {
ZM\Warning('The file path for event '.$event->Id().' was not found.');
continue;
}
if ($minTimeSecs == -1 or $minTimeSecs > $event->StartDateTimeSecs()) {
$minTimeSecs = $event->StartDateTimeSecs();
$minTime = $event->StartDateTime();
}
$endSecs = $event->EndDateTimeSecs();
if ($endSecs and ($maxTimeSecs == -1 or $maxTimeSecs < $endSecs)) {
$maxTimeSecs = $endSecs;
$maxTime = $event->EndDateTime();
}
$fileName = basename($filePath);
if (strpos($fileName, 'incomplete') !== false && !$endSecs) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event.
$eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL;
}
if ($eventFileList === '') {
ZM\Warning('No event files were found for exporting monitor events with ID='.$mid);
continue;
}
if (!$maxTime) $maxTime = date('Y-m-d H:i:s'); # For example, we download a single non-incomlete event, but it's missing EndDateTimeSecs() due to a crash
$mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4';
if (($fp = fopen('event_files.txt', 'w'))) {
fwrite($fp, $eventFileList);
fclose($fp);
} else {
ZM\Error("Can't open event images export file 'event_files.txt'");
}
$cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy '.escapeshellarg($export_dir.'/'.$mergedFileName). ' 2>&1';
exec($cmd, $output, $return);
ZM\Debug($cmd.' return code: '.$return.' output: '.print_r($output,true));
$exportFileList[] = $mergedFileName;
@unlink('event_files.txt');
# We're sending one file at a time to the archive. This will significantly save disk space.
$command = '';
if ($exportFormat == 'tar') {
# We can't just create a tar.gz file and add files to it. We first add everything to the 'tar' file, and then to the 'gz' file.
$command = 'tar --append --dereference';
if ($exportStructure == 'flat') {
$command .= getFlatCommandForTar();
}
$command .= ' --file='.escapeshellarg($archive_path);
} else if ($exportFormat == 'zip') {
$command .= 'zip ';
$command .= ($exportStructure == 'flat' ? ' -j ' : '').escapeshellarg($archive_path);
$command .= $exportCompressed ? ' -9' : ' -0';
} else if ($exportFormat == 'noArchive') {
}
if ($command) {
$command .= ' -- '.escapeshellarg($mergedFileName); # Name of the file to be added
if (executeShelCommand($command, $deleteFile = $mergedFileName) === false) return false;
}
} # end foreach monitor
if (count($exportFileList) === 0) {
ZM\Warning('No events were found for export.');
return false;
}
generateFileList($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList);
chdir(DIR_EXPORTS_DOWNLOAD);
if ($exportFormat == 'tar') {
# Create an archive if necessary
//$exportCompressed = true; // For debugging
if ($exportCompressed) {
$command = 'gzip -- '.escapeshellarg($archive_path); # Name of the file to be archived
if (executeShelCommand($command) === false) return false;
$archiveFileName .= '.gz';
}
} else if ($exportFormat == 'zip') {
} else if ($exportFormat == 'noArchive') {
}
$linkExportFile = [];
if ($exportFormat == 'noArchive') {
$returnString = [];
foreach ($exportFileList as $link) {
# The merged name carries spaces, colons and whatever the monitor is
# called, so it has to be escaped to survive as a query parameter.
$returnString[] = '?view=download&type=mp4&file='.urlencode($link).'&export_root='.urlencode($export_root);
}
} else {
$returnString = '?view=download&type='.$exportFormat.'&file='.urlencode($archiveFileName);
}
return $returnString;
} # end function downloadEvents
function generateFileList ($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList) {
if ($exportFormat != 'tar' && $exportFormat != 'zip') return false;
$export_listFile = 'FileList.txt';
$listFile = $export_dir.'/'.$export_listFile;
if (!($fp = fopen($listFile, 'w'))) {
ZM\Error("Can't open event export list file '$listFile'");
return false;
}
foreach ($exportFileList as $exportFile) {
$exportFile = $export_root.'/'.$exportFile;
fwrite($fp, $exportFile.PHP_EOL);
}
fwrite($fp, $export_listFile.PHP_EOL);
fclose($fp);
# Let's add a text file to the archive
if ($exportFormat == 'tar') {
$command = 'tar --append --dereference';
$command .= getFlatCommandForTar(); # We add one file, which means FLAT
$command .= ' --file='.escapeshellarg($archive_path);
} else if ($exportFormat == 'zip') {
$command = 'zip -j '.escapeshellarg($archive_path);
$command .= $exportCompressed ? ' -9' : ' -0';
}
$command .= ' '.escapeshellarg($export_listFile); # Name of the file to be added
if (executeShelCommand($command, $deleteFile = $export_listFile) === false) return false;
# Let's delete the directory, it should already be empty.
if (!@rmdir($export_dir)) {
ZM\Error("Cannot remove '$export_dir' - directory is not empty");
}
}
function executeShelCommand($command, $deleteFile = '') {
if (!$command) return false;
exec($command, $output, $status);
ZM\Debug("Executing a command: $command");
$deleteFile = preg_replace('@[/\\\]@', '', $deleteFile); # Let's allow deletion only in the current directory, clear the paths.
if ($deleteFile) {
if (!@unlink($deleteFile)) {;
ZM\Error("Cannot delete file '".DOWNLOAD_DIR_EXPORTS."/$deleteFile'");
}
}
if ($status) {
ZM\Error("Command '$command' returned with status $status");
if (isset($output[0])) {
ZM\Error('First line of output is \''.$output[0].'\'');
}
return false;
}
return true;
}
function getFlatCommandForTar() {
$version = @shell_exec('tar --version');
ZM\Debug("Version tar=$version");
if (preg_match('/BSD/i', $version)) {
$command = ' -s \'#^.*/##\'';
} else {
$command = ' --xform=\'s#^.+/##x\'';
}
return $command;
}
# Build the value of a Content-Disposition header offering $filename as an
# attachment.
#
# The plain filename= parameter is an RFC 6266 quoted-string of ASCII
# characters. Merged exports are named '<Monitor> <start> to <end>.mp4', so they
# carry spaces and colons: emitted unquoted, browsers that parse the header
# strictly find no usable filename and fall back to naming the download after
# the last path segment of the URL, which is index.php. Characters that are not
# legal in a Windows filename are folded to '_' so the quoted name is usable
# there too, and whenever that folding changed anything the untouched name is
# offered alongside it as RFC 5987 filename*, which takes precedence in browsers
# that understand it and keeps non-ASCII monitor names intact.
function contentDispositionAttachment($filename) {
$filename = basename($filename);
# The Windows-illegal set - of which '"' and '\' would also terminate or
# escape the quoted-string - plus runs of anything outside printable ASCII.
# Runs, so that one multi-byte character costs one underscore, not one per
# byte.
$ascii = preg_replace('/["*\/:<>?\\\\|]|[^\x20-\x7e]+/', '_', $filename);
$header = 'attachment; filename="'.$ascii.'"';
if ($ascii !== $filename) {
$header .= "; filename*=UTF-8''".rawurlencode($filename);
}
return $header;
}
# Return $value if it is a single, safe path component, otherwise ''. The export
# file and directory names handled by download.php and archive.php are always
# plain names (e.g. zmExport_<connkey>.zip or a merged '<Monitor> <start> to
# <end>.mp4'); a value with a directory separator, a null byte, or a '.'/'..'
# traversal is a crafted request, not one of ours. Stripping only '/' left '..'
# intact, which let export_root/file climb out of the downloads directory
# (GHSA-fvx4-86xp-9v22).
function safeExportComponent($value) {
$value = (string)$value;
if ($value === '' or $value === '.' or $value === '..') return '';
if (preg_match('@[/\\\\]@', $value) or strpos($value, "\0") !== false) return '';
return $value;
}
# Return the canonical path of $path if it exists at or below $base, otherwise
# false. Both are resolved with realpath(), so symlinks cannot be used to reach
# a file outside $base either. A backstop in front of readfile()/unlink() so a
# path that somehow still escapes the export directory is refused.
function pathWithinExportDir($path, $base) {
$realBase = realpath($base);
$realPath = realpath($path);
if ($realBase === false or $realPath === false) return false;
if ($realPath !== $realBase and strpos($realPath, $realBase.DIRECTORY_SEPARATOR) !== 0) return false;
return $realPath;
}