Files
zoneminder/tests/php/test_download_content_disposition.php
Isaac ConnorandClaude Opus 5 dc8e22d42a fix: quote the download filename so Chrome doesn't save the export as index.php
The merged mp4 export is named '<Monitor> <start> to <end>.mp4', so it contains
spaces and colons, and download.php emitted it as a bare unquoted filename=
parameter. That is not a valid RFC 6266 token, so browsers that parse
Content-Disposition strictly find no filename and fall back to naming the
download after the last path segment of the URL - index.php. Firefox is lenient
and accepted it, which is why the report was Chrome-on-Windows only.

Add contentDispositionAttachment(), which emits a quoted ASCII filename with the
Windows-illegal characters folded to '_', plus the untouched name as RFC 5987
filename* whenever that folding changed anything, so unicode monitor names still
arrive intact.

Also in that path:
- urlencode the file and export_root query parameters; a monitor name containing
  '&' or '+' would otherwise split or mis-decode the download URL. Read them back
  in export.js with URLSearchParams so the link text shows the decoded name.
- drop the stray ';' from Content-Length, which made the value unparseable.
- silence the shutdown unlink()s, whose warnings would be appended to the body
  of a download that had already started.
- log $this->filenamePath, not an undefined local, on the unreadable-file path.

Tests: tests/php/test_download_content_disposition.php, 12 assertions, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL
2026-09-03 20:17:16 -04:00

95 lines
4.0 KiB
PHP

<?php
// Tests the Content-Disposition value download.php sends for a generated
// export, and the download URL downloadEvents() hands back for it.
//
// Merged exports are named '<Monitor> <start> to <end>.mp4', which carries
// spaces and colons. Emitted as a bare unquoted filename= parameter, browsers
// that parse Content-Disposition strictly (Chrome) find no usable filename and
// name the download after the last path segment of the URL instead - index.php.
//
// download_functions.php declares functions only, so this runs from a source
// checkout: php tests/php/test_download_content_disposition.php
require_once __DIR__.'/../../web/includes/download_functions.php';
$failures = 0;
$passes = 0;
function check($name, $got, $expected) {
global $failures, $passes;
if ($got === $expected) {
$passes++;
echo "ok - $name\n";
} else {
$failures++;
echo "not ok - $name (got ".var_export($got, true)." expected ".var_export($expected, true).")\n";
}
}
// ---- contentDispositionAttachment() ----
// The regression: a merged export name reached the browser unquoted.
$merged = 'Front Door 2026-09-03 10:11:12 to 2026-09-03 10:15:00.mp4';
check('merged export name is quoted',
contentDispositionAttachment($merged),
'attachment; filename="Front Door 2026-09-03 10_11_12 to 2026-09-03 10_15_00.mp4"'
."; filename*=UTF-8''".rawurlencode($merged));
// A name needing nothing carries no filename*: it is already unambiguous.
check('plain name needs no filename*',
contentDispositionAttachment('zmDownload-12345.zip'),
'attachment; filename="zmDownload-12345.zip"');
// Spaces alone are legal inside the quoted-string, so they survive as-is.
check('spaces are kept inside the quotes',
contentDispositionAttachment('Front Door.mp4'),
'attachment; filename="Front Door.mp4"');
// A quote or backslash would end or escape the quoted-string early, handing the
// browser a truncated name and a stray header parameter.
check('quote cannot terminate the quoted-string',
contentDispositionAttachment('od"d.mp4'),
'attachment; filename="od_d.mp4"; filename*=UTF-8\'\'od%22d.mp4');
check('backslash cannot escape out of the quoted-string',
contentDispositionAttachment('od\\d.mp4'),
'attachment; filename="od_d.mp4"; filename*=UTF-8\'\'od%5Cd.mp4');
// The rest of the characters Windows rejects in a filename.
check('windows-illegal characters are replaced',
contentDispositionAttachment('a*b<c>d?e|f.mp4'),
'attachment; filename="a_b_c_d_e_f.mp4"; filename*=UTF-8\'\'a%2Ab%3Cc%3Ed%3Fe%7Cf.mp4');
// A CR or LF in the value would split the header entirely.
check('control characters are replaced',
contentDispositionAttachment("a\r\nb.mp4"),
'attachment; filename="a_b.mp4"; filename*=UTF-8\'\'a%0D%0Ab.mp4');
// A unicode monitor name survives in filename*, where it is encoded, and is
// only degraded in the ASCII-only fallback.
$unicode = 'Zoo Küche.mp4';
check('non-ascii name is preserved in filename*',
contentDispositionAttachment($unicode),
'attachment; filename="Zoo K_che.mp4"'."; filename*=UTF-8''".rawurlencode($unicode));
// The header names the file, never a path.
check('a path is reduced to its basename',
contentDispositionAttachment('/etc/passwd'),
'attachment; filename="passwd"');
// ---- the download URL ----
// The same name has to survive as a query parameter. Round-tripping it the way
// the browser will is what proves it: '&' or '+' unescaped in the monitor name
// would otherwise split the parameter or decode as a space.
$link = '?view=download&type=mp4&file='.urlencode($merged).'&export_root='.urlencode('zmDownload-1');
parse_str(substr($link, 1), $params);
check('the file parameter round-trips', $params['file'], $merged);
check('the export_root parameter round-trips', $params['export_root'], 'zmDownload-1');
$awkward = 'Front & Back +1 2026-09-03 10:11:12 to 2026-09-03 10:15:00.mp4';
parse_str('file='.urlencode($awkward), $params);
check('ampersands and pluses in a monitor name round-trip', $params['file'], $awkward);
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);