diff --git a/plugins/README-saas-integrations.md b/plugins/README-saas-integrations.md index 3da5ab65c..8c1ce000a 100644 --- a/plugins/README-saas-integrations.md +++ b/plugins/README-saas-integrations.md @@ -9,8 +9,8 @@ contributing one `Provider`: | --- | --- | --- | | [`flyteplugins-github`](../github) | GitHub | HMAC-SHA256 (`X-Hub-Signature-256`) | | [`flyteplugins-slack`](../slack) | Slack Events API | HMAC-SHA256 with a replay window (`X-Slack-Signature`) | -| [`flyteplugins-linear`](../linear) | Linear | HMAC-SHA256 (`X-Linear-Signature`) | -| [`flyteplugins-clickup`](../clickup) | ClickUp | HMAC-SHA256 (`X-Clickup-Signature`) | +| [`flyteplugins-linear`](../linear) | Linear | HMAC-SHA256 (`Linear-Signature`) | +| [`flyteplugins-clickup`](../clickup) | ClickUp | HMAC-SHA256 (`X-Signature`) | | [`flyteplugins-jira`](../jira) | Jira Cloud | none — Jira does not sign; a shared token stands in | Install core plus the packages for the products you wire up — each row above is diff --git a/plugins/clickup/README.md b/plugins/clickup/README.md index 31b5ff158..0c89f43ae 100644 --- a/plugins/clickup/README.md +++ b/plugins/clickup/README.md @@ -62,7 +62,7 @@ dedupe key is stable. 2. Point ClickUp at `/webhook/clickup`, from Space Settings → Integrations → Webhooks (it shows the signing secret on creation). -**Verification:** HMAC-SHA256 over the raw body (`X-Clickup-Signature`). +**Verification:** HMAC-SHA256 over the raw body (`X-Signature`). The list id is at the top level on list-scoped events and on the nested task for task-scoped ones; the parser reads both. diff --git a/plugins/clickup/src/flyteplugins/clickup/__init__.py b/plugins/clickup/src/flyteplugins/clickup/__init__.py index b7af04ad0..3f9284118 100644 --- a/plugins/clickup/src/flyteplugins/clickup/__init__.py +++ b/plugins/clickup/src/flyteplugins/clickup/__init__.py @@ -16,7 +16,7 @@ def _sample_headers(body: bytes, secret: str) -> dict[str, str]: - return {"X-Clickup-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()} + return {"X-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()} #: A real `taskCreated` delivery, trimmed to the fields the parser reads. diff --git a/plugins/clickup/src/flyteplugins/clickup/_provider.py b/plugins/clickup/src/flyteplugins/clickup/_provider.py index 71f76b6e6..88f8e3599 100644 --- a/plugins/clickup/src/flyteplugins/clickup/_provider.py +++ b/plugins/clickup/src/flyteplugins/clickup/_provider.py @@ -15,8 +15,8 @@ def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool: - """Verify the `X-Clickup-Signature` HMAC over the raw body.""" - signature = lower_headers(headers).get("x-clickup-signature") + """Verify the `X-Signature` HMAC over the raw body.""" + signature = lower_headers(headers).get("x-signature") if not signature: return False return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip()) diff --git a/plugins/clickup/tests/test_clickup.py b/plugins/clickup/tests/test_clickup.py index 1d2a2110e..183bdc8a3 100644 --- a/plugins/clickup/tests/test_clickup.py +++ b/plugins/clickup/tests/test_clickup.py @@ -6,14 +6,14 @@ import hmac import json -from flyteplugins.clickup import events, parse +from flyteplugins.clickup import events, parse, verify SECRET = "clickup-secret" def _parse(payload: dict): body = json.dumps(payload).encode() - return parse({"X-Clickup-Signature": hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()}, body) + return parse({}, body) def test_the_event_name_is_the_qualified_type(): @@ -36,3 +36,19 @@ def update(timestamp: int): return _parse({"event": "taskUpdated", "task_id": "t1", "timestamp": timestamp}) assert update(1700000000000).dedupe_key() != update(1700000009999).dedupe_key() + + +def test_verify_reads_the_header_clickup_actually_sends(): + """ClickUp sends a bare `X-Signature`, not a product-prefixed name. + + See https://developer.clickup.com/docs/webhooksignature. + + Asserted on the literal wire name because nothing else can: the conformance + round trip signs `SAMPLE_DELIVERY` with whatever header this module reads, so + a wrong name verifies against itself while every genuine delivery 401s. + """ + body = b'{"event": "taskCreated", "task_id": "t1"}' + digest = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + + assert verify(body, {"X-Signature": digest}, SECRET) is True + assert verify(body, {"X-Clickup-Signature": digest}, SECRET) is False diff --git a/plugins/linear/README.md b/plugins/linear/README.md index fb517d275..d6af84a82 100644 --- a/plugins/linear/README.md +++ b/plugins/linear/README.md @@ -62,7 +62,7 @@ dedupe key is stable. 2. Point Linear at `/webhook/linear`, from Linear Settings → API → Webhooks (it shows the signing secret on creation). -**Verification:** HMAC-SHA256 over the raw body (`X-Linear-Signature`). +**Verification:** HMAC-SHA256 over the raw body (`Linear-Signature`). Comment and reaction payloads carry the team id only on the nested issue; the parser follows it, so a `scopes` allowlist can still attribute them. diff --git a/plugins/linear/src/flyteplugins/linear/__init__.py b/plugins/linear/src/flyteplugins/linear/__init__.py index 5c354565c..85330fc43 100644 --- a/plugins/linear/src/flyteplugins/linear/__init__.py +++ b/plugins/linear/src/flyteplugins/linear/__init__.py @@ -16,7 +16,7 @@ def _sample_headers(body: bytes, secret: str) -> dict[str, str]: - return {"X-Linear-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()} + return {"Linear-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()} #: A real `Issue.create` delivery, trimmed to the fields the parser reads. diff --git a/plugins/linear/src/flyteplugins/linear/_provider.py b/plugins/linear/src/flyteplugins/linear/_provider.py index dcff7f57a..9998b7fbc 100644 --- a/plugins/linear/src/flyteplugins/linear/_provider.py +++ b/plugins/linear/src/flyteplugins/linear/_provider.py @@ -15,8 +15,8 @@ def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool: - """Verify the `X-Linear-Signature` HMAC over the raw body.""" - signature = lower_headers(headers).get("x-linear-signature") + """Verify the `Linear-Signature` HMAC over the raw body.""" + signature = lower_headers(headers).get("linear-signature") if not signature: return False return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip()) diff --git a/plugins/linear/tests/test_linear.py b/plugins/linear/tests/test_linear.py index 2f51fb1fa..c53eee5d2 100644 --- a/plugins/linear/tests/test_linear.py +++ b/plugins/linear/tests/test_linear.py @@ -6,14 +6,14 @@ import hmac import json -from flyteplugins.linear import events, parse +from flyteplugins.linear import events, parse, verify SECRET = "linear-secret" def _parse(payload: dict): body = json.dumps(payload).encode() - return parse({"X-Linear-Signature": hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()}, body) + return parse({}, body) def test_entity_and_action_join_into_the_constant(): @@ -41,3 +41,19 @@ def test_the_team_id_is_found_nested_on_a_comment(): def test_a_payload_with_no_entity_timestamp_falls_back_to_the_delivery_time(): event = _parse({"action": "create", "type": "Issue", "createdAt": "2024-01-01T00:00:00Z", "data": {"id": "i1"}}) assert event.occurred_at == "2024-01-01T00:00:00Z" + + +def test_verify_reads_the_header_linear_actually_sends(): + """Linear's header has no `X-` prefix. + + See https://linear.app/developers/webhooks. + + Asserted on the literal wire name because nothing else can: the conformance + round trip signs `SAMPLE_DELIVERY` with whatever header this module reads, so + a wrong name verifies against itself while every genuine delivery 401s. + """ + body = b'{"action": "create", "type": "Issue", "data": {"id": "i1"}}' + digest = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + + assert verify(body, {"Linear-Signature": digest}, SECRET) is True + assert verify(body, {"X-Linear-Signature": digest}, SECRET) is False diff --git a/src/flyte/extras/webhooks/testing.py b/src/flyte/extras/webhooks/testing.py index 5e8eefcca..6d03a0294 100644 --- a/src/flyte/extras/webhooks/testing.py +++ b/src/flyte/extras/webhooks/testing.py @@ -183,9 +183,11 @@ def _assert_sample_delivery_conforms(plugin: typing.Any, provider: Provider, nam # Attacker-controlled headers must never raise. A non-ASCII credential is the # case that turns a clean 401 into a 500 when compared as str. + checked_a_credential = False for key, value in headers.items(): if key.lower() not in _CREDENTIAL_HEADERS: continue + checked_a_credential = True for hostile in _hostile_variants(value): replaced = {**headers, key: hostile} try: @@ -198,6 +200,15 @@ def _assert_sample_delivery_conforms(plugin: typing.Any, provider: Provider, nam ) from exc assert accepted is False, f"{name}: verify accepted {key}={hostile!r}" + # Without this the loop above degrades to a no-op the moment a provider's + # header is renamed: an unlisted name is skipped, not flagged, so the + # hostile-value check would silently stop running for that provider. + assert checked_a_credential, ( + f"{name}: no header in SAMPLE_DELIVERY is listed in _CREDENTIAL_HEADERS, so the " + f"hostile-credential check ran on nothing. Headers were {sorted(headers)}; add the " + "one carrying the credential to that set." + ) + event = provider.parse(headers, body) assert isinstance(event, WebhookEvent), f"{name}: parse must return a WebhookEvent" assert event.provider == provider.name, ( @@ -222,8 +233,8 @@ def _assert_sample_delivery_conforms(plugin: typing.Any, provider: Provider, nam { "x-hub-signature-256", "x-slack-signature", - "x-linear-signature", - "x-clickup-signature", + "linear-signature", + "x-signature", "x-webhook-token", } )