diff --git a/agent/webapp.py b/agent/webapp.py index c7909c6a..ece49d8b 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -1339,8 +1339,32 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st ) +LINEAR_WEBHOOK_MAX_AGE_SECONDS = 60 + + +def _linear_timestamp_is_fresh(body: bytes) -> bool: + """Reject replays: the signed payload's ``webhookTimestamp`` must be recent. + + Linear includes ``webhookTimestamp`` (Unix milliseconds) inside the signed + body. Fail closed when it is missing or malformed. + """ + try: + ts_ms = json.loads(body)["webhookTimestamp"] + except (json.JSONDecodeError, KeyError, TypeError): + logger.warning("Linear webhook missing/invalid webhookTimestamp — rejecting") + return False + if not isinstance(ts_ms, (int, float)) or isinstance(ts_ms, bool): + logger.warning("Linear webhook webhookTimestamp is not numeric — rejecting") + return False + now_ms = datetime.now(UTC).timestamp() * 1000 + if abs(now_ms - ts_ms) > LINEAR_WEBHOOK_MAX_AGE_SECONDS * 1000: + logger.warning("Linear webhook timestamp outside freshness window — rejecting") + return False + return True + + def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool: - """Verify the Linear webhook signature. + """Verify the Linear webhook signature and replay-freshness window. Args: body: Raw request body bytes @@ -1348,15 +1372,17 @@ def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool: secret: The webhook signing secret Returns: - True if signature is valid, False otherwise + True if the signature is valid AND the signed timestamp is fresh. """ if not secret: logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request") return False expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, signature): + return False - return hmac.compare_digest(expected, signature) + return _linear_timestamp_is_fresh(body) @app.post("/webhooks/linear") diff --git a/tests/test_linear_webhook_replay.py b/tests/test_linear_webhook_replay.py new file mode 100644 index 00000000..c0e4295c --- /dev/null +++ b/tests/test_linear_webhook_replay.py @@ -0,0 +1,53 @@ +"""Replay-window enforcement for Linear webhook signature verification (AUTHZ-001).""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from datetime import UTC, datetime + +from agent import webapp + +_SECRET = "linear-signing-secret" + + +def _sign(body: bytes) -> str: + return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() + + +def _now_ms() -> int: + return int(datetime.now(UTC).timestamp() * 1000) + + +def test_fresh_timestamp_accepted() -> None: + body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode() + assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is True + + +def test_stale_timestamp_rejected() -> None: + stale = _now_ms() - 10 * 60 * 1000 # 10 minutes old + body = json.dumps({"type": "Comment", "webhookTimestamp": stale}).encode() + # Signature is valid, but the timestamp is outside the freshness window. + assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False + + +def test_future_timestamp_rejected() -> None: + future = _now_ms() + 10 * 60 * 1000 + body = json.dumps({"type": "Comment", "webhookTimestamp": future}).encode() + assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False + + +def test_missing_timestamp_rejected() -> None: + body = json.dumps({"type": "Comment"}).encode() + assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False + + +def test_non_numeric_timestamp_rejected() -> None: + body = json.dumps({"type": "Comment", "webhookTimestamp": "not-a-number"}).encode() + assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False + + +def test_bad_signature_rejected_even_when_fresh() -> None: + body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode() + assert webapp.verify_linear_signature(body, "deadbeef", _SECRET) is False