mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 20:44:50 +02:00
[eric] browser: agents can attach a file to an upload field without the OS picker; the path is allow-listed backend-side so a hostile page cannot turn it into exfiltration (ENG-47)
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""ENG-47: the upload tool is the first browser tool that can move bytes off the machine, and the
|
||||
browser sub-agent takes its instructions from the page it is driving. These pin the containment
|
||||
guard, including the case that matters most: a symlink planted INSIDE an allowed root."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from backend.apps.agents.browser.resolve_upload_path import (
|
||||
resolve_upload_path,
|
||||
allowed_upload_roots,
|
||||
UploadPathRefused,
|
||||
MAX_UPLOAD_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir():
|
||||
root = allowed_upload_roots()[0]
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def test_a_file_the_user_attached_is_uploadable(uploads_dir):
|
||||
f = os.path.join(uploads_dir, "resume.pdf")
|
||||
with open(f, "w", encoding="utf-8") as fh:
|
||||
fh.write("cv")
|
||||
assert resolve_upload_path(f) == os.path.realpath(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hostile", ["~/.ssh/id_rsa", "/etc/passwd", "/etc/hosts", ""])
|
||||
def test_paths_outside_every_allowed_root_are_refused(hostile):
|
||||
with pytest.raises(UploadPathRefused):
|
||||
resolve_upload_path(hostile)
|
||||
|
||||
|
||||
def test_a_symlink_inside_an_allowed_root_cannot_smuggle_a_file_out(uploads_dir):
|
||||
# String math on the path would pass this: it really does live under the uploads root.
|
||||
link = os.path.join(uploads_dir, "innocent.txt")
|
||||
if os.path.lexists(link):
|
||||
os.remove(link)
|
||||
os.symlink("/etc/hosts", link)
|
||||
try:
|
||||
with pytest.raises(UploadPathRefused):
|
||||
resolve_upload_path(link)
|
||||
finally:
|
||||
os.remove(link)
|
||||
|
||||
|
||||
def test_a_sibling_root_with_a_shared_prefix_is_not_inside_it(uploads_dir):
|
||||
# Without the trailing separator, root `self-swarm-uploads` would own `self-swarm-uploads-evil`.
|
||||
evil = uploads_dir + "-evil"
|
||||
os.makedirs(evil, exist_ok=True)
|
||||
f = os.path.join(evil, "x.txt")
|
||||
with open(f, "w", encoding="utf-8") as fh:
|
||||
fh.write("x")
|
||||
with pytest.raises(UploadPathRefused):
|
||||
resolve_upload_path(f)
|
||||
|
||||
|
||||
def test_a_directory_is_not_a_file(uploads_dir):
|
||||
with pytest.raises(UploadPathRefused):
|
||||
resolve_upload_path(uploads_dir)
|
||||
|
||||
|
||||
def test_an_oversized_file_is_refused(uploads_dir, monkeypatch):
|
||||
f = os.path.join(uploads_dir, "huge.bin")
|
||||
with open(f, "w", encoding="utf-8") as fh:
|
||||
fh.write("x")
|
||||
monkeypatch.setattr(os.path, "getsize", lambda p: MAX_UPLOAD_BYTES + 1)
|
||||
with pytest.raises(UploadPathRefused):
|
||||
resolve_upload_path(f)
|
||||
|
||||
|
||||
def test_the_dispatcher_refuses_before_the_command_leaves_the_backend(monkeypatch):
|
||||
"""The guard has to sit in front of the WS hop, not inside the renderer."""
|
||||
import asyncio
|
||||
import backend.apps.agents.core.ws_manager as ws_mod
|
||||
from backend.apps.agents.browser import browser_agent
|
||||
|
||||
sent = []
|
||||
|
||||
async def spy(*args, **kwargs):
|
||||
sent.append(args)
|
||||
return {"text": "should never happen"}
|
||||
|
||||
monkeypatch.setattr(ws_mod.ws_manager, "send_browser_command", spy, raising=True)
|
||||
out = asyncio.run(browser_agent.execute_browser_tool(
|
||||
"BrowserUploadFile", {"path": "/etc/passwd"}, "browser-1"))
|
||||
assert "Refused" in str(out.get("error", ""))
|
||||
assert sent == [], "a refused path must never reach the renderer"
|
||||
Reference in New Issue
Block a user