diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 34e2dfa2c5af..696f238f30cb 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -356,6 +356,8 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": # Check event type filter event_type = ( request.headers.get("X-GitHub-Event", "") + or request.headers.get("X-Gitea-Event", "") + or request.headers.get("X-Forgejo-Event", "") or request.headers.get("X-GitLab-Event", "") or payload.get("event_type", "") or "unknown" @@ -372,6 +374,47 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": {"status": "ignored", "event": event_type} ) + # Denylist filters: skip noisy event types and senders without dispatch. + ignored_event_types = { + e.lower() for e in route_config.get("ignored_event_types", []) + } + if ignored_event_types and event_type.lower() in ignored_event_types: + logger.debug( + "[webhook] filtered route=%s event=%s reason=event_type_denied", + route_name, + event_type, + ) + return web.json_response( + { + "filtered": True, + "reason": "event_type_denied", + "event_type": event_type, + } + ) + + ignored_senders = { + s.lower() for s in route_config.get("ignored_senders", []) + } + if ignored_senders: + sender_login = "" + sender = payload.get("sender") if isinstance(payload, dict) else None + if isinstance(sender, dict): + sender_login = (sender.get("login") or "").lower() + if sender_login and sender_login in ignored_senders: + logger.debug( + "[webhook] filtered route=%s event=%s sender=%s reason=sender_denied", + route_name, + event_type, + sender_login, + ) + return web.json_response( + { + "filtered": True, + "reason": "sender_denied", + "sender": sender_login, + } + ) + # Format prompt from template prompt_template = route_config.get("prompt", "") prompt = self._render_prompt( diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index bedf254a15d4..490b72b3eb28 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -758,3 +758,229 @@ async def test_no_thread_id_sends_no_metadata(self): mock_target.send.assert_awaited_once_with( "12345", "hello", metadata=None ) + + +# =================================================================== +# Sender / event-type denylist filters (Forgejo/Gitea/GitHub firehose) +# =================================================================== + + +class TestDenylistFilters: + """Tests for `ignored_senders` and `ignored_event_types` per-route config.""" + + @pytest.mark.asyncio + async def test_ignored_sender_returns_200_filtered(self): + """sender.login matching ignored_senders returns 200 filtered, no dispatch.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_senders": ["dependabot"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"action": "opened", "sender": {"login": "dependabot"}}, + headers={"X-Forgejo-Event": "pull_request"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + assert data["reason"] == "sender_denied" + assert data["sender"] == "dependabot" + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_ignored_event_type_returns_200_filtered(self): + """event type matching ignored_event_types returns 200 filtered, no dispatch.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_event_types": ["status"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"sender": {"login": "alice"}}, + headers={"X-Forgejo-Event": "status"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + assert data["reason"] == "event_type_denied" + assert data["event_type"] == "status" + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_sender_match_is_case_insensitive(self): + """ignored_senders comparison is case-insensitive in both directions.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_senders": ["Dependabot"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"sender": {"login": "DEPENDABOT"}}, + headers={"X-Forgejo-Event": "push"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + assert data["reason"] == "sender_denied" + + @pytest.mark.asyncio + async def test_event_type_match_is_case_insensitive(self): + """ignored_event_types comparison is case-insensitive.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_event_types": ["STATUS"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"sender": {"login": "alice"}}, + headers={"X-Forgejo-Event": "status"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + assert data["reason"] == "event_type_denied" + + @pytest.mark.asyncio + async def test_missing_sender_does_not_filter(self): + """A payload with no sender field is dispatched (defensive default).""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_senders": ["dependabot"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"action": "opened"}, # no sender at all + headers={"X-Forgejo-Event": "pull_request"}, + ) + assert resp.status == 202 + + @pytest.mark.asyncio + async def test_allowlist_takes_precedence_over_denylist(self): + """If event is excluded by `events` allowlist, response uses the existing + 'ignored' shape, not the new 'filtered' shape.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "events": ["pull_request"], # only PR events allowed + "ignored_event_types": ["push"], # also denylisted, but allowlist hits first + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"sender": {"login": "alice"}}, + headers={"X-Forgejo-Event": "push"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data.get("status") == "ignored" + assert "filtered" not in data + + @pytest.mark.asyncio + async def test_forgejo_event_header_detected(self): + """X-Forgejo-Event header is recognised for event-type filtering.""" + routes = { + "fj": { + "secret": _INSECURE_NO_AUTH, + "ignored_event_types": ["status"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/fj", + json={"sender": {"login": "alice"}}, + headers={"X-Forgejo-Event": "status"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + + @pytest.mark.asyncio + async def test_gitea_event_header_detected(self): + """X-Gitea-Event header is recognised for event-type filtering.""" + routes = { + "gt": { + "secret": _INSECURE_NO_AUTH, + "ignored_event_types": ["status"], + "prompt": "x", + } + } + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/gt", + json={"sender": {"login": "alice"}}, + headers={"X-Gitea-Event": "status"}, + ) + assert resp.status == 200 + data = await resp.json() + assert data["filtered"] is True + + @pytest.mark.asyncio + async def test_no_filter_config_is_backward_compatible(self): + """Routes with neither denylist key behave exactly as before.""" + routes = {"plain": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}} + adapter = _make_adapter(routes=routes) + adapter.handle_message = AsyncMock() + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/webhooks/plain", + json={"sender": {"login": "dependabot"}}, + headers={"X-GitHub-Event": "push"}, + ) + assert resp.status == 202 diff --git a/website/docs/user-guide/messaging/webhooks.md b/website/docs/user-guide/messaging/webhooks.md index 24b582a160d8..f8a22a8e52f1 100644 --- a/website/docs/user-guide/messaging/webhooks.md +++ b/website/docs/user-guide/messaging/webhooks.md @@ -78,7 +78,9 @@ Routes define how different webhook sources are handled. Each route is a named e | Property | Required | Description | |----------|----------|-------------| -| `events` | No | List of event types to accept (e.g. `["pull_request"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-GitLab-Event`, or `event_type` in the payload. | +| `events` | No | Allowlist of event types to accept (e.g. `["pull_request"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-Gitea-Event`, `X-Forgejo-Event`, `X-GitLab-Event`, or `event_type` in the payload. | +| `ignored_event_types` | No | Denylist of event types to drop without dispatching the agent (e.g. `["status", "push"]`). Case-insensitive. Applied after the `events` allowlist. Useful for filtering noise from a system-wide Forgejo/Gitea/GitHub firehose. | +| `ignored_senders` | No | Denylist of `sender.login` values to drop without dispatching the agent (e.g. `["dependabot", "mirror-sync-bot"]`). Case-insensitive exact match. Payloads without a `sender.login` are not filtered. | | `secret` | **Yes** | HMAC secret for signature validation. Falls back to the global `secret` if not set on the route. Set to `"INSECURE_NO_AUTH"` for testing only (skips validation). | | `prompt` | No | Template string with dot-notation payload access (e.g. `{pull_request.title}`). If omitted, the full JSON payload is dumped into the prompt. | | `skills` | No | List of skill names to load for the agent run. | @@ -86,6 +88,8 @@ Routes define how different webhook sources are handled. Each route is a named e | `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. | | `deliver_only` | No | If `true`, skip the agent entirely — the rendered `prompt` template becomes the literal message that gets delivered. Zero LLM cost, sub-second delivery. See [Direct Delivery Mode](#direct-delivery-mode) for use cases. Requires `deliver` to be a real target (not `log`). | +Filtered events (denylist match) return HTTP `200` with `{"filtered": true, "reason": "...", ...}` so the source treats them as successful and won't retry. + ### Full example ```yaml