mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness check, so a captured request could be replayed indefinitely. Parse the signed webhookTimestamp (Unix ms) and reject requests outside a 60s window, failing closed when the field is missing or malformed — mirroring the Slack verifier.
This commit is contained in:
parent
9444fd7677
commit
3e56062c43
2 changed files with 82 additions and 3 deletions
|
|
@ -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:
|
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:
|
Args:
|
||||||
body: Raw request body bytes
|
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
|
secret: The webhook signing secret
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if signature is valid, False otherwise
|
True if the signature is valid AND the signed timestamp is fresh.
|
||||||
"""
|
"""
|
||||||
if not secret:
|
if not secret:
|
||||||
logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request")
|
logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
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")
|
@app.post("/webhooks/linear")
|
||||||
|
|
|
||||||
53
tests/test_linear_webhook_replay.py
Normal file
53
tests/test_linear_webhook_replay.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue