mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).
Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.
Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Atlassian Connect qsh vectors + JWT verification (auth boundary).
|
|
|
|
qsh correctness is a silent-auth-bypass surface, so the official Atlassian test
|
|
vector and the three endpoint vectors are pinned here. The JWT tests exercise
|
|
alg-pinning, signature, exp, issuer binding, and qsh binding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
import jwt
|
|
import pytest
|
|
|
|
from agent.utils import atlassian_connect as ac
|
|
|
|
_SECRET = "connect-shared-secret"
|
|
|
|
|
|
def _request(method: str, path: str, query: str = "", *, token: str | None = None) -> object:
|
|
headers = {"Authorization": f"JWT {token}"} if token else {}
|
|
return SimpleNamespace(
|
|
method=method,
|
|
url=SimpleNamespace(path=path, query=query),
|
|
headers=headers,
|
|
query_params={},
|
|
)
|
|
|
|
|
|
def _make_token(
|
|
*,
|
|
secret: str = _SECRET,
|
|
iss: str = "tenant-1",
|
|
alg: str = "HS256",
|
|
exp_delta: int = 180,
|
|
qsh: str | None = "auto",
|
|
method: str = "POST",
|
|
path: str = "/connect/webhook/comment-created",
|
|
query: str = "",
|
|
drop_exp: bool = False,
|
|
) -> str:
|
|
claims: dict = {"iss": iss}
|
|
if not drop_exp:
|
|
claims["exp"] = int(time.time()) + exp_delta
|
|
if qsh == "auto":
|
|
claims["qsh"] = ac.compute_qsh(method, path, query)
|
|
elif qsh is not None:
|
|
claims["qsh"] = qsh
|
|
return jwt.encode(claims, secret, algorithm=alg)
|
|
|
|
|
|
# --- qsh vectors -----------------------------------------------------------
|
|
|
|
|
|
def test_official_atlassian_qsh_vector() -> None:
|
|
canon = ac.canonical_request(
|
|
"GET",
|
|
"/path/to/service",
|
|
"zee_last=param&repeated=parameter 1&first=param&repeated=parameter 2",
|
|
)
|
|
assert canon == (
|
|
"GET&/path/to/service&first=param&repeated=parameter%201,parameter%202&zee_last=param"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "expected"),
|
|
[
|
|
("/connect/installed", "72c0a77bd4d709a202e9b2561ed003fdb400318f7a1cfabe47576d1e1d5b5dd7"),
|
|
(
|
|
"/connect/uninstalled",
|
|
"ef0c0673ed4cf59a823d82cdc5c397c8643d79db724ce7d567342ea15e02acfe",
|
|
),
|
|
(
|
|
"/connect/webhook/comment-created",
|
|
"72e058a8906e894732718ec80dbbbf073640341b9ac6ed7cd86f887f23a00b4d",
|
|
),
|
|
],
|
|
)
|
|
def test_endpoint_qsh_vectors(path: str, expected: str) -> None:
|
|
assert ac.compute_qsh("POST", path, "") == expected
|
|
|
|
|
|
def test_qsh_drops_jwt_param_and_encodes_space_not_plus() -> None:
|
|
# jwt param is excluded; space must be %20 (never +).
|
|
with_jwt = ac.compute_qsh("GET", "/x", "a=b c&jwt=zzz")
|
|
without = ac.compute_qsh("GET", "/x", "a=b c")
|
|
assert with_jwt == without
|
|
assert "%20" in ac.canonical_request("GET", "/x", "a=b c")
|
|
assert "+" not in ac.canonical_request("GET", "/x", "a=b c")
|
|
|
|
|
|
# --- JWT verification ------------------------------------------------------
|
|
|
|
|
|
def test_valid_token_accepted() -> None:
|
|
token = _make_token()
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
claims = ac.verify_connect_jwt(req, shared_secret=_SECRET)
|
|
assert claims is not None
|
|
assert claims["iss"] == "tenant-1"
|
|
|
|
|
|
def test_missing_token_rejected() -> None:
|
|
req = _request("POST", "/connect/webhook/comment-created")
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
def test_alg_none_rejected() -> None:
|
|
token = jwt.encode({"iss": "t", "exp": int(time.time()) + 60}, "", algorithm="none")
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
def test_wrong_secret_rejected() -> None:
|
|
token = _make_token(secret="attacker-secret")
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
def test_expired_token_rejected() -> None:
|
|
token = _make_token(exp_delta=-3600)
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
def test_missing_secret_fails_closed() -> None:
|
|
token = _make_token()
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=None) is None
|
|
|
|
|
|
def test_issuer_binding_mismatch_rejected() -> None:
|
|
token = _make_token(iss="tenant-1")
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, expected_client_key="tenant-2") is None
|
|
|
|
|
|
def test_qsh_mismatch_rejected_cross_endpoint_replay() -> None:
|
|
# Token signed with the qsh for /installed, replayed at the webhook endpoint.
|
|
token = _make_token(path="/connect/installed")
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
def test_missing_qsh_rejected_when_required() -> None:
|
|
token = _make_token(qsh=None)
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=True) is None
|
|
|
|
|
|
def test_missing_qsh_allowed_on_lifecycle_when_not_required() -> None:
|
|
token = _make_token(qsh=None)
|
|
req = _request("POST", "/connect/installed", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=False) is not None
|
|
|
|
|
|
def test_context_qsh_rejected() -> None:
|
|
token = _make_token(qsh="context-qsh")
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET, qsh_required=False) is None
|
|
|
|
|
|
def test_missing_exp_rejected() -> None:
|
|
token = _make_token(drop_exp=True)
|
|
req = _request("POST", "/connect/webhook/comment-created", token=token)
|
|
assert ac.verify_connect_jwt(req, shared_secret=_SECRET) is None
|
|
|
|
|
|
# --- baseUrl host allowlist (first-install gate) ---------------------------
|
|
|
|
|
|
def test_base_url_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
ac, "CONNECT_EXPECTED_BASE_URL_HOSTS", frozenset({"seahaven.atlassian.net"})
|
|
)
|
|
assert ac.base_url_host_allowed("https://seahaven.atlassian.net/wiki") is True
|
|
assert ac.base_url_host_allowed("https://evil.example.com/wiki") is False
|
|
|
|
|
|
def test_base_url_allowlist_empty_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ac, "CONNECT_EXPECTED_BASE_URL_HOSTS", frozenset())
|
|
assert ac.base_url_host_allowed("https://seahaven.atlassian.net/wiki") is False
|
|
|
|
|
|
# --- signed-install (asymmetric RS256) lifecycle verification ---------------
|
|
|
|
_AUD = "https://openswe.example.com"
|
|
|
|
|
|
def _rsa_keypair() -> tuple[str, str]:
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
priv = key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
pub = (
|
|
key.public_key()
|
|
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
.decode()
|
|
)
|
|
return priv, pub
|
|
|
|
|
|
def _install_token(priv_pem: str, *, iss="tenant-1", aud=_AUD, exp_delta=180) -> str:
|
|
return jwt.encode(
|
|
{"iss": iss, "aud": aud, "exp": int(time.time()) + exp_delta},
|
|
priv_pem,
|
|
algorithm="RS256",
|
|
headers={"kid": "install-key-1"},
|
|
)
|
|
|
|
|
|
def _install_req(token: str) -> object:
|
|
return SimpleNamespace(
|
|
method="POST",
|
|
url=SimpleNamespace(path="/connect/installed", query=""),
|
|
headers={"Authorization": f"JWT {token}"},
|
|
query_params={},
|
|
)
|
|
|
|
|
|
def _run_install_verify(token, pub_pem, monkeypatch, *, expected_client_key=None):
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
monkeypatch.setattr(ac, "CONNECT_BASE_URL", _AUD)
|
|
with patch.object(ac, "_fetch_atlassian_public_key", new=AsyncMock(return_value=pub_pem)):
|
|
return asyncio.run(
|
|
ac.verify_asymmetric_install_jwt(
|
|
_install_req(token), expected_client_key=expected_client_key
|
|
)
|
|
)
|
|
|
|
|
|
def test_signed_install_valid_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, pub = _rsa_keypair()
|
|
claims = _run_install_verify(_install_token(priv), pub, monkeypatch)
|
|
assert claims is not None and claims["iss"] == "tenant-1"
|
|
|
|
|
|
def test_signed_install_hs256_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# An HS256 token (symmetric alg-confusion) must not pass asymmetric verify.
|
|
_priv, pub = _rsa_keypair()
|
|
hs = jwt.encode({"iss": "t", "aud": _AUD, "exp": int(time.time()) + 60}, "x", algorithm="HS256")
|
|
assert _run_install_verify(hs, pub, monkeypatch) is None
|
|
|
|
|
|
def test_signed_install_wrong_audience_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, pub = _rsa_keypair()
|
|
token = _install_token(priv, aud="https://some-other-app.example.com")
|
|
assert _run_install_verify(token, pub, monkeypatch) is None
|
|
|
|
|
|
def test_signed_install_wrong_key_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, _pub = _rsa_keypair()
|
|
_priv2, pub2 = _rsa_keypair()
|
|
assert _run_install_verify(_install_token(priv), pub2, monkeypatch) is None
|
|
|
|
|
|
def test_signed_install_expired_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, pub = _rsa_keypair()
|
|
assert _run_install_verify(_install_token(priv, exp_delta=-3600), pub, monkeypatch) is None
|
|
|
|
|
|
def test_signed_install_issuer_binding(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, pub = _rsa_keypair()
|
|
token = _install_token(priv, iss="tenant-1")
|
|
assert _run_install_verify(token, pub, monkeypatch, expected_client_key="tenant-2") is None
|
|
|
|
|
|
def test_signed_install_key_fetch_failure_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
priv, _pub = _rsa_keypair()
|
|
assert _run_install_verify(_install_token(priv), None, monkeypatch) is None
|
|
|
|
|
|
async def test_fetch_public_key_rejects_malformed_kid() -> None:
|
|
# Defense-in-depth: a kid with path/URL chars is rejected before any fetch.
|
|
for bad in ["../../evil", "a/b", "http://evil.com", "a b", ""]:
|
|
assert await ac._fetch_atlassian_public_key(bad) is None
|
|
|
|
|
|
def test_client_key_allowed_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset())
|
|
assert ac.client_key_allowed("anything") is False
|
|
monkeypatch.setattr(ac, "CONNECT_EXPECTED_CLIENT_KEYS", frozenset({"ours"}))
|
|
assert ac.client_key_allowed("ours") is True
|
|
assert ac.client_key_allowed("attacker") is False
|
|
assert ac.client_key_allowed("") is False
|
|
|
|
|
|
def test_signed_install_no_base_url_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
priv, pub = _rsa_keypair()
|
|
monkeypatch.setattr(ac, "CONNECT_BASE_URL", "")
|
|
with patch.object(ac, "_fetch_atlassian_public_key", new=AsyncMock(return_value=pub)):
|
|
result = asyncio.run(ac.verify_asymmetric_install_jwt(_install_req(_install_token(priv))))
|
|
assert result is None
|