fix: harden local trust boundaries (shell allowlist, MCP OAuth loopback, WS ingestion)

Boundary-hardening pass addressing three audit findings on the local sidecar.

Shell command allowlist (andrewyng/openworker#28):
- Replace prefix-string matching in PermissionEngine._command_allowed with
  argv-aware matching: reject any command containing shell operators
  (; & | > < ` $( ( and newlines) before consulting the allowlist, then require
  the allowlisted entry's tokens to be an exact argv prefix. This closes the
  auto-run bypass where an allowlisted "git status" also auto-ran
  "git status && rm -rf ~", pipes, redirection, and command substitution.
- Drop language interpreters / package managers (python, python3, node, npm,
  npx) from DEFAULT_ALLOWED_COMMANDS — allowlisting an interpreter allowlists
  arbitrary code (python3 -c "..."), defeating approval gating. Read-only
  inspection commands and pytest remain.

MCP OAuth loopback (andrewyng/openworker#29):
- Verify the OAuth state at the loopback boundary. The MCP SDK already validates
  state (compare_digest), so this is not a CSRF fix but defense-in-depth: capture
  the state from the authorize URL and have deliver_callback ignore a callback
  whose state does not match WITHOUT consuming the pending future, so a stray or
  forged local hit can no longer abort a user's in-progress sign-in. Falls back to
  prior accept-any behavior when no state was captured.

WebSocket ingestion caps (andrewyng/openworker#38):
- Bound a single user_message frame in the session WS loop: max text length,
  max attachment count, and max total attachment bytes. Oversized frames get a
  visible error frame and are dropped instead of being buffered into a turn; the
  socket stays alive. Guards the unauthenticated loopback socket against cheap
  memory spikes.

Tests:
- Allowlist: reject operator chaining (8 variants), argv-boundary matching, and
  interpreters-not-auto-allowed-by-default.
- OAuth: state extraction, and mismatched/missing state ignored without consuming
  the flow while the matching state still resolves it.
- WS: oversized text and too-many-attachments rejected with an error frame, and a
  normal message still works afterwards.

Full suite: 865 passed (1 pre-existing unrelated failure in
test_provider_router::test_manager_curated_models, present on origin/main).
This commit is contained in:
Fahad Siddiqui
2026-07-24 01:41:05 +05:00
parent 4766e59c47
commit 657cf03460
7 changed files with 210 additions and 11 deletions
+26
View File
@@ -31,8 +31,10 @@ def _state(tmp_path, monkeypatch, servers=None):
@pytest.fixture(autouse=True)
def _no_pending():
mcp_oauth._pending = None
mcp_oauth._expected_state = None
yield
mcp_oauth._pending = None
mcp_oauth._expected_state = None
# -- config --------------------------------------------------------------------
@@ -100,6 +102,30 @@ def test_wait_then_deliver_resolves():
assert asyncio.run(run()) == ("c0de", "st4te")
def test_state_from_url():
url = "https://idp.example/authorize?client_id=x&state=abc123&scope=y"
assert mcp_oauth._state_from_url(url) == "abc123"
assert mcp_oauth._state_from_url("https://idp.example/authorize?client_id=x") is None
def test_deliver_rejects_mismatched_state_without_consuming_flow():
# A stray/forged loopback hit with the wrong state must NOT resolve or consume the
# pending future — the genuine redirect (correct state) still gets through.
async def run():
task = asyncio.create_task(mcp_oauth._wait_for_callback())
await asyncio.sleep(0)
mcp_oauth._expected_state = "good-state" # captured from the authorize URL
# Wrong state and missing state are both ignored, flow stays pending.
assert mcp_oauth.deliver_callback("evil", "bad-state") is False
assert mcp_oauth.deliver_callback("evil", None) is False
assert not task.done()
# The real browser redirect carries the matching state and resolves the flow.
assert mcp_oauth.deliver_callback("c0de", "good-state") is True
return await task
assert asyncio.run(run()) == ("c0de", "good-state")
# -- status surfacing over REST ---------------------------------------------------
+43
View File
@@ -98,3 +98,46 @@ def test_exec_uses_command_allowlist(tmp_path):
assert eng.evaluate("run_shell", {"command": "pytest -q"}, None).allowed
asked = eng.evaluate("run_shell", {"command": "rm -rf /"}, None)
assert not asked.allowed and asked.needs_user
@pytest.mark.parametrize(
"command",
[
"git status && rm -rf ~", # chaining
"git status; rm -rf ~", # sequencing
"git status | tee /tmp/x", # pipe
"git status || curl evil", # or-chain
"git status $(rm -rf ~)", # command substitution
"git status `rm -rf ~`", # backtick substitution
"git status > /etc/passwd", # redirection
"git status\nrm -rf ~", # newline-embedded second command
],
)
def test_allowlist_rejects_shell_operator_chaining(tmp_path, command):
# An allowlisted prefix must NOT auto-run a command that chains anything after it.
eng = PermissionEngine(workspace_root=tmp_path, allowed_commands=["git status"])
d = eng.evaluate("run_shell", {"command": command}, None)
assert not d.allowed and d.needs_user, command
def test_allowlist_prefix_is_argv_boundary(tmp_path):
eng = PermissionEngine(workspace_root=tmp_path, allowed_commands=["git status", "ls"])
# Exact and sub-argument extensions of the allowlisted argv are fine.
assert eng.evaluate("run_shell", {"command": "git status"}, None).allowed
assert eng.evaluate("run_shell", {"command": "git status -s"}, None).allowed
assert eng.evaluate("run_shell", {"command": "ls -la"}, None).allowed
# A different subcommand or a token that merely shares a prefix is NOT allowed.
assert eng.evaluate("run_shell", {"command": "git push"}, None).needs_user
assert eng.evaluate("run_shell", {"command": "lsof"}, None).needs_user
def test_interpreters_not_auto_allowed_by_default(tmp_path):
# The default allowlist must not auto-run interpreters (arbitrary code execution).
from coworker.config import DEFAULT_ALLOWED_COMMANDS
eng = PermissionEngine(
workspace_root=tmp_path, allowed_commands=list(DEFAULT_ALLOWED_COMMANDS)
)
for cmd in ("python3 -c 'import os'", "node -e 1", "npm run x", "npx foo"):
d = eng.evaluate("run_shell", {"command": cmd}, None)
assert not d.allowed and d.needs_user, cmd
+30
View File
@@ -323,6 +323,36 @@ def test_ws_simple_turn(tmp_path):
assert "turn_end" in types
def test_ws_rejects_oversized_message(tmp_path):
from coworker.server import app as app_mod
client = _client(tmp_path, [_text("should not run")])
with client.websocket_connect("/ws/session/big") as ws:
assert ws.receive_json()["type"] == "ready"
# Oversized text → single error frame, no turn runs.
ws.send_json(
{"type": "user_message", "text": "x" * (app_mod._MAX_MESSAGE_TEXT_CHARS + 1)}
)
evt = ws.receive_json()
assert evt["type"] == "error" and "too long" in evt["data"]["error"].lower()
# Too many attachments → error frame.
ws.send_json(
{
"type": "user_message",
"text": "hi",
"attachments": ["a"] * (app_mod._MAX_ATTACHMENTS + 1),
}
)
evt = ws.receive_json()
assert evt["type"] == "error" and "attachment" in evt["data"]["error"].lower()
# A normal message still works afterwards (the socket wasn't torn down).
ws.send_json({"type": "user_message", "text": "hello"})
assert "turn_done" in _drain(ws)
def test_ws_error_persists_notice_and_retry_reruns(tmp_path):
class FlakyProvider(ProviderClient):
def __init__(self):