mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 23:03:22 +00:00
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:
@@ -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 ---------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user