mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(bash-guard): deny interpreter/library HTTP to internal hosts (#175)
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": "<self>", "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.
This commit is contained in:
@@ -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": "<self>", "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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user