feat(api): manage workspace webhooks via the public token API#9398
feat(api): manage workspace webhooks via the public token API#9398clwluvw wants to merge 3 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (17)
🚧 Files skipped from review as they are similar to previous changes (13)
📝 WalkthroughWalkthroughAdds workspace webhook CRUD endpoints, shared SSRF and domain validation, owner authorization, secret-key response filtering, development-only private URL controls, delivery integration, routing, and contract tests. ChangesWorkspace webhook management
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant WebhookAPIEndpoint
participant WebhookSerializer
participant validate_webhook_url
participant Webhook
Client->>WebhookAPIEndpoint: Submit workspace webhook request
WebhookAPIEndpoint->>WebhookSerializer: Validate request data
WebhookSerializer->>validate_webhook_url: Validate URL and request host
validate_webhook_url-->>WebhookSerializer: Accept or reject URL
WebhookSerializer->>Webhook: Create or update webhook
Webhook-->>WebhookAPIEndpoint: Return persisted webhook
WebhookAPIEndpoint-->>Client: Return serialized response
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
apps/api/plane/tests/contract/api/test_webhooks.py (1)
90-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGood create coverage — consider adding a test for
is_internal/versionprotection.The create tests thoroughly cover secret handling, SSRF, scheme validation, conflicts, and authorization. However, there's no test asserting that
is_internalandversionare read-only on create. Given thatsecret_keyis tested for read-only enforcement (line 116-123),is_internalandversiondeserve the same coverage — especially since they're currently writable (see the major issue flagged on the view).🧪 Suggested test
+ `@pytest.mark.django_db` + def test_create_ignores_is_internal_and_version(self, api_key_client, workspace, webhook_data): + """is_internal and version are server-managed, not client-settable.""" + payload = {**webhook_data, "is_internal": True, "version": "v2"} + response = api_key_client.post(_webhooks_url(workspace.slug), payload, format="json") + assert response.status_code == status.HTTP_201_CREATED + webhook = Webhook.objects.get(id=response.data["id"]) + assert webhook.is_internal is False + assert webhook.version == "v1"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/tests/contract/api/test_webhooks.py` around lines 90 - 159, Add a create test to TestWebhookCreateAPIEndpoint verifying that client-supplied is_internal and version values are ignored and the response/persisted webhook retains server-controlled defaults. Follow the existing test_create_ignores_client_supplied_secret pattern and assert both fields are protected.apps/api/plane/api/views/webhook.py (1)
104-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOperation ID
list_webhooksis used for both list and retrieve.The
getmethod handles both list (nopk) and retrieve (withpk) operations, but the@extend_schemaoperation_id is"list_webhooks"for both. drf-spectacular may auto-suffix duplicates, but the naming is misleading and could cause issues with OpenAPI client generators that expect unique operation IDs.Consider splitting into two operations or using a more generic operation_id like
list_or_retrieve_webhooks.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/api/views/webhook.py` around lines 104 - 127, Update the `get` endpoint schema so its `operation_id` accurately represents both the list and retrieve branches, or split the schema definitions to assign distinct operation IDs for each operation. Ensure the generated OpenAPI document uses unique, semantically correct IDs without changing the existing response behavior.apps/api/plane/utils/webhook.py (1)
56-65: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winDisallowed-domain / allowed-host matching is case-sensitive against raw config.
hostnameis normalized to lowercase (Line 50), but the comparisons againstsettings.WEBHOOK_ALLOWED_HOSTS(Line 56) andsettings.WEBHOOK_DISALLOWED_DOMAINS(Line 64) use the raw config values. If an operator configures a disallowed domain with any uppercase (e.g.Internal.Corp), it will silently fail to matchinternal.corpand the block is bypassed. Normalizing the config values makes the guard robust to casing.🛡️ Suggested normalization
- if hostname in settings.WEBHOOK_ALLOWED_HOSTS: + allowed_hosts = {h.rstrip(".").lower() for h in settings.WEBHOOK_ALLOWED_HOSTS} + if hostname in allowed_hosts: return - disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS) + disallowed_domains = [d.rstrip(".").lower() for d in settings.WEBHOOK_DISALLOWED_DOMAINS] if request: request_host = request.get_host().split(":")[0].rstrip(".").lower() disallowed_domains.append(request_host)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/utils/webhook.py` around lines 56 - 65, Normalize WEBHOOK_ALLOWED_HOSTS and WEBHOOK_DISALLOWED_DOMAINS entries to lowercase before comparing them with the already-normalized hostname in the webhook validation logic. Preserve the existing exact-host and subdomain matching behavior while ensuring mixed-case configuration values are enforced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/api/plane/api/views/webhook.py`:
- Around line 104-127: Update the `get` endpoint schema so its `operation_id`
accurately represents both the list and retrieve branches, or split the schema
definitions to assign distinct operation IDs for each operation. Ensure the
generated OpenAPI document uses unique, semantically correct IDs without
changing the existing response behavior.
In `@apps/api/plane/tests/contract/api/test_webhooks.py`:
- Around line 90-159: Add a create test to TestWebhookCreateAPIEndpoint
verifying that client-supplied is_internal and version values are ignored and
the response/persisted webhook retains server-controlled defaults. Follow the
existing test_create_ignores_client_supplied_secret pattern and assert both
fields are protected.
In `@apps/api/plane/utils/webhook.py`:
- Around line 56-65: Normalize WEBHOOK_ALLOWED_HOSTS and
WEBHOOK_DISALLOWED_DOMAINS entries to lowercase before comparing them with the
already-normalized hostname in the webhook validation logic. Preserve the
existing exact-host and subdomain matching behavior while ensuring mixed-case
configuration values are enforced.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bde45a2d-257f-4f65-ac93-e6b17e981214
📒 Files selected for processing (9)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/webhook.pyapps/api/plane/api/urls/__init__.pyapps/api/plane/api/urls/webhook.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/webhook.pyapps/api/plane/app/serializers/webhook.pyapps/api/plane/tests/contract/api/test_webhooks.pyapps/api/plane/utils/webhook.py
There was a problem hiding this comment.
Pull request overview
Adds public token-API (X-Api-Key) endpoints to manage workspace webhooks under /api/v1/workspaces/{slug}/webhooks/, mirroring the internal app API behavior while centralizing webhook URL SSRF/disallowed-domain validation in a shared utility.
Changes:
- Introduces token-API CRUD endpoints for workspace webhooks (create/list/retrieve/update/delete), with secret_key returned only on create.
- Extracts webhook URL validation (SSRF + disallowed-domain + loop-back guard) into
plane.utils.webhook.validate_webhook_urland reuses it from both app API and token API serializers. - Adds contract tests covering CRUD behavior, SSRF rejection, secret_key visibility rules, and delivery signature correctness.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/api/plane/utils/webhook.py | New shared webhook URL validation helper used by both API surfaces. |
| apps/api/plane/tests/contract/api/test_webhooks.py | New contract tests for token-API webhook CRUD + secret/signature guarantees. |
| apps/api/plane/app/serializers/webhook.py | Refactors internal app serializer to call the shared validation helper. |
| apps/api/plane/api/views/webhook.py | Adds token-API webhook endpoint implementation + OpenAPI annotations. |
| apps/api/plane/api/views/init.py | Exposes the new webhook API view from the token-API views package. |
| apps/api/plane/api/urls/webhook.py | Adds URL routes for list/create and detail operations. |
| apps/api/plane/api/urls/init.py | Registers webhook URL patterns into the token-API URL set. |
| apps/api/plane/api/serializers/webhook.py | Adds token-API webhook serializer (incl. shared URL validation). |
| apps/api/plane/api/serializers/init.py | Exports the new token-API webhook serializer. |
14dbb17 to
e34e886
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
apps/api/plane/api/views/webhook.py:150
- OpenAPI schema for the retrieve response uses
WebhookSerializer, which includessecret_keyin its declared fields, but the endpoint filters it out at runtime withfields=WEBHOOK_READ_FIELDS. The schema should reflect the actual response shape so clients don’t assumesecret_keyis returned.
responses={
200: OpenApiResponse(description="Webhook", response=WebhookSerializer),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,
},
apps/api/plane/api/views/webhook.py:175
- OpenAPI schema for the update response uses
WebhookSerializer, which includessecret_key, but the PATCH handler filters the serializer fields toWEBHOOK_READ_FIELDS(excludingsecret_key). Update the schema response serializer to match the filtered response fields.
responses={
200: OpenApiResponse(description="Webhook updated", response=WebhookSerializer),
400: OpenApiResponse(description="Invalid or disallowed webhook URL"),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,
409: CONFLICT_RESPONSE,
},
Add GET/POST/PATCH/DELETE /api/v1/workspaces/{slug}/webhooks/ to the
public (X-Api-Key) token API, mirroring the internal app-API webhook
endpoint so webhooks can be provisioned programmatically instead of only
through the UI.
- New WebhookAPIEndpoint (workspace-admin only, via WorkspaceOwnerPermission)
reusing the shared Webhook model.
- New public WebhookSerializer: generates secret_key server-side (returned
only on create, withheld on list/retrieve), accepts url + the entity
toggles (issue, issue_comment, cycle, module, project), and enforces the
schema/domain validators plus the SSRF / WEBHOOK_ALLOWED_IPS guard.
- Extract the SSRF/disallowed-domain webhook-URL validation into a shared
plane.utils.webhook.validate_webhook_url helper used by BOTH the app and
public serializers, so the guard can never drift between the two surfaces.
The app serializer keeps its _validate_webhook_url adapter (behaviour
unchanged) and delegates to the shared helper.
- Generic HMAC delivery (bgtasks/webhook_task.py) is unchanged:
X-Plane-Signature stays HMAC-SHA256 over the JSON payload with secret_key.
- Contract tests: create returns a usable server-generated secret, the
secret is withheld on reads, the SSRF/non-http guards reject
loopback/private/scheme-invalid targets, duplicate URLs 409, management is
admin-only, and a real delivery signs the payload correctly with the
API-issued secret.
Signed-off-by: Seena Fallah <seenafallah@gmail.com>
…private targets Provisioning webhooks against a local docker-compose Plane is impossible today because the SSRF guard rejects private/loopback targets — a webhook pointing at http://host.docker.internal:<port>/ (an engine on the docker host) is blocked at both registration and delivery. Add a deliberately narrow, default-off instance flag that permits such targets for local development only: - New WEBHOOK_ALLOW_PRIVATE_URLS setting (settings/common.py), parsed with the house `== "1"` idiom, default off, documented with a production warning next to the other webhook SSRF controls. - Thread an `allow_private` flag through the shared guard so BOTH the app and public token APIs pick it up in one place: - registration: validate_webhook_url -> validate_url (allow_private skips only the private-IP block via require_safe=not allow_private). - delivery: webhook_send_task -> pinned_fetch -> _fetch_validated_hop (require_safe = not (trusted or allow_private)). - The flag ONLY lifts the private-IP block: scheme (http/https) and URL well-formedness are still enforced, and delivery is still pinned to the resolved IP (no DNS-rebinding regression). - Default-off is byte-identical to prior behavior on every path. - Hardening: pinned_fetch_following_redirects (OAuth avatar / link unfurling) explicitly drops any allow_private so the redirect path can never inherit the escape hatch. - Docs: dev-only commented-out example + production warning in the aio and cli community variables.env; wire the var through the cli docker-compose x-app-env anchor so the documented flag actually reaches the container. - Tests: default rejects a private URL, flag permits the same URL, non-http and malformed URLs rejected regardless of the flag, delivery skips the block but still pins, and the redirect path never inherits the flag. Signed-off-by: Seena Fallah <seenafallah@gmail.com>
…n reads The list/retrieve/update responses were annotated with WebhookSerializer, whose declared fields include the signing `secret_key`, while the handlers filtered it out at runtime via `fields=WEBHOOK_READ_FIELDS`. The published schema therefore contradicted the documented guarantee that the secret is returned only once, on create — all three read operations resolved to the `Webhook` component, which declares `secret_key`. Replace the runtime field filtering with a dedicated read serializer so the response and the schema are generated from the same class and cannot drift: - Add WebhookLiteSerializer, whose field list is derived from WebhookSerializer minus `secret_key`, making the exclusion structural rather than a hand-maintained parallel list. - Use it for the list, retrieve and update responses (runtime + schema) and drop the now-unused WEBHOOK_READ_FIELDS tuple. Create is unchanged and still returns the secret exactly once. - Update PATCH to validate/save with the full serializer (it carries the URL schema, domain and SSRF guards) and render the response with the lite one. - Tests: assert the update response hides the secret, that a client-supplied secret is ignored on update, and that the read shape is exactly the full shape minus `secret_key`. Generated schema now resolves list/retrieve/update to a WebhookLite component with no `secret_key`; create still resolves to Webhook. Signed-off-by: Seena Fallah <seenafallah@gmail.com>
be99b37 to
ca5c320
Compare
Two issues surfaced only once the upstream PRs were combined: - PR makeplane#9401's app webhook test patched plane.app.serializers.webhook.validate_url, the SSRF seam that existed when it was written. PR makeplane#9398 replaced that helper with the shared validate_webhook_url, so the patch target no longer resolved and both tests errored at setup. Point the mock at the current seam. - PR makeplane#9401 and PR makeplane#9470 each added the page OpenAPI parameters and examples to plane/utils/openapi/__init__.py. Merging both left PAGE_ID_PARAMETER, PAGE_TYPE_PARAMETER, PAGE_CREATE_EXAMPLE, PAGE_UPDATE_EXAMPLE and PAGE_EXAMPLE imported twice (ruff F811). Drop the duplicate imports; each symbol is still exported once.
Description
Add GET/POST/PATCH/DELETE /api/v1/workspaces/{slug}/webhooks/ to the public (X-Api-Key) token API, mirroring the internal app-API webhook endpoint so webhooks can be provisioned programmatically instead of only through the UI.
Type of Change
Summary by CodeRabbit
X-Plane-Signature.WEBHOOK_ALLOW_PRIVATE_URLS=1escape hatch for private/loopback webhook targets (not for production).