{"repro_id":"REPRO-2026-00197","version":8,"title":"TaskingAI web_reader plugin SSRF via /v1/execute","repro_type":"security","status":"published","severity":"high","description":"Argus scanned a fresh real GitHub target with Arbor enabled. Arbor flagged the public plugin execution boundary for missing input validation at `plugin/app/routes/execute.py:117`, and Metis flagged a high-confidence SSRF in the `web_reader/read_web_page` plugin. Manual source review found that TaskingAI Plugin exposes a documented Docker-run `/v1/execute` API, accepts user-supplied `bundle_id`, `plugin_id`, and `input_params`, validates `read_web_page.url` only as a required string, and then performs an outbound `aiohttp` request to that URL from the plugin server.","root_cause":"# RCA Report: TaskingAI Plugin web_reader SSRF (ARGUS-TASKINGAI-WEB-READER-SSRF-20260625)\n\n## Summary\n\nThe TaskingAI Plugin server exposes an unauthenticated `/v1/execute` API endpoint that allows callers to invoke bundled plugins with arbitrary user-supplied input parameters. The `web_reader/read_web_page` plugin accepts a `url` parameter with no validation of scheme, hostname, IP range, or private network restrictions. When invoked through the API, the plugin server performs an outbound HTTP GET request to the attacker-supplied URL using `aiohttp.ClientSession().get(url=url, proxy=CONFIG.PROXY)` and returns the fetched content in the API response. This enables server-side request forgery (SSRF) against internal services accessible from the plugin container's network.\n\n## Impact\n\n- **Package/component affected**: TaskingAI Plugin server (`taskingai/taskingai-plugin:latest` Docker image, version approximately v0.3.5-1)\n- **Affected versions**: At least commit `f0092d6b2dd82e98e188e0b9849fdd4c7230dd98` and the current `latest` Docker image\n- **Risk level**: High\n- **Consequences**: An attacker who can reach the plugin service can force it to make HTTP requests to internal addresses, potentially exfiltrating metadata, accessing internal APIs, or scanning the container network.\n\n## Root Cause\n\nThe root cause is missing input validation on the `url` parameter in the `read_web_page` plugin and the `/v1/execute` route that passes arbitrary `input_params` directly to the plugin implementation.\n\nCode path:\n1. `plugin/app/routes/execute.py:117-123` strips only `None` values and invokes the selected plugin with user-supplied `input_params`.\n2. `plugin/bundles/web_reader/plugins/read_web_page/plugin.py:9-13` reads `url` from `plugin_input.input_params` and passes it directly to `aiohttp.ClientSession().get(url=url, proxy=CONFIG.PROXY)` without any allow-list, deny-list, or private-IP validation.\n3. The plugin schema at `plugin/bundles/web_reader/plugins/read_web_page/plugin_schema.yml:3-8` declares `url` as a required plain string with no URL restrictions.\n\nNo fix commit is known at this time; the ticket references a vulnerable commit but does not name a patched version.\n\n## Reproduction Steps\n\nThe reproduction script is `bundle/repro/reproduction_steps.sh`. It performs the following steps:\n\n1. Creates a dedicated Docker bridge network (`taskingai-ssrf-net`).\n2. Starts a Python HTTP listener container on the network that logs every incoming request and returns a unique marker string (`PRUVA_TASKINGAI_SSRF_MARKER`).\n3. Starts the official `taskingai/taskingai-plugin:latest` Docker container on the same network with required environment variables (`MODE=PROD`, `OBJECT_STORAGE_TYPE=local`, `HOST_URL`, etc.).\n4. Waits for the plugin service to become healthy (checks `/v1/execute` from a client container on the same network).\n5. Sends a `POST /v1/execute` request to the plugin with `bundle_id=web_reader`, `plugin_id=read_web_page`, and `input_params.url` pointing to the internal listener (`http://taskingai-listener:9000/internal/proof`).\n6. Verifies both required signals:\n   - The listener logs show `REQUEST_RECEIVED: /internal/proof`.\n   - The plugin API response contains `\"result\":\"PRUVA_TASKINGAI_SSRF_MARKER\"`.\n\n### Expected evidence of reproduction\n\n- `bundle/logs/listener.log` should contain the listener access log showing the request.\n- `bundle/logs/execute_response.json` should contain the JSON response from the plugin with the marker string inside `data.data.result`.\n\n## Evidence\n\n- **Log file locations**:\n  - `bundle/logs/execute_response.json` — the `/v1/execute` response from the plugin server\n  - `bundle/logs/listener.log` — the listener access log showing the SSRF request\n  - `bundle/logs/plugin_startup.log` — plugin startup log confirming the service loaded all bundles successfully\n- **Key excerpts**:\n  - Response: `{\"status\":\"success\",\"data\":{\"status\":200,\"data\":{\"result\":\"PRUVA_TASKINGAI_SSRF_MARKER\"}}}`\n  - Listener log: `REQUEST_RECEIVED: /internal/proof` followed by `\"GET /internal/proof HTTP/1.1\" 200 -`\n- **Environment details**: Docker-based reproduction using the official `taskingai/taskingai-plugin:latest` image (digest `sha256:d38f821e1585be4b56e827b81fdfcc1f42958cf063e8fe6e1f95defb8b1a1159`) on a Linux host with Docker bridge networking.\n\n## Recommendations / Next Steps\n\n1. **Input validation**: Add strict URL validation in the `read_web_page` plugin (or in the `/v1/execute` route) before performing the HTTP request. Reject URLs with private IP ranges (e.g., `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16`) and loopback hostnames (`localhost`).\n2. **URL scheme allow-list**: Restrict allowed schemes to `http` and `https` only, and block `file://`, `ftp://`, etc.\n3. **Redirect handling**: If redirects are followed, validate the destination of each redirect hop to prevent redirect-based SSRF bypasses.\n4. **Network isolation**: Run the plugin service in a restricted network segment with egress controls to limit internal exposure.\n5. **Testing**: Add unit and integration tests that verify the plugin rejects internal URLs and returns an appropriate error response.\n\n## Additional Notes\n\n- **Idempotency**: The reproduction script is fully idempotent. It creates and cleans up its own Docker network and containers, and it runs successfully every time (confirmed with 2 consecutive successful runs).\n- **Edge cases**: The vulnerability depends only on the `url` parameter reaching the `aiohttp.get()` call. Any URL string that the container can resolve is exploitable, including DNS names that resolve to internal addresses.\n- **Limitations**: The reproduction was performed using Docker networking. In a production deployment, the attacker would need network reachability to the plugin service; the SSRF destination would be any address reachable from the plugin server's network context.\n","cwe_id":"CWE-918","source_url":"https://github.com/TaskingAI/TaskingAI","package":{"name":"taskingai","ecosystem":"github","affected_versions":"commit f0092d6b2dd82e98e188e0b9849fdd4c7230dd98"},"reproduced_at":"2026-07-02T04:55:50.934129+00:00","duration_secs":1055.0,"tool_calls":62,"handoffs":2,"total_cost_usd":0.4307495999999999,"agent_costs":{"judge":0.06186549,"repro":0.35303926999999996,"support":0.01584484},"cost_breakdown":{"judge":{"accounts/fireworks/models/kimi-k2p6":0.06186549},"repro":{"accounts/fireworks/models/kimi-k2p6":0.35303926999999996},"support":{"accounts/fireworks/models/kimi-k2p6":0.01584484}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"published_at":"2026-07-02T04:55:51.504182+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5181,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":5926,"category":"analysis"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":482,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":768,"category":"other"},{"path":"bundle/ticket.json","filename":"ticket.json","size":7115,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":4530,"category":"ticket"},{"path":"bundle/logs/execute_response.err","filename":"execute_response.err","size":0,"category":"other"},{"path":"bundle/logs/plugin_startup.log","filename":"plugin_startup.log","size":4931,"category":"log"},{"path":"bundle/logs/reproduction_steps_run2.log","filename":"reproduction_steps_run2.log","size":5715,"category":"log"},{"path":"bundle/logs/execute_response.json","filename":"execute_response.json","size":91,"category":"other"},{"path":"bundle/logs/listener.log","filename":"listener.log","size":71,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":5715,"category":"log"},{"path":"bundle/logs/image_inspect.json","filename":"image_inspect.json","size":866,"category":"other"},{"path":"bundle/logs/plugin_inspect.log","filename":"plugin_inspect.log","size":7751,"category":"log"}]}