diff --git a/skills/x-twitter/SKILL.md b/skills/x-twitter/SKILL.md index ebf7f3f65..c3d373265 100644 --- a/skills/x-twitter/SKILL.md +++ b/skills/x-twitter/SKILL.md @@ -32,9 +32,13 @@ python3 skills/x-twitter/x-post.py engagement 2040817066199195818 ## Setup -1. Go to https://developer.x.com and sign in -2. Create a Project + App -3. Generate keys and add to `.env`: +1. Install Python dependencies (one-time): + ``` + pip3 install requests requests-oauthlib + ``` +2. Go to https://developer.x.com and sign in +3. Create a Project + App +4. Generate keys and add to `.env`: ``` X_API_KEY=... X_API_SECRET=... diff --git a/skills/x-twitter/x-post.py b/skills/x-twitter/x-post.py index d002a0d61..2891d278d 100644 --- a/skills/x-twitter/x-post.py +++ b/skills/x-twitter/x-post.py @@ -37,10 +37,11 @@ def _require_requests(): import requests as _requests from requests_oauthlib import OAuth1 as _OAuth1 except ImportError: - print("Installing required packages...") - os.system("pip3 install --break-system-packages requests requests-oauthlib") - import requests as _requests - from requests_oauthlib import OAuth1 as _OAuth1 + sys.exit( + "x-post: missing dependencies. Install them with:\n" + " pip3 install requests requests-oauthlib\n" + "or add them to your project's requirements." + ) requests = _requests OAuth1 = _OAuth1 diff --git a/src/agent-api.py b/src/agent-api.py index f12b5f3c3..e0c5bb4e2 100644 --- a/src/agent-api.py +++ b/src/agent-api.py @@ -51,11 +51,13 @@ def _safe_id(raw: str) -> str: def validate_twilio_signature(handler, body: str) -> bool: - """Validate X-Twilio-Signature if TWILIO_AUTH_TOKEN is configured. - Returns True if valid or if token not configured (local dev).""" + """Validate X-Twilio-Signature against TWILIO_AUTH_TOKEN. + Fails closed — returns False when the token is not configured so that + unauthenticated requests cannot create tasks via the /twilio/* endpoints. + TWILIO_AUTH_TOKEN must be set in .env for these endpoints to accept webhooks.""" auth_token = os.environ.get("TWILIO_AUTH_TOKEN", "") if not auth_token: - return True + return False import hmac, hashlib, base64 from urllib.parse import parse_qs diff --git a/tests/agent-api-twilio-auth.test.py b/tests/agent-api-twilio-auth.test.py new file mode 100644 index 000000000..f90c76229 --- /dev/null +++ b/tests/agent-api-twilio-auth.test.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Security regression: validate_twilio_signature must fail CLOSED when +TWILIO_AUTH_TOKEN is not set. + +Finding #2 from the 2026-07-06 source-code security audit: the original +implementation returned True (accept all) when auth_token was empty, +allowing unauthenticated Twilio webhook requests to create tasks in the +agent. + +The fix: return False when TWILIO_AUTH_TOKEN is unset so that /twilio/* +endpoints always reject unauthenticated requests. Operators must set the +token in .env for Twilio webhooks to work. + +Run: python3 tests/agent-api-twilio-auth.test.py +Exit: 0 on pass, 1 on fail. +""" +from __future__ import annotations + +import importlib.util +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +REPO = Path(__file__).resolve().parent.parent + + +def _load_agent_api() -> object: + spec = importlib.util.spec_from_file_location("agent_api", REPO / "src" / "agent-api.py") + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +class TwilioAuthTests(unittest.TestCase): + def setUp(self): + # Ensure no token in env for the fail-closed tests. + self._orig = os.environ.pop("TWILIO_AUTH_TOKEN", None) + self.mod = _load_agent_api() + + def tearDown(self): + if self._orig is not None: + os.environ["TWILIO_AUTH_TOKEN"] = self._orig + else: + os.environ.pop("TWILIO_AUTH_TOKEN", None) + + def _make_handler(self, headers: dict | None = None) -> MagicMock: + h = MagicMock() + h.headers = {**(headers or {})} + h.path = "/twilio/voice" + return h + + def test_no_token_rejects_request(self): + """TWILIO_AUTH_TOKEN unset → validate_twilio_signature returns False (fail closed).""" + os.environ.pop("TWILIO_AUTH_TOKEN", None) + handler = self._make_handler() + result = self.mod.validate_twilio_signature(handler, "CallSid=CA123&To=%2B1555") + self.assertFalse(result, "Must reject when TWILIO_AUTH_TOKEN is not configured") + + def test_empty_token_rejects_request(self): + """TWILIO_AUTH_TOKEN='' (explicitly empty) → rejects.""" + os.environ["TWILIO_AUTH_TOKEN"] = "" + handler = self._make_handler() + result = self.mod.validate_twilio_signature(handler, "CallSid=CA123") + self.assertFalse(result) + + def test_valid_token_with_missing_signature_rejects(self): + """Token configured but X-Twilio-Signature header absent → rejects.""" + os.environ["TWILIO_AUTH_TOKEN"] = "test_token_abc" + handler = self._make_handler({"Host": "example.com"}) + result = self.mod.validate_twilio_signature(handler, "CallSid=CA123") + self.assertFalse(result) + + def test_valid_token_with_wrong_signature_rejects(self): + """Token configured but signature doesn't match → rejects.""" + os.environ["TWILIO_AUTH_TOKEN"] = "test_token_abc" + handler = self._make_handler({ + "Host": "example.com", + "X-Forwarded-Proto": "https", + "X-Twilio-Signature": "badsig", + }) + os.environ["TWILIO_WEBHOOK_URL"] = "https://example.com" + try: + result = self.mod.validate_twilio_signature(handler, "CallSid=CA123") + finally: + os.environ.pop("TWILIO_WEBHOOK_URL", None) + self.assertFalse(result) + + +if __name__ == "__main__": + result = unittest.main(exit=False).result + sys.exit(0 if result.wasSuccessful() else 1)