From 1d02b09fe074f15aa3f33dfffaaca2932fcd9dad Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 18 May 2026 00:08:04 +0200 Subject: [PATCH] fix(bash-guard): deny interpreter/library HTTP to internal hosts (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal-API rule only fired when the FIRST shell token was an HTTP CLI (curl/wget/http/https/httpie). smoke-17 showed an agent reach the orchestrator with hand-forged X-Agent-ID/X-Agent-Role headers via: python3 << 'EOF' import httpx httpx.post("http://roboco-orchestrator:8000/api/v2/flow/developer/i_will_work_on", headers={"X-Agent-ID": "", "X-Agent-Role": "developer"}) EOF The binary is python3 (slips the CLI check) and it imports httpx, not roboco.* (slips the #164 import check). Only minimax's wrong endpoint path prevented a real gateway bypass under a forged identity. Add a language-agnostic rule: deny when the command pairs an HTTP-client token (httpx/requests/urllib/aiohttp/http.client/net::http/fetch(/ node-fetch/axios/...) with a forbidden internal host, consistent with the curl/wget sibling (inspects full $low incl. heredoc body). External HTTP (pypi/docs/github) has no internal host so it still passes. The stale "interpreter one-liners — out of scope" KNOWN GAP comment is corrected; the variable-expansion gap remains documented. 11 new tests incl. the exact smoke-17 heredoc, requests/urllib/aiohttp/ node-fetch/Net::HTTP variants, and allow-cases (external host, client import w/o host, pytest runner). make quality green. --- docker/scripts/bash-guard-hook.sh | 28 ++++++++- tests/unit/scripts/test_bash_guard.py | 90 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/docker/scripts/bash-guard-hook.sh b/docker/scripts/bash-guard-hook.sh index c888f29b..b47489d7 100755 --- a/docker/scripts/bash-guard-hook.sh +++ b/docker/scripts/bash-guard-hook.sh @@ -119,18 +119,40 @@ fi # - scheme-less: `curl roboco-orchestrator:8000/api` # - protocol-relative: `curl //roboco-orchestrator:8000/api` # - any flag ordering: `curl -s -X POST http://localhost:8000/x -d ...` -# KNOWN GAPS (out of scope here): +# Interpreter / library-driven HTTP is handled by the #175 rule below. +# KNOWN GAP (still out of scope here): # - Variable expansion: `URL=http://orchestrator/x; curl $URL` — the guard # sees `curl $URL`, not the expanded URL, so this slips through. The # X-Agent-Role check (task 4) is the second gate. -# - Interpreter one-liners: `python -c "import urllib.request; ..."` — too -# deeply hidden to regex. Mitigated by the manifest-bound MCP surface. if echo "$low" | grep -qE '(^|[[:space:];&|])(curl|wget|http|https|httpie)[[:space:]]' && \ echo "$low" | grep -qE '((http|https)://)?/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]'; then echo "Denied: internal API calls bypass the gateway. Use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal / roboco-docs) — they route through the orchestrator with the right auth and tracing." >&2 exit 2 fi +# --- interpreter/library HTTP to an internal host (task #175) ----------------- +# The curl/wget rule above only fires when the FIRST token is an HTTP CLI. +# smoke-17 showed an agent reach the orchestrator with forged X-Agent-* +# identity headers via: +# python3 << 'EOF' +# import httpx +# httpx.post("http://roboco-orchestrator:8000/api/v2/flow/developer/i_will_work_on", +# headers={"X-Agent-ID": "", "X-Agent-Role": "developer"}) +# EOF +# The binary is python3 (slips the CLI check) and it imports httpx, not +# roboco.* (slips the #164 import check). Close it language-agnostically: +# deny when the command pairs an HTTP-client token with a forbidden +# internal host. The whole command (heredoc body included) is in $low, +# consistent with the curl/wget sibling above. Legitimate shell work does +# not both name an internal host AND drive an HTTP client; external HTTP +# (pypi, docs.python.org, github — github also hits its own rule earlier) +# has no internal host so it still passes. +if echo "$low" | grep -qE '(httpx|requests|urllib|aiohttp|http\.client|httplib|http\.request|net/http|net::http|httparty|faraday|lwp|libwww|httpurlconnection|okhttp|node-fetch|axios|xmlhttprequest|websocket|fetch[[:space:]]*\()' && \ + echo "$low" | grep -qE '((http|https|ws|wss)://)?/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]'; then + echo "Denied: reaching an internal host via an HTTP client (httpx / requests / urllib / aiohttp / fetch / Net::HTTP / ...) bypasses the gateway, role manifest, tracing and auth — and lets you forge X-Agent-* identity headers. Use your role's MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal / roboco-docs); they are the only sanctioned path to the orchestrator." >&2 + exit 2 +fi + if echo "$low" | grep -qE '(^|[[:space:];&|])(env|printenv)([[:space:]]|$)' && ! echo "$low" | grep -qE '(^|[[:space:];&|])env[[:space:]]+-i'; then # allow `env VAR=val cmd` style prefixes (`env ` followed by `NAME=`) if ! echo "$low" | grep -qE '(^|[[:space:];&|])env[[:space:]]+[a-z_][a-z0-9_]*='; then diff --git a/tests/unit/scripts/test_bash_guard.py b/tests/unit/scripts/test_bash_guard.py index 2949eff1..f2b43d67 100644 --- a/tests/unit/scripts/test_bash_guard.py +++ b/tests/unit/scripts/test_bash_guard.py @@ -250,3 +250,93 @@ def test_still_denies_git_after_echo_separator() -> None: def test_still_denies_printf_piped_into_git_apply_path() -> None: """printf body stripped, but `| git checkout` survives the separator.""" assert _run("printf 'patch' | git checkout -- .") == _DENIED + + +# --------------------------------------------------------------------------- +# Task #175: interpreter/library-driven HTTP to an internal host. The +# curl/wget rule only fires when the first token is an HTTP CLI; smoke-17 +# reached the orchestrator with forged X-Agent-* headers via a python3 +# heredoc using httpx. Close it language-agnostically. +# --------------------------------------------------------------------------- + + +def test_blocks_smoke17_python_httpx_heredoc_to_orchestrator() -> None: + """The exact smoke-17 bypass: python3 heredoc, httpx.post to the + orchestrator with hand-forged identity headers.""" + cmd = ( + "python3 << 'PYEOF'\n" + "import httpx\n" + 'httpx.post("http://roboco-orchestrator:8000/api/v2/flow/' + 'developer/i_will_work_on",\n' + ' headers={"X-Agent-ID": "00000000-0000-0000-0001-' + '000000000001", "X-Agent-Role": "developer"})\n' + "PYEOF" + ) + assert _run(cmd) == _DENIED + + +def test_blocks_python_requests_to_localhost() -> None: + assert ( + _run("python3 -c \"import requests; requests.get('http://localhost:8000/x')\"") + == _DENIED + ) + + +def test_blocks_python_urllib_to_orchestrator() -> None: + assert ( + _run( + 'python3 -c "import urllib.request; ' + "urllib.request.urlopen('http://roboco-orchestrator:8000/api')\"" + ) + == _DENIED + ) + + +def test_blocks_node_fetch_to_internal_host() -> None: + assert ( + _run("node -e \"fetch('http://roboco-orchestrator:8000/api/v2/do/note')\"") + == _DENIED + ) + + +def test_blocks_ruby_nethttp_to_127() -> None: + assert ( + _run( + "ruby -e \"require 'net/http'; Net::HTTP.get(URI('http://127.0.0.1:8000/x'))\"" + ) + == _DENIED + ) + + +def test_blocks_aiohttp_to_orchestrator() -> None: + cmd = ( + "uv run python3 << 'EOF'\n" + "import aiohttp, asyncio\n" + "async def m():\n" + " async with aiohttp.ClientSession() as s:\n" + ' await s.post("http://roboco-orchestrator:8000/api/v2/flow/' + 'developer/i_am_done")\n' + "asyncio.run(m())\n" + "EOF" + ) + assert _run(cmd) == _DENIED + + +def test_allows_python_requests_to_external_host() -> None: + """External HTTP (pypi/docs) has no internal host — must still pass.""" + assert ( + _run("python3 -c \"import requests; requests.get('https://pypi.org/simple/')\"") + == _ALLOWED + ) + + +def test_allows_python_httpx_import_without_internal_host() -> None: + """Importing/using an HTTP client with no internal host is fine — + don't over-block normal dependency usage.""" + assert _run('python3 -c "import httpx; print(httpx.__version__)"') == _ALLOWED + + +def test_allows_pytest_even_if_suite_uses_requests() -> None: + """The command string is just the runner — no http-client token and + no internal host literal — so it must pass.""" + assert _run("uv run python -m pytest tests/unit/ -q") == _ALLOWED