Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/README-saas-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/clickup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dedupe key is stable.
2. Point ClickUp at `<app-url>/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.

Expand Down
2 changes: 1 addition & 1 deletion plugins/clickup/src/flyteplugins/clickup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions plugins/clickup/src/flyteplugins/clickup/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
20 changes: 18 additions & 2 deletions plugins/clickup/tests/test_clickup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
2 changes: 1 addition & 1 deletion plugins/linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ dedupe key is stable.
2. Point Linear at `<app-url>/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.

Expand Down
2 changes: 1 addition & 1 deletion plugins/linear/src/flyteplugins/linear/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions plugins/linear/src/flyteplugins/linear/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
20 changes: 18 additions & 2 deletions plugins/linear/tests/test_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
15 changes: 13 additions & 2 deletions src/flyte/extras/webhooks/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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, (
Expand All @@ -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",
}
)
Expand Down
Loading