import http.server
import json
import threading
import pytest
from tempus_ddb import github_executor, http_executor
from tempus_ddb.github_executor import GitHubActionAdapter
from tempus_ddb.http_executor import HttpActionAdapter
from tempus_ddb.slack_executor import SlackActionAdapter, UrllibSlackTransport
REDIRECT_CODES = [301, 302, 303, 307, 308]
class _Server:
def __init__(self, status=200, body=b"{}", headers=None):
self.status, self.body, self.extra = status, body, headers or {}
self.requests = []
outer = self
class Handler(http.server.BaseHTTPRequestHandler):
def _serve(self):
length = int(self.headers.get("Content-Length") or 0)
if length:
self.rfile.read(length)
outer.requests.append(
{
"method": self.command,
"path": self.path,
"authorization": self.headers.get("Authorization"),
}
)
self.send_response(outer.status)
for key, value in outer.extra.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(outer.body)))
self.end_headers()
self.wfile.write(outer.body)
do_GET = do_POST = do_PUT = _serve
def log_message(self, *args):
pass
self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler)
self.port = self.httpd.server_port
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
@property
def url(self):
return f"http://127.0.0.1:{self.port}"
def close(self):
self.httpd.shutdown()
self.httpd.server_close()
@pytest.fixture
def servers():
created = []
def make(**kwargs):
server = _Server(**kwargs)
created.append(server)
return server
yield make
for server in created:
server.close()
@pytest.fixture(autouse=True)
def loopback_http(monkeypatch):
monkeypatch.setattr(http_executor, "_validate_https_url", lambda url, *a, **k: url)
monkeypatch.setattr(
github_executor, "_validate_github_api_url", lambda url: url.strip().rstrip("/")
)
def _redirecting_pair(servers, code, secret_in_location=False):
other = servers(body=b'{"leaked": true}')
location = other.url + "/stolen"
if secret_in_location:
location += "?token=LOCATION-SECRET-123"
origin = servers(status=code, headers={"Location": location})
return origin, other
def _http_intent(url):
return {"action_type": "http.post", "resource": url, "input": {"a": 1}}
def _http_adapter():
return HttpActionAdapter(
auth_header="Bearer HTTP-SECRET-TOKEN", allowed_domains={"127.0.0.1"}
)
@pytest.mark.parametrize("code", REDIRECT_CODES)
def test_http_executor_refuses_redirects(servers, code):
origin, other = _redirecting_pair(servers, code, secret_in_location=True)
result = _http_adapter().execute_action(_http_intent(origin.url + "/hook"))
assert other.requests == [], "redirect was followed to the second host"
assert result.status == "FAILED"
assert result.payload["http_status"] == code
text = json.dumps(result.payload)
assert "HTTP-SECRET-TOKEN" not in text and "LOCATION-SECRET-123" not in text
assert origin.requests[0]["authorization"] == "Bearer HTTP-SECRET-TOKEN"
assert other.requests == []
def test_http_executor_normal_flow_still_works(servers):
origin = servers(body=b'{"ok": true}')
result = _http_adapter().execute_action(_http_intent(origin.url + "/hook"))
assert result.status == "SUCCEEDED"
assert result.payload == {"ok": True}
assert origin.requests[0]["authorization"] == "Bearer HTTP-SECRET-TOKEN"
def _github_intent():
return {
"action_type": "github.create_issue",
"resource": "acme/widget",
"input": {"title": "t", "body": "b"},
}
@pytest.mark.parametrize("code", REDIRECT_CODES)
def test_github_executor_refuses_redirects(servers, code):
origin, other = _redirecting_pair(servers, code, secret_in_location=True)
adapter = GitHubActionAdapter(token="GH-SECRET-TOKEN", api_url=origin.url)
result = adapter.execute_action(_github_intent())
assert other.requests == [], "redirect was followed to the second host"
assert result.status == "FAILED"
assert result.payload["error_code"] == f"GITHUB_HTTP_{code}"
text = json.dumps(result.payload)
assert "GH-SECRET-TOKEN" not in text and "LOCATION-SECRET-123" not in text
assert origin.requests[0]["authorization"] == "Bearer GH-SECRET-TOKEN"
assert other.requests == []
def test_github_executor_normal_flow_still_works(servers):
issue = {
"id": 7,
"number": 3,
"html_url": "https://github.test/i/3",
"state": "open",
}
origin = servers(status=201, body=json.dumps(issue).encode())
adapter = GitHubActionAdapter(token="GH-SECRET-TOKEN", api_url=origin.url)
result = adapter.execute_action(_github_intent())
assert result.status == "SUCCEEDED"
assert result.payload["number"] == 3
assert origin.requests[0]["path"] == "/repos/acme/widget/issues"
assert origin.requests[0]["authorization"] == "Bearer GH-SECRET-TOKEN"
def _slack_intent():
return {
"action_type": "slack.post_message",
"resource": "C123",
"input": {"text": "hello"},
}
def _slack_adapter(monkeypatch, origin):
monkeypatch.setattr(UrllibSlackTransport, "API_BASE", origin.url + "/api/")
return SlackActionAdapter(token="xoxb-SLACK-SECRET-TOKEN")
@pytest.mark.parametrize("code", REDIRECT_CODES)
def test_slack_executor_refuses_redirects(servers, monkeypatch, code):
origin, other = _redirecting_pair(servers, code, secret_in_location=True)
result = _slack_adapter(monkeypatch, origin).execute_action(_slack_intent())
assert other.requests == [], "redirect was followed to the second host"
assert result.status == "FAILED"
text = json.dumps(result.payload)
assert str(code) in text
assert "SLACK-SECRET-TOKEN" not in text and "LOCATION-SECRET-123" not in text
assert origin.requests[0]["authorization"] == "Bearer xoxb-SLACK-SECRET-TOKEN"
assert other.requests == []
def test_slack_executor_normal_flow_still_works(servers, monkeypatch):
reply = {"ok": True, "ts": "1.2", "message": {"ts": "1.2"}}
origin = servers(body=json.dumps(reply).encode())
result = _slack_adapter(monkeypatch, origin).execute_action(_slack_intent())
assert result.status == "SUCCEEDED"
assert result.payload["ts"] == "1.2"
assert origin.requests[0]["path"] == "/api/chat.postMessage"
def test_redirect_error_carries_only_a_sanitized_host(servers):
from urllib import request
from tempus_ddb._http import RedirectRefused, open_request
origin = servers(
status=302,
headers={"Location": "https://user:pw@evil.example:8443/p?secret=1#frag"},
)
with pytest.raises(RedirectRefused) as caught:
open_request(request.Request(origin.url + "/x"), timeout=5)
message = str(caught.value)
assert caught.value.target_host == "evil.example"
assert "secret" not in message and "pw" not in message and "/p" not in message