feat: upload and extract zip/tar archives into workspace (#525)

- Add extract_archive() with zip-slip and tar-slip protection
- New /api/upload/extract endpoint for archive uploads
- Auto-detect archive files (.zip, .tar.gz, .tgz, .bz2, .xz)
- Archives extracted into named subfolder (avoids overwrites)
- Workspace file tree auto-refreshes after extraction
- Archive extensions added to file picker accept list
- i18n: archive_extracted key in all 7 locales

Security: path traversal blocked via resolve() prefix check,
matching existing safe_resolve_ws() sandbox pattern.
This commit is contained in:
bergeouss authored and Hermes Agent committed 2026-04-29 04:31:59 +00:00
1 parent d08d96f864
commit 8c24b24dcd
5 files changed
+116 -4

No files matched your search

+3 -1
View File
@@ -413,7 +413,7 @@ from api.workspace import (
_is_blocked_system_path,
_workspace_blocked_roots,
)
from api.upload import handle_upload, handle_transcribe
from api.upload import handle_upload, handle_upload_extract, handle_transcribe
from api.streaming import _sse, _run_agent_streaming, cancel_stream
from api.providers import get_providers, set_provider_key, remove_provider_key
from api.onboarding import (
@@ -1196,6 +1196,8 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/upload":
return handle_upload(handler)
if parsed.path == "/api/upload/extract":
return handle_upload_extract(handler)
if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
+92
View File
@@ -88,6 +88,98 @@ def handle_upload(handler):
return j(handler, {'error': 'Upload failed'}, status=500)
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
"""Extract a zip or tar archive into the workspace.
Returns a dict with ``extracted`` (int), ``files`` (list[str]).
Raises ValueError on zip-slip or unsupported format.
"""
import zipfile, tarfile, io, os
name = Path(filename).name
stem = Path(filename).stem # strip .zip / .tar.gz etc.
if name.lower().endswith(('.zip',)):
_mode = 'zip'
elif name.lower().endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')):
_mode = 'tar'
else:
raise ValueError(f'Unsupported archive format: {filename}')
# Determine destination directory — use archive stem as folder name
dest_dir = safe_resolve_ws(workspace, stem)
# Avoid overwriting existing files by appending a suffix
if dest_dir.exists():
import string, random
while dest_dir.exists():
suffix = ''.join(random.choices(string.digits, k=3))
dest_dir = dest_dir.with_name(stem + '_' + suffix)
dest_dir.mkdir(parents=True, exist_ok=True)
extracted_files = []
if _mode == 'zip':
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
for member in zf.infolist():
# Skip directories
if member.is_dir():
continue
# Zip-slip protection
member_path = (dest_dir / member.filename).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise ValueError(f'Zip-slip blocked: {member.filename}')
member_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(member_path, 'wb') as dst:
dst.write(src.read())
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
elif _mode == 'tar':
with tarfile.open(fileobj=io.BytesIO(file_bytes)) as tf:
for member in tf.getmembers():
if not member.isfile():
continue
# Tar-slip protection
member_path = (dest_dir / member.name).resolve()
if not str(member_path).startswith(str(dest_dir.resolve())):
raise ValueError(f'Tar-slip blocked: {member.name}')
member_path.parent.mkdir(parents=True, exist_ok=True)
with tf.extractfile(member) as src, open(member_path, 'wb') as dst:
if src:
dst.write(src.read())
extracted_files.append(str(member_path.relative_to(workspace.resolve())))
return {'extracted': len(extracted_files), 'files': extracted_files, 'dest': str(dest_dir)}
def handle_upload_extract(handler):
"""Handle archive upload and extraction."""
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
session_id = fields.get('session_id', '')
if 'file' not in files:
return j(handler, {'error': 'No file field in request'}, status=400)
filename, file_bytes = files['file']
if not filename:
return j(handler, {'error': 'No filename in upload'}, status=400)
try:
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
result = extract_archive(file_bytes, filename, workspace)
return j(handler, {'ok': True, **result})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] upload extract error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Archive extraction failed'}, status=500)
def handle_transcribe(handler):
import traceback as _tb
temp_path = None
+7
View File
@@ -239,6 +239,7 @@ const LOCALES = {
empty_dir: '(empty)',
upload_failed: 'Upload failed: ',
all_uploads_failed: (n) => `All ${n} upload(s) failed`,
archive_extracted: (n, c) => `Extracted ${n} file(s) from ${c} archive(s)`,
session_pin: 'Pin conversation',
session_unpin: 'Unpin conversation',
session_pin_desc: 'Keep this conversation at the top',
@@ -865,6 +866,7 @@ const LOCALES = {
empty_dir: '(пусто)',
upload_failed: 'Не удалось загрузить: ',
all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`,
archive_extracted: (n, c) => `Извлечено ${n} файл(ов) из ${c} архив(ов)`,
settings_title: 'Настройки',
settings_save_btn: 'Сохранить настройки',
settings_label_model: 'Модель по умолчанию',
@@ -1487,6 +1489,7 @@ const LOCALES = {
empty_dir: '(vacío)',
upload_failed: 'Error al subir: ',
all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`,
archive_extracted: (n, c) => `${n} archivo(s) extraído(s) de ${c} archivo(s) comprimido(s)`,
// settings panel
settings_title: 'Configuración',
settings_save_btn: 'Guardar configuración',
@@ -2006,6 +2009,7 @@ const LOCALES = {
model_not_found_label: 'Modell nicht gefunden',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
archive_extracted: (n, c) => `${n} Datei(en) aus ${c} Archiv(en) entpackt`,
cmd_clear: 'Konversationsverlauf löschen',
cmd_compress: 'Kontext manuell komprimieren (Nutzung: /compress [Thema])',
cmd_compact_alias: 'Alte Alias für /compress',
@@ -2641,6 +2645,7 @@ const LOCALES = {
// onboarding
onboarding_badge: '首次运行',
onboarding_title: '欢迎使用 Hermes Web UI',
archive_extracted: (n, c) => `从 ${c} 个压缩包中解压了 ${n} 个文件`,
onboarding_lead: '快速引导将验证 Hermes、保存真实的提供商配置、选择工作区和模型,并可选设置密码保护应用。',
onboarding_back: '返回',
onboarding_continue: '继续',
@@ -3179,6 +3184,7 @@ const LOCALES = {
settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58',
settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a',
settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a',
archive_extracted: (n, c) => `\u5f9e ${c} \u500b\u58d3\u7e2e\u5305\u4e2d\u89e3\u58d3\u7e2e\u4e86 ${n} \u500b\u6a94\u6848`,
settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u4fdd\u8b77\u5df2\u555f\u7528\uff0c\u7576\u524d\u700f\u89bd\u5668\u6703\u4fdd\u6301\u767b\u5165',
settings_saved_pw_updated: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u5df2\u66f4\u65b0',
// login page
@@ -3869,6 +3875,7 @@ const LOCALES = {
workspace_auto_create_folder: '폴더가 없으면 생성',
folder_add_as_space_btn: '스페이스로 추가',
folder_add_as_space_msg: '이 폴더를 워크스페이스 목록의 새 스페이스로 추가할까요?',
archive_extracted: (n, c) => `${c}개 압축 파일에서 ${n}개 파일 압축 해제됨`,
folder_add_as_space_title: '스페이스로 추가할까요?',
remove_title: 'Remove',
empty_dir: '(비어 있음)',
+1 -1
View File
@@ -323,7 +323,7 @@
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx" style="display:none">
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx,.zip,.tar,.gz,.tgz,.bz2,.xz" style="display:none">
<button class="icon-btn" id="btnAttach" title="Attach files">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
+13 -2
View File
@@ -88,6 +88,7 @@ document.addEventListener('click', e => {
});
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
@@ -3665,17 +3666,27 @@ async function uploadPendingFiles(){
const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{
const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd});
const isArchive=_ARCHIVE_EXTS.test(f.name);
const url=new URL(isArchive?'api/upload/extract':'api/upload',location.href).href;
const res=await fetch(url,{method:'POST',credentials:'include',body:fd});
if(_redirectIfUnauth(res)) return;
if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json();
if(data.error)throw new Error(data.error);
names.push({name: data.filename, path: data.path});
if(isArchive){
names.push({name: data.dest, path: data.dest, extracted: data.extracted});
if(typeof loadDir==='function')loadDir(S.currentDir||'.');
}else{
names.push({name: data.filename, path: data.path});
}
}catch(e){failures++;setStatus(`\u274c ${t('upload_failed')}${f.name} \u2014 ${e.message}`);}
bar.style.width=`${Math.round((i+1)/total*100)}%`;
}
barWrap.classList.remove('active');bar.style.width='0%';
S.pendingFiles=[];renderTray();
if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total));
// Show extraction summary
const extracted=names.filter(n=>n.extracted);
if(extracted.length)showToast(t('archive_extracted',extracted.reduce((s,n)=>s+n.extracted,0),extracted.length));
return names;
}