fix: replace IS_SELF_MANAGED with WEBHOOK_ALLOWED_IPS allowlist#8884
Conversation
… allowlist Instead of blanket-allowing all private IPs on self-managed deployments, webhook URL validation now blocks all private/internal IPs by default and only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env variable (comma-separated IPs/CIDRs).
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughCentralizes webhook URL security by adding a shared Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant WebhookSerializer
participant validate_url
participant DNS as DNS_Resolver
participant DB as Webhook_DB
participant Task as Webhook_Task
participant HTTP as External_Endpoint
Client->>WebhookSerializer: create/update webhook (url)
WebhookSerializer->>validate_url: _validate_webhook_url(url, allowed_ips)
validate_url->>DNS_Resolver: resolve hostname
DNS_Resolver-->>validate_url: IP(s)
validate_url-->>WebhookSerializer: ok / raise ValidationError
WebhookSerializer->>Webhook_DB: save Webhook
Webhook_DB-->>Task: enqueue/send event
Task->>validate_url: validate_url(url, allowed_ips) (re-check before send)
validate_url->>DNS_Resolver: resolve hostname
validate_url-->>Task: ok / raise ValueError (abort)
Task->>HTTP: requests.post(url, payload)
HTTP-->>Task: response
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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.
Pull request overview
This PR tightens SSRF protections for webhook endpoints by blocking private/internal IP resolution by default and introducing an explicit WEBHOOK_ALLOWED_IPS allowlist for self-managed deployments. It centralizes URL-to-IP validation in plane.utils.ip_address.validate_url and updates the webhook serializer to reuse it, with unit tests covering the allowlist behavior.
Changes:
- Add shared
validate_urlutility to block private/internal IP targets unless explicitly allowlisted. - Introduce
WEBHOOK_ALLOWED_IPSsetting parsed from an env var intoip_networkentries. - Refactor webhook serializer SSRF checks to use the shared validator and add allowlist unit tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| apps/api/plane/utils/ip_address.py | Adds shared validate_url SSRF/allowlist validator used by webhook URL validation. |
| apps/api/plane/settings/common.py | Parses WEBHOOK_ALLOWED_IPS from env into a network allowlist for SSRF exceptions. |
| apps/api/plane/app/serializers/webhook.py | Deduplicates SSRF checks by calling validate_url and normalizes domain comparisons. |
| apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py | Adds unit tests verifying allowlist behavior for private/loopback addresses. |
| def _validate_webhook_url(self, url): | ||
| """Validate a webhook URL against SSRF and disallowed domain rules.""" | ||
| try: | ||
| ip_addresses = socket.getaddrinfo(hostname, None) | ||
| except socket.gaierror: | ||
| raise serializers.ValidationError({"url": "Hostname could not be resolved."}) | ||
|
|
||
| if not ip_addresses: | ||
| raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) | ||
| validate_url(url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS) | ||
| except ValueError as e: | ||
| raise serializers.ValidationError({"url": str(e)}) | ||
|
|
There was a problem hiding this comment.
The webhook URL is validated only at create/update time, but webhook_send_task later posts to webhook.url without re-validating the resolved IP and (by default) will follow redirects. This leaves a DNS-rebinding/redirect path where a URL that was safe when saved can resolve/redirect to a private/internal IP at send time. Consider validating the target immediately before each send (using the same validate_url(..., allowed_ips=settings.WEBHOOK_ALLOWED_IPS) logic) and disabling redirects or validating each redirect hop.
|
|
||
|
|
There was a problem hiding this comment.
The new allowlist tests don’t cover the mixed IPv4/IPv6 allowlist case (e.g., allowed_ips contains an IPv6 CIDR while the hostname resolves to an IPv4 address). Today this can surface as a TypeError during membership checks. Add a unit test that passes mixed-version networks and asserts validation still works (and blocks/permits correctly) without crashing.
| def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self): | |
| allowed = [ | |
| ipaddress.ip_network("2001:db8::/32"), | |
| ipaddress.ip_network("192.168.1.0/24"), | |
| ] | |
| with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns: | |
| mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))] | |
| validate_url("http://example.com", allowed_ips=allowed) # Should not raise | |
| def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self): | |
| allowed = [ | |
| ipaddress.ip_network("2001:db8::/32"), | |
| ipaddress.ip_network("192.168.1.0/24"), | |
| ] | |
| with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns: | |
| mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))] | |
| with pytest.raises(ValueError, match="private/internal"): | |
| validate_url("http://example.com", allowed_ips=allowed) |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/api/plane/app/serializers/webhook.py`:
- Around line 28-29: The except block currently re-raises
serializers.ValidationError with str(e) which can leak internals; instead log
the original exception server-side (e.g., using logger.exception or an error
reporting client) and raise a fixed, client-safe message like
serializers.ValidationError({"url": "Invalid URL"}) or similar user-facing text;
update the except ValueError as e handler (the block that currently raises
serializers.ValidationError({"url": str(e)})) to perform logging of e and return
the sanitized ValidationError to the client.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4ab8cf65-b83d-4364-919f-f60c6bad2fed
📒 Files selected for processing (4)
apps/api/plane/app/serializers/webhook.pyapps/api/plane/settings/common.pyapps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.pyapps/api/plane/utils/ip_address.py
- Sanitize error messages to avoid leaking internal details to clients - Guard against TypeError with mixed IPv4/IPv6 allowlist networks - Re-validate webhook URL at send time to prevent DNS-rebinding - Add unit tests for mixed-version IP network allowlists
…plane#8884) * fix: replace IS_SELF_MANAGED toggle with explicit WEBHOOK_ALLOWED_IPS allowlist Instead of blanket-allowing all private IPs on self-managed deployments, webhook URL validation now blocks all private/internal IPs by default and only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env variable (comma-separated IPs/CIDRs). * fix: address PR review comments for webhook SSRF protection - Sanitize error messages to avoid leaking internal details to clients - Guard against TypeError with mixed IPv4/IPv6 allowlist networks - Re-validate webhook URL at send time to prevent DNS-rebinding - Add unit tests for mixed-version IP network allowlists
* [WEB-6784] feat scrollbar in shortcuts modal (makeplane#8872) * fix: update border for project timezone * feat: added scrollbar in keyboard shortcuts modal * fix: remove unnecessary changes * fix: remove redundant overflow * [WEB-6785] fix: update border for project timezone (makeplane#8870) * chore: remove Intercom integration and chat support components (makeplane#8875) Intercom is no longer used. This removes all related frontend components, hooks, custom events, API config, types, and i18n keys. * chore: update dependencies (Django, cryptography, axios, lodash) (makeplane#8880) * chore: update dependencies (Django, cryptography, axios, lodash) - Django 4.2.29 → 4.2.30 - cryptography 46.0.6 → 46.0.7 - axios 1.13.5 → 1.15.0 - lodash 4.17.23 → 4.18.0 * chore: update lodash from 4.18.0 to 4.18.1 * [WEB-6840] feat: skip role & use-case steps for self-hosted instances (makeplane#8890) * chore(deps): bump pytest (makeplane#8891) Bumps the pip group with 1 update in the /apps/api/requirements directory: [pytest](https://github.com/pytest-dev/pytest). Updates `pytest` from 9.0.2 to 9.0.3 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](pytest-dev/pytest@9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * enhance sub-issue query performance with optimized annotations and subqueries (makeplane#8889) * fix: enforce workspace membership on V2 asset endpoints (makeplane#8885) WorkspaceFileAssetEndpoint had no authorization checks beyond authentication, allowing any logged-in user to create, read, patch, and delete assets in any workspace by slug. DuplicateAssetEndpoint only authorized the destination workspace, letting users copy assets from workspaces they don't belong to. Add @allow_permission decorators to all WorkspaceFileAssetEndpoint methods and scope DuplicateAssetEndpoint's source asset lookup to workspaces where the caller is an active member. Ref: GHSA-qw87-v5w3-6vxx * fix: replace IS_SELF_MANAGED with WEBHOOK_ALLOWED_IPS allowlist (makeplane#8884) * fix: replace IS_SELF_MANAGED toggle with explicit WEBHOOK_ALLOWED_IPS allowlist Instead of blanket-allowing all private IPs on self-managed deployments, webhook URL validation now blocks all private/internal IPs by default and only permits specific networks listed in the WEBHOOK_ALLOWED_IPS env variable (comma-separated IPs/CIDRs). * fix: address PR review comments for webhook SSRF protection - Sanitize error messages to avoid leaking internal details to clients - Guard against TypeError with mixed IPv4/IPv6 allowlist networks - Re-validate webhook URL at send time to prevent DNS-rebinding - Add unit tests for mixed-version IP network allowlists * [SILO-1158] chore: add context for project in relations API (makeplane#8860) * add context for project in relations API * modify issue relation serializer * fix: sanitize filenames in upload paths to prevent path traversal (makeplane#8879) * fix: sanitize filenames in upload paths to prevent path traversal (GHSA-v57h-5999-w7xp) Add server-side filename sanitization across all file upload endpoints to prevent path traversal sequences (../) in user-supplied filenames from being incorporated into S3 object keys. While S3 keys are flat strings and not vulnerable to filesystem traversal, this adds defense-in-depth and prevents S3 key pollution. Changes: - Add sanitize_filename() utility in path_validator.py - Sanitize filenames in get_upload_path() for FileAsset and IssueAttachment models - Sanitize name parameter in all upload view endpoints * fix: address PR review feedback on filename sanitization - Remove unused `import re` - Normalize backslashes to forward slashes before os.path.basename() so Windows-style paths (e.g. ..\..\..\evil.txt) are handled on POSIX - Strip whitespace before removing leading dots so " .env" is caught - Return None instead of "unnamed" for empty input so existing `if not name` validation guards remain effective - Add `or "unnamed"` fallback at call sites that lack a name guard * fix: use random hex name as fallback in get_upload_path instead of "unnamed" * fix: resolve ruff E501 line too long in DuplicateAssetEndpoint * chore(ci): suppress CodeQL file coverage deprecation warning (makeplane#8916) * chore(ci): suppress CodeQL file coverage deprecation warning Explicitly opt into the new default behavior where CodeQL skips computing file coverage information on pull requests for improved analysis performance. * Update .github/workflows/codeql.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: update CODEOWNERS for apps and deployments (makeplane#8919) * chore: update CODEOWNERS for apps and deployments Assign owners per app/area so reviews are routed to the right maintainers. * chore: update the codeowners * chore: add Claude Code skills for PR descriptions and release notes (makeplane#8920) * chore: add Claude Code skills for PR descriptions and release notes * chore(skills): update release-notes branches to canary->master and example version to v1.3.0 * chore(skills): address PR review comments - pr-description: infer base branch from PR metadata, fix Improvement wording, reference template's screenshot placeholder verbatim - release-notes: add `text` language to unlabeled fenced code block * chore: bump up the package version * chore(deps): bump lxml (makeplane#8925) Bumps the pip group with 1 update in the /apps/api/requirements directory: [lxml](https://github.com/lxml/lxml). Updates `lxml` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](lxml/lxml@lxml-6.0.0...lxml-6.1.0) --- updated-dependencies: - dependency-name: lxml dependency-version: 6.1.0 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump axios, uuid and add security overrides (makeplane#8930) * chore(deps): bump axios, uuid and add security overrides Bump axios 1.15.0 → 1.15.2 and uuid 13.0.0 → 14.0.0 in the catalog, and add pnpm overrides pinning postcss >=8.5.10, follow-redirects >=1.16.0, and routing axios/uuid through the catalog. * fix: overrides * fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets (makeplane#9078) * fix: add WEBHOOK_ALLOWED_HOSTS allowlist for internal webhook targets The IP-based allowlist alone isn't practical for containerised deployments where service IPs are dynamic. Adds a hostname-based bypass for trusted internal services (e.g. Silo via docker-compose / k8s service DNS) and makes the previously hardcoded ["plane.so"] domain blocklist configurable via WEBHOOK_DISALLOWED_DOMAINS. - validate_url accepts allowed_hosts (exact, case-insensitive match; skips DNS lookup for trusted names) - WebhookSerializer wires both settings through and lets allowlisted hosts bypass the disallowed-domain check - Exposes WEBHOOK_ALLOWED_HOSTS in aio/cli deployment env files * fix: default WEBHOOK_DISALLOWED_DOMAINS to empty for self-hosted * fix: pass WEBHOOK_ALLOWED_HOSTS to send-time webhook re-validation * fix: pnpm path for Docker builds (makeplane#9079) Add $PNPM_HOME/bin to PATH so corepack-installed pnpm binaries are resolvable during Docker builds. * fix(brand): replace upstream Plane logos and copy with Plane Plus in onboarding screens - not-ready-view.tsx: swap gradient-logo.webp (3D Plane diamond) → EyrieHQ icon; "Welcome to Plane" → "Welcome to Plane Plus" - tour/root.tsx: "Welcome to Plane, {name}" → "Welcome to Plane Plus, {name}"; copy updated to match - admin/instance-not-ready.tsx: drop PlaneTakeOffImage → EyrieHQ icon; "Welcome aboard Plane!" → "Welcome aboard Plane Plus!" - admin/new-user-popup.tsx: drop TakeoffIcon SVGs + unused theme imports → EyrieHQ icon; "Welcome to Plane instance portal" → "Welcome to Plane Plus" --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: b-saikrishnakanth <130811169+b-saikrishnakanth@users.noreply.github.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Phạm Nguyên Phương <69796528+PhuongPN6689@users.noreply.github.com> Co-authored-by: Saurabh Kumar <70131915+Saurabhkmr98@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: EyrieHQ <eyriehq@eyriehq.com> Co-authored-by: Surya <surya@eyriehq.com>
Summary
IS_SELF_MANAGEDflag-based private IP toggle with an explicitWEBHOOK_ALLOWED_IPSenv variable (comma-separated IPs/CIDRs)10.0.0.0/8,192.168.1.0/24) instead of blanket-allowing all private IPsvalidate_urlfunction inip_address.pyand deduplicates inline SSRF checks in the webhook serializerTest plan
WEBHOOK_ALLOWED_IPS=10.0.0.0/8to allow internal webhook targetsSummary by CodeRabbit
New Features
Improvements
Tests