From eda5263d8b2bf7f4d6cbd6faa2f0e2001abaa812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 5 Sep 2026 12:20:26 +0300 Subject: [PATCH] Reject unsafe ZIP paths --- HISTORY/2026-09-05-file-picker.md | 18 ++++++++++++++++++ site/app.py | 2 +- upload/frontend/zip/list_zip_files.js | 22 ++++++++++++++++------ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/HISTORY/2026-09-05-file-picker.md b/HISTORY/2026-09-05-file-picker.md index 4d8da19..41d8e53 100644 --- a/HISTORY/2026-09-05-file-picker.md +++ b/HISTORY/2026-09-05-file-picker.md @@ -128,6 +128,24 @@ ## Git +## Исправление traversal — 2026-09-05 + +### Причина + +`list_zip_files.js` использовал сырой `entryName` из ZIP: он напрямую попадал в `path` и разбивался через `split('/')`. Поэтому `..`, ведущий `/`, пустые сегменты и Windows backslash создавали видимые небезопасные группы и пути. + +### Исправление + +- Добавлена проверка сегментов ZIP entry до построения дерева. +- Отклоняются пустые entry, абсолютные пути, backslash, `.`, `..` и пустые сегменты. +- Пути и имена leaf-файлов строятся только из проверенных сегментов. +- Вложенный ZIP с опасным путём пропускается без прерывания обработки внешнего ZIP. +- Версия повышена до `0.1.8`. + +### Повторная проверка + +ZIP с `folder/../../escape.txt`, `/absolute.md`, `windows\\..\\bad.txt`, `folder/./dot.txt` и безопасным `safe/normal.txt` оставил только `paths.zip/safe/normal.txt`. Группы `..`, пустая группа и абсолютный путь больше не отображаются. Результат: `1 файлов · 2 B`. + - `c0b8788 Implement reusable upload platform` — первоначальный reusable upload platform. - `85605fe Remove VM upload layer; keep file picker only` — удаление VM upload и переход к picker-only. - `a965f18 Add hierarchical file picker` — иерархическое дерево, вложенные ZIP, удаление групп и файлов. diff --git a/site/app.py b/site/app.py index 96b6268..79dbb5e 100644 --- a/site/app.py +++ b/site/app.py @@ -9,7 +9,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file: CONFIG = json.load(config_file) -VERSION = "0.1.7" +VERSION = "0.1.8" app = Flask(__name__, template_folder="templates", static_folder="static") diff --git a/upload/frontend/zip/list_zip_files.js b/upload/frontend/zip/list_zip_files.js index 513266e..48f27af 100644 --- a/upload/frontend/zip/list_zip_files.js +++ b/upload/frontend/zip/list_zip_files.js @@ -9,6 +9,13 @@ function makeFile(data, name) { return new File([data], name); } +function safeEntryParts(entryName) { + if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null; + const parts = entryName.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..')) return null; + return parts; +} + function node(kind, name, path, children = [], file = null) { return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true }; } @@ -42,17 +49,20 @@ async function listEntries(data, zipName, allowedExt, depth) { for (const [entryName, entryData] of Object.entries(entries)) { if (entryName.endsWith('/')) continue; - if (entryName.toLowerCase().endsWith('.zip')) { + const parts = safeEntryParts(entryName); + if (!parts) continue; + const normalizedName = parts.join('/'); + if (normalizedName.toLowerCase().endsWith('.zip')) { const nested = await listEntries(entryData, entryName, allowedExt, depth + 1); if (nested) { rebaseTree(nested, zipName); - nested.name = entryName.split('/').pop(); - addPath(root, entryName.split('/'), nested); + nested.name = parts.at(-1); + addPath(root, parts, nested); } - } else if (extensionAllowed(entryName, allowedExt)) { - const path = `${zipName}/${entryName}`; + } else if (extensionAllowed(normalizedName, allowedExt)) { + const path = `${zipName}/${normalizedName}`; const file = makeFile(entryData, path); - addPath(root, entryName.split('/'), node('file', entryName.split('/').pop(), path, [], file)); + addPath(root, parts, node('file', parts.at(-1), path, [], file)); } } return root.children.length ? root : null;