@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
|
@@ -11,18 +11,59 @@ from pathlib import Path
|
|
|
11
11
|
from api.config import MAX_UPLOAD_BYTES, STATE_DIR
|
|
12
12
|
from api.helpers import j, bad
|
|
13
13
|
from api.models import get_session
|
|
14
|
-
from api.workspace import safe_resolve_ws
|
|
14
|
+
from api.workspace import safe_resolve_ws, resolve_trusted_workspace, open_anchored_create_fd, make_anchored_dir
|
|
15
15
|
|
|
16
|
+
|
|
17
|
+
def _max_extracted_bytes() -> int:
|
|
18
|
+
"""Total-extracted-bytes cap for archive uploads (zip/tar-bomb guard).
|
|
19
|
+
|
|
20
|
+
Independently tunable from the upload size cap via
|
|
21
|
+
HERMES_WEBUI_MAX_EXTRACTED_MB; defaults to 10x the upload cap. Read at call
|
|
22
|
+
time (not import) so the value reflects the running process's environment
|
|
23
|
+
and is exercisable by tests against the out-of-process test server.
|
|
24
|
+
"""
|
|
25
|
+
raw = os.getenv("HERMES_WEBUI_MAX_EXTRACTED_MB", "").strip()
|
|
26
|
+
if raw:
|
|
27
|
+
try:
|
|
28
|
+
mb = float(raw)
|
|
29
|
+
if mb > 0:
|
|
30
|
+
return int(mb * 1024 * 1024)
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
return 10 * MAX_UPLOAD_BYTES
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Back-compat module constant (some call sites / tests reference it). The
|
|
37
|
+
# authoritative value is _max_extracted_bytes(), read at extraction time.
|
|
16
38
|
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
|
|
17
39
|
|
|
18
40
|
|
|
19
41
|
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
|
20
42
|
import re as _re, email.parser as _ep
|
|
43
|
+
# Imported locally (not just module-level) so the function stays
|
|
44
|
+
# self-contained — some tests exec() this function's source in an isolated
|
|
45
|
+
# namespace, and a bare module global would NameError there.
|
|
46
|
+
try:
|
|
47
|
+
from api.config import MAX_UPLOAD_BYTES as _MAX_UPLOAD_BYTES
|
|
48
|
+
except Exception:
|
|
49
|
+
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
21
50
|
m = _re.search(r'boundary=([^;\s]+)', content_type)
|
|
22
51
|
if not m:
|
|
23
52
|
raise ValueError('No boundary in Content-Type')
|
|
24
53
|
boundary = m.group(1).strip('"').encode()
|
|
25
|
-
|
|
54
|
+
# Centralized length guard for ALL upload callers: a missing/garbage or
|
|
55
|
+
# NEGATIVE Content-Length must never reach rfile.read(<0), which reads the
|
|
56
|
+
# stream unbounded (read(-1) == read-to-EOF) and bypasses the per-handler
|
|
57
|
+
# size cap. Reject anything not in [0, MAX_UPLOAD_BYTES].
|
|
58
|
+
try:
|
|
59
|
+
length = int(content_length)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
raise ValueError('Invalid Content-Length') from None
|
|
62
|
+
if length < 0:
|
|
63
|
+
raise ValueError('Invalid Content-Length (negative)')
|
|
64
|
+
if length > _MAX_UPLOAD_BYTES:
|
|
65
|
+
raise ValueError(f'Upload too large (max {_MAX_UPLOAD_BYTES} bytes)')
|
|
66
|
+
raw = rfile.read(length)
|
|
26
67
|
fields = {}
|
|
27
68
|
files = {}
|
|
28
69
|
delimiter = b'--' + boundary
|
|
@@ -146,6 +187,7 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
|
|
146
187
|
"""
|
|
147
188
|
import zipfile, tarfile, io, os, shutil
|
|
148
189
|
|
|
190
|
+
cap = _max_extracted_bytes()
|
|
149
191
|
name = Path(filename).name
|
|
150
192
|
stem = Path(filename).stem # strip .zip / .tar.gz etc.
|
|
151
193
|
|
|
@@ -158,13 +200,23 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
|
|
158
200
|
|
|
159
201
|
# Determine destination directory — use archive stem as folder name
|
|
160
202
|
dest_dir = safe_resolve_ws(workspace, stem)
|
|
161
|
-
# Avoid overwriting existing files by appending a suffix
|
|
203
|
+
# Avoid overwriting existing files by appending a suffix (bounded — astronomically
|
|
204
|
+
# unlikely to collide, but never spin forever).
|
|
162
205
|
if dest_dir.exists():
|
|
163
206
|
import string, random
|
|
164
|
-
|
|
207
|
+
for _ in range(1000):
|
|
208
|
+
if not dest_dir.exists():
|
|
209
|
+
break
|
|
165
210
|
suffix = ''.join(random.choices(string.digits, k=3))
|
|
166
|
-
dest_dir =
|
|
167
|
-
|
|
211
|
+
dest_dir = safe_resolve_ws(workspace, stem).with_name(stem + '_' + suffix)
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError('Could not allocate a unique extraction directory')
|
|
214
|
+
# #3398: create the extraction root race-safely under the true workspace root.
|
|
215
|
+
make_anchored_dir(workspace, dest_dir)
|
|
216
|
+
|
|
217
|
+
# Member-count cap: a tiny archive with millions of (possibly empty) members
|
|
218
|
+
# slips under the byte cap but can exhaust inodes / file descriptors. Bound it.
|
|
219
|
+
_MAX_ARCHIVE_MEMBERS = 10000
|
|
168
220
|
|
|
169
221
|
extracted_files = []
|
|
170
222
|
total_extracted = 0
|
|
@@ -176,29 +228,38 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
|
|
176
228
|
# Skip directories
|
|
177
229
|
if member.is_dir():
|
|
178
230
|
continue
|
|
231
|
+
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
|
|
234
|
+
f'Possible archive bomb.'
|
|
235
|
+
)
|
|
179
236
|
# Zip-slip protection
|
|
180
237
|
member_path = (dest_dir / member.filename).resolve()
|
|
181
238
|
if not member_path.is_relative_to(dest_dir.resolve()):
|
|
182
239
|
raise ValueError(f'Zip-slip blocked: {member.filename}')
|
|
183
240
|
# Zip-bomb protection: track actual extracted bytes (not declared file_size)
|
|
184
|
-
if total_extracted >
|
|
241
|
+
if total_extracted > cap:
|
|
185
242
|
raise ValueError(
|
|
186
243
|
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
|
187
|
-
f'{
|
|
244
|
+
f'{cap // (1024*1024)} MB limit). '
|
|
188
245
|
f'Possible zip bomb.'
|
|
189
246
|
)
|
|
190
|
-
|
|
191
|
-
|
|
247
|
+
# #3398: open_anchored_create_fd creates intermediate dirs
|
|
248
|
+
# race-safely under the true workspace root (anchored mkdirat),
|
|
249
|
+
# so no pathname member_path.parent.mkdir() before it (which
|
|
250
|
+
# could be redirected outside by a raced symlink component).
|
|
251
|
+
_mfd = open_anchored_create_fd(workspace, member_path)
|
|
252
|
+
with zf.open(member) as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
|
|
192
253
|
_chunk_size = 65536
|
|
193
254
|
while True:
|
|
194
255
|
chunk = src.read(_chunk_size)
|
|
195
256
|
if not chunk:
|
|
196
257
|
break
|
|
197
258
|
total_extracted += len(chunk)
|
|
198
|
-
if total_extracted >
|
|
259
|
+
if total_extracted > cap:
|
|
199
260
|
raise ValueError(
|
|
200
261
|
f'Extraction too large (> '
|
|
201
|
-
f'{
|
|
262
|
+
f'{cap // (1024*1024)} MB limit). '
|
|
202
263
|
f'Possible zip bomb.'
|
|
203
264
|
)
|
|
204
265
|
dst.write(chunk)
|
|
@@ -209,31 +270,39 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
|
|
|
209
270
|
for member in tf.getmembers():
|
|
210
271
|
if not member.isfile():
|
|
211
272
|
continue
|
|
273
|
+
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
|
|
276
|
+
f'Possible archive bomb.'
|
|
277
|
+
)
|
|
212
278
|
# Tar-slip protection
|
|
213
279
|
member_path = (dest_dir / member.name).resolve()
|
|
214
280
|
if not member_path.is_relative_to(dest_dir.resolve()):
|
|
215
281
|
raise ValueError(f'Tar-slip blocked: {member.name}')
|
|
216
282
|
# Tar-bomb protection: track actual extracted bytes (not declared size)
|
|
217
|
-
if total_extracted >
|
|
283
|
+
if total_extracted > cap:
|
|
218
284
|
raise ValueError(
|
|
219
285
|
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
|
|
220
|
-
f'{
|
|
286
|
+
f'{cap // (1024*1024)} MB limit). '
|
|
221
287
|
f'Possible zip bomb.'
|
|
222
288
|
)
|
|
223
|
-
|
|
289
|
+
# #3398: anchored member create makes intermediate dirs
|
|
290
|
+
# race-safely; no pathname member_path.parent.mkdir() first.
|
|
224
291
|
src_obj = tf.extractfile(member)
|
|
225
292
|
if src_obj:
|
|
226
|
-
|
|
293
|
+
# #3398: fd-anchored member create under the TRUE workspace root.
|
|
294
|
+
_mfd = open_anchored_create_fd(workspace, member_path)
|
|
295
|
+
with src_obj as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
|
|
227
296
|
_chunk_size = 65536
|
|
228
297
|
while True:
|
|
229
298
|
chunk = src.read(_chunk_size)
|
|
230
299
|
if not chunk:
|
|
231
300
|
break
|
|
232
301
|
total_extracted += len(chunk)
|
|
233
|
-
if total_extracted >
|
|
302
|
+
if total_extracted > cap:
|
|
234
303
|
raise ValueError(
|
|
235
304
|
f'Extraction too large (> '
|
|
236
|
-
f'{
|
|
305
|
+
f'{cap // (1024*1024)} MB limit). '
|
|
237
306
|
f'Possible zip bomb.'
|
|
238
307
|
)
|
|
239
308
|
dst.write(chunk)
|
|
@@ -320,3 +389,167 @@ def handle_transcribe(handler):
|
|
|
320
389
|
Path(temp_path).unlink(missing_ok=True)
|
|
321
390
|
except Exception:
|
|
322
391
|
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def handle_workspace_upload(handler):
|
|
395
|
+
"""Upload a file into a session's workspace directory.
|
|
396
|
+
|
|
397
|
+
Form fields:
|
|
398
|
+
session_id – target session
|
|
399
|
+
path – subdirectory within the workspace (default: '')
|
|
400
|
+
File:
|
|
401
|
+
file – the uploaded file(s)
|
|
402
|
+
"""
|
|
403
|
+
import traceback as _tb
|
|
404
|
+
try:
|
|
405
|
+
content_type = handler.headers.get('Content-Type', '')
|
|
406
|
+
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
|
407
|
+
if content_length > MAX_UPLOAD_BYTES:
|
|
408
|
+
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
|
409
|
+
|
|
410
|
+
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
|
411
|
+
session_id = fields.get('session_id', '')
|
|
412
|
+
subpath = fields.get('path', '')
|
|
413
|
+
|
|
414
|
+
if not session_id:
|
|
415
|
+
return j(handler, {'error': 'Missing session_id'}, status=400)
|
|
416
|
+
|
|
417
|
+
if not files:
|
|
418
|
+
return j(handler, {'error': 'No file field in request'}, status=400)
|
|
419
|
+
|
|
420
|
+
# Validate session
|
|
421
|
+
try:
|
|
422
|
+
session = get_session(session_id)
|
|
423
|
+
except KeyError:
|
|
424
|
+
return j(handler, {'error': 'Session not found'}, status=404)
|
|
425
|
+
|
|
426
|
+
# Resolve workspace root from session
|
|
427
|
+
workspace = resolve_trusted_workspace(session.workspace)
|
|
428
|
+
|
|
429
|
+
# Resolve target subdirectory within workspace
|
|
430
|
+
target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
|
|
431
|
+
# safe_resolve_ws intentionally permits in-workspace symlinks pointing
|
|
432
|
+
# outside the root (read trust model). For an UPLOAD target that's not
|
|
433
|
+
# acceptable: a planted symlink subpath would let mkdir() + writes create
|
|
434
|
+
# files OUTSIDE the workspace. Require the resolved target to be inside
|
|
435
|
+
# the workspace before creating anything. (is_relative_to is True for the
|
|
436
|
+
# workspace==target equality case, so the normal subpath='' path passes.)
|
|
437
|
+
if not target_dir.resolve().is_relative_to(workspace.resolve()):
|
|
438
|
+
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
|
|
439
|
+
# #3398: create the upload target dir race-safely under the workspace root
|
|
440
|
+
# (anchored mkdirat) so a raced symlink subpath can't mkdir outside.
|
|
441
|
+
try:
|
|
442
|
+
make_anchored_dir(workspace, target_dir)
|
|
443
|
+
except (ValueError, OSError):
|
|
444
|
+
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
|
|
445
|
+
|
|
446
|
+
results = []
|
|
447
|
+
for _field_name, (filename, file_bytes) in files.items():
|
|
448
|
+
if not filename:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
safe_name = _sanitize_upload_name(filename)
|
|
452
|
+
dest = safe_resolve_ws(target_dir, safe_name)
|
|
453
|
+
|
|
454
|
+
# Path traversal guard (belt-and-suspenders: safe_resolve_ws above is
|
|
455
|
+
# the authoritative guard and raises ValueError on traversal; this
|
|
456
|
+
# check catches any edge case where the resolved path escapes).
|
|
457
|
+
if not dest.resolve().is_relative_to(workspace.resolve()):
|
|
458
|
+
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
|
|
459
|
+
|
|
460
|
+
# Deduplicate: append -1, -2, etc. if file already exists
|
|
461
|
+
if dest.exists():
|
|
462
|
+
stem = dest.stem
|
|
463
|
+
suffix = dest.suffix
|
|
464
|
+
for idx in range(1, 1000):
|
|
465
|
+
candidate = safe_resolve_ws(target_dir, f'{stem}-{idx}{suffix}')
|
|
466
|
+
if not candidate.resolve().is_relative_to(workspace.resolve()):
|
|
467
|
+
return j(handler, {'error': 'Path traversal blocked'}, status=403)
|
|
468
|
+
if not candidate.exists():
|
|
469
|
+
dest = candidate
|
|
470
|
+
break
|
|
471
|
+
else:
|
|
472
|
+
return j(handler, {'error': 'Too many uploads with the same filename'}, status=400)
|
|
473
|
+
|
|
474
|
+
# #3398 TOCTOU hardening: create the destination via an anchored
|
|
475
|
+
# openat-walk from the true workspace root with O_CREAT|O_EXCL|
|
|
476
|
+
# O_NOFOLLOW, so a symlink raced into any path component after the
|
|
477
|
+
# containment checks above cannot redirect the write outside the
|
|
478
|
+
# workspace. The dedup loop guarantees `dest` does not exist.
|
|
479
|
+
try:
|
|
480
|
+
_wfd = open_anchored_create_fd(workspace, dest.resolve())
|
|
481
|
+
except FileExistsError:
|
|
482
|
+
return j(handler, {'error': f'Upload destination already exists: {safe_name}'}, status=409)
|
|
483
|
+
except (ValueError, OSError):
|
|
484
|
+
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
|
|
485
|
+
with os.fdopen(_wfd, 'wb', closefd=True) as _wfh:
|
|
486
|
+
_wfh.write(file_bytes)
|
|
487
|
+
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
|
488
|
+
|
|
489
|
+
# For archives, optionally extract into the target directory.
|
|
490
|
+
# Suffix set MUST match extract_archive()'s supported formats, else
|
|
491
|
+
# accepted-but-unlisted archives (.tar/.tbz2/.txz) silently land as
|
|
492
|
+
# raw files instead of extracting.
|
|
493
|
+
is_archive = safe_name.lower().endswith(('.zip', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz'))
|
|
494
|
+
if is_archive:
|
|
495
|
+
import zipfile, tarfile, traceback as _extract_tb
|
|
496
|
+
try:
|
|
497
|
+
extraction = extract_archive(file_bytes, safe_name, target_dir)
|
|
498
|
+
# Remove the archive file after successful extraction
|
|
499
|
+
dest.unlink(missing_ok=True)
|
|
500
|
+
results.append({
|
|
501
|
+
'filename': safe_name,
|
|
502
|
+
'path': str(extraction.get('dest', target_dir)),
|
|
503
|
+
'size': len(file_bytes),
|
|
504
|
+
'is_image': False,
|
|
505
|
+
'extracted': True,
|
|
506
|
+
'extracted_files': extraction.get('files', []),
|
|
507
|
+
'extracted_count': extraction.get('extracted', 0),
|
|
508
|
+
})
|
|
509
|
+
continue
|
|
510
|
+
except (zipfile.BadZipFile, tarfile.TarError, ValueError) as e:
|
|
511
|
+
# Extraction failed — remove the archive file (no partial
|
|
512
|
+
# content left behind) and surface the error to the user.
|
|
513
|
+
dest.unlink(missing_ok=True)
|
|
514
|
+
print(f'[webui] workspace upload extract error: {e}', flush=True)
|
|
515
|
+
results.append({
|
|
516
|
+
'filename': safe_name,
|
|
517
|
+
'path': str(target_dir),
|
|
518
|
+
'size': len(file_bytes),
|
|
519
|
+
'mime': mime,
|
|
520
|
+
'is_image': False,
|
|
521
|
+
'extracted': False,
|
|
522
|
+
'extract_error': str(e) or 'Archive extraction failed',
|
|
523
|
+
})
|
|
524
|
+
continue
|
|
525
|
+
except Exception:
|
|
526
|
+
print('[webui] workspace upload extract error: ' + _extract_tb.format_exc(), flush=True)
|
|
527
|
+
dest.unlink(missing_ok=True)
|
|
528
|
+
results.append({
|
|
529
|
+
'filename': safe_name,
|
|
530
|
+
'path': str(target_dir),
|
|
531
|
+
'size': len(file_bytes),
|
|
532
|
+
'mime': mime,
|
|
533
|
+
'is_image': False,
|
|
534
|
+
'extracted': False,
|
|
535
|
+
'extract_error': 'Archive extraction failed',
|
|
536
|
+
})
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
results.append({
|
|
540
|
+
'filename': dest.name,
|
|
541
|
+
'path': str(dest),
|
|
542
|
+
'size': dest.stat().st_size,
|
|
543
|
+
'mime': mime,
|
|
544
|
+
'is_image': mime.startswith('image/'),
|
|
545
|
+
'extracted': False,
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
if len(results) == 1:
|
|
549
|
+
return j(handler, results[0])
|
|
550
|
+
return j(handler, {'files': results, 'count': len(results)})
|
|
551
|
+
except ValueError as e:
|
|
552
|
+
return j(handler, {'error': str(e)}, status=400)
|
|
553
|
+
except Exception:
|
|
554
|
+
print('[webui] workspace upload error: ' + _tb.format_exc(), flush=True)
|
|
555
|
+
return j(handler, {'error': 'Upload failed'}, status=500)
|