Skip to content

Commit 79010a7

Browse files
authored
BcZip: ZIP展開時にエントリパス検証を追加(Zip Slip対策) (#4300)
ZIP内エントリのパスを事前に検証し、 展開先ディレクトリ配下に収まらないパスを拒否する処理を追加。 - Zip Slip脆弱性対策として、展開先パスの検証ロジックを強化 - ユニットテストに親ディレクトリへのファイル流出チェックを追加 - Copilot指摘事項の修正: Zip Slip脆弱性対応の強化とテスト追加 - エラーメッセージを __d() 関数で国際化対応 - 各メソッドに @checked, @notodo, @UnitTest アノテーションを追加 - _isZipEntrySafe メソッドの空文字チェックを調整 Co-authored-by: kaminuma <>
1 parent 128b696 commit 79010a7

File tree

2 files changed

+400
-19
lines changed

2 files changed

+400
-19
lines changed

plugins/baser-core/src/Utility/BcZip.php

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public function extract($source, $target)
7575
$result = $this->_extractByCommand($source, $target);
7676
}
7777
if ($result) {
78-
$extractedPath = $target . $this->topArchiveName;
78+
$extractedPath = rtrim($target, "/\\") . DS . $this->topArchiveName;
7979
$Folder = new BcFolder($extractedPath);
8080
$Folder->chmod( 0777);
8181
if ($this->Zip) $this->Zip->close();
@@ -97,14 +97,25 @@ public function extract($source, $target)
9797
*/
9898
protected function _extractByPhpLib($source, $target)
9999
{
100-
if ($this->Zip->open($source) === true && $this->Zip->extractTo($target)) {
100+
if ($this->Zip->open($source) !== true) {
101+
return false;
102+
}
103+
$targetPath = $this->_normalizeTargetPath($target);
104+
if ($targetPath === false) {
105+
$this->Zip->close();
106+
return false;
107+
}
108+
if (!$this->_validateZipEntries($targetPath)) {
109+
$this->Zip->close();
110+
return false;
111+
}
112+
if ($this->Zip->extractTo($target)) {
101113
$archivePath = $this->Zip->getNameIndex(0);
102114
$archivePathAry = explode('/', $archivePath);
103115
$this->topArchiveName = $archivePathAry[0];
104116
return true;
105-
} else {
106-
return false;
107117
}
118+
return false;
108119
}
109120

110121
/**
@@ -124,7 +135,27 @@ protected function _extractByCommand($source, $target)
124135
return false;
125136
}
126137
$unzipCommand = $return1[0];
127-
$target = preg_replace('/\/$/', '', $target);
138+
$targetPath = $this->_normalizeTargetPath($target);
139+
if ($targetPath === false) {
140+
return false;
141+
}
142+
$listCommand = $unzipCommand . ' -Z -1 ' . $this->_escapePath($source);
143+
exec($listCommand . ' 2>&1', $entries, $listStatus);
144+
if ($listStatus !== 0) {
145+
$this->error = $entries;
146+
return false;
147+
}
148+
foreach ($entries as $entry) {
149+
$entry = trim($entry);
150+
if ($entry === '') {
151+
continue;
152+
}
153+
if (!$this->_isZipEntrySafe($entry, $targetPath)) {
154+
$this->error = __d('baser_core', 'Invalid zip entry path: {0}', $entry);
155+
return false;
156+
}
157+
}
158+
$target = rtrim($target, "/\\");
128159
$command = $unzipCommand . ' -o ' . $this->_escapePath($source) . ' -d ' . $this->_escapePath($target);
129160
exec($command . ' 2>&1', $return2);
130161
if (!empty($return2[2])) {
@@ -158,6 +189,166 @@ protected function _escapePath($path)
158189
return implode(DS, $pathAry);
159190
}
160191

192+
/**
193+
* ZIPエントリがターゲット配下か確認する
194+
*
195+
* @param string $entry
196+
* @param string $targetPath
197+
* @return bool
198+
* @checked
199+
* @noTodo
200+
* @unitTest
201+
*/
202+
protected function _isZipEntrySafe($entry, $targetPath)
203+
{
204+
$entry = str_replace('\\', '/', $entry);
205+
if ($entry === '' || strpos($entry, "\0") !== false) {
206+
return false;
207+
}
208+
if (preg_match('/^[A-Za-z]:\//', $entry) || strpos($entry, '/') === 0) {
209+
return false;
210+
}
211+
$normalizedEntry = $this->_normalizeRelativePath($entry);
212+
// "." や "a/.." 等で正規化後に空になるエントリは展開対象として無意味なため拒否する。
213+
if ($normalizedEntry === null || $normalizedEntry === '') {
214+
return false;
215+
}
216+
$destPath = $this->_normalizeAbsolutePath($targetPath . '/' . $normalizedEntry);
217+
if ($destPath === null || $destPath === '') {
218+
return false;
219+
}
220+
$comparisonDest = $destPath . '/';
221+
$comparisonTarget = $targetPath . '/';
222+
if (DIRECTORY_SEPARATOR === '\\') {
223+
// Windows 互換性のため、大文字小文字を無視して比較する。
224+
$comparisonDest = strtolower($comparisonDest);
225+
$comparisonTarget = strtolower($comparisonTarget);
226+
}
227+
// 展開先がターゲット配下に収まることを保証する。
228+
return (strpos($comparisonDest, $comparisonTarget) === 0);
229+
}
230+
231+
/**
232+
* ZIPエントリ一覧を検証する
233+
*
234+
* @param string $targetPath
235+
* @return bool
236+
* @checked
237+
* @noTodo
238+
* @unitTest
239+
*/
240+
protected function _validateZipEntries($targetPath)
241+
{
242+
for ($i = 0; $i < $this->Zip->numFiles; $i++) {
243+
$entry = $this->Zip->getNameIndex($i);
244+
if ($entry === false || !$this->_isZipEntrySafe($entry, $targetPath)) {
245+
$this->error = __d('baser_core', 'Invalid zip entry path: {0}', $entry);
246+
return false;
247+
}
248+
}
249+
return true;
250+
}
251+
252+
/**
253+
* 展開先ディレクトリの正規化
254+
*
255+
* 展開前検証用に、存在しない場合は親ディレクトリで解決して基準パスを作る。
256+
*
257+
* @param string $target
258+
* @return string|false
259+
* @checked
260+
* @noTodo
261+
* @unitTest
262+
*/
263+
protected function _normalizeTargetPath($target)
264+
{
265+
$trimmedTarget = rtrim($target, "/\\");
266+
if ($trimmedTarget === '') {
267+
$this->error = __d('baser_core', 'Target directory not found.');
268+
return false;
269+
}
270+
$targetPath = realpath($trimmedTarget);
271+
if ($targetPath === false) {
272+
// ディレクトリ作成は行わず、比較用の基準パスのみ構成する。
273+
$parentPath = realpath(dirname($trimmedTarget));
274+
if ($parentPath === false || !is_dir($parentPath)) {
275+
$this->error = __d('baser_core', 'Target directory not found.');
276+
return false;
277+
}
278+
$targetPath = $parentPath . '/' . basename($trimmedTarget);
279+
}
280+
return rtrim(str_replace('\\', '/', $targetPath), '/');
281+
}
282+
283+
/**
284+
* 相対パスを正規化する
285+
*
286+
* @param string $path
287+
* @return string|null
288+
* @checked
289+
* @noTodo
290+
* @unitTest
291+
*/
292+
protected function _normalizeRelativePath($path)
293+
{
294+
$path = str_replace('\\', '/', $path);
295+
$parts = [];
296+
foreach (explode('/', $path) as $part) {
297+
if ($part === '' || $part === '.') {
298+
continue;
299+
}
300+
if ($part === '..') {
301+
if (empty($parts)) {
302+
return null;
303+
}
304+
array_pop($parts);
305+
continue;
306+
}
307+
$parts[] = $part;
308+
}
309+
return implode('/', $parts);
310+
}
311+
312+
/**
313+
* 絶対パスを正規化する
314+
*
315+
* @param string $path
316+
* @return string|null
317+
* @checked
318+
* @noTodo
319+
* @unitTest
320+
*/
321+
protected function _normalizeAbsolutePath($path)
322+
{
323+
// ZIP 内のエントリはまだ実在しないため、realpath() は使えない。
324+
$path = str_replace('\\', '/', $path);
325+
$drive = '';
326+
if (preg_match('/^[A-Za-z]:/', $path)) {
327+
$drive = strtoupper($path[0]) . ':';
328+
$path = substr($path, 2);
329+
}
330+
$parts = [];
331+
foreach (explode('/', $path) as $part) {
332+
if ($part === '' || $part === '.') {
333+
continue;
334+
}
335+
if ($part === '..') {
336+
if (!empty($parts)) {
337+
array_pop($parts);
338+
} else {
339+
return null;
340+
}
341+
continue;
342+
}
343+
$parts[] = $part;
344+
}
345+
$normalized = implode('/', $parts);
346+
if ($drive !== '') {
347+
return $drive . '/' . $normalized;
348+
}
349+
return '/' . $normalized;
350+
}
351+
161352
/**
162353
* zip生成
163354
*

0 commit comments

Comments
 (0)