Skip to content

feat: allow private/loopback webhook URLs for self-hosted instances#8837

Open
prue-starfield wants to merge 1 commit intomakeplane:previewfrom
prue-starfield:fix/allow-private-webhook-urls
Open

feat: allow private/loopback webhook URLs for self-hosted instances#8837
prue-starfield wants to merge 1 commit intomakeplane:previewfrom
prue-starfield:fix/allow-private-webhook-urls

Conversation

@prue-starfield
Copy link
Copy Markdown

@prue-starfield prue-starfield commented Mar 31, 2026

Summary

Add PLANE_ALLOW_PRIVATE_WEBHOOKS environment variable that bypasses IP validation for webhook URLs when set to 1, true, or yes. Disabled by default.

Problem

Self-hosted Plane instances commonly need webhooks pointing to local services (notification relays, CI runners, internal APIs) but the SSRF protection blocks all private/loopback/reserved/link-local IPs unconditionally.

This was raised in #5248 and affects anyone running Plane behind a reverse proxy with internal webhook receivers.

Solution

Gated bypass via environment variable PLANE_ALLOW_PRIVATE_WEBHOOKS. When enabled, the private IP check in WebhookSerializer is skipped for both create and update paths.

  • Default: disabled (no change for cloud or security-conscious deployments)
  • Self-hosted operators opt in explicitly via env var
  • Zero impact on existing behavior when not set

Changes

  • apps/api/plane/app/serializers/webhook.py: Read PLANE_ALLOW_PRIVATE_WEBHOOKS env var; skip IP validation when enabled

Testing

  • Verified webhook creation with private IP succeeds when env var is set
  • Verified webhook creation with private IP still fails when env var is unset/0
  • Running in production on self-hosted v1.2.3 instance

Summary by CodeRabbit

  • New Features

    • Environment toggle to allow private and loopback webhook targets (more flexible webhook destinations).
  • Behavior

    • When enabled, a runtime warning is emitted to draw attention to the change.
    • Domain/subdomain blocking for internal hosts remains enforced in all cases.
  • Quality

    • Webhook URL validation centralized and unified across creation and update flows for consistent checks.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 31, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2af08c8f-73e4-48d2-a33c-b810d215484f

📥 Commits

Reviewing files that changed from the base of the PR and between 2e3799e and bbea154.

📒 Files selected for processing (1)
  • apps/api/plane/app/serializers/webhook.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/plane/app/serializers/webhook.py

📝 Walkthrough

Walkthrough

Introduced environment-driven ALLOW_PRIVATE_WEBHOOKS and a module-level warning; centralized webhook URL verification into _validate_webhook_url(url, request) which normalizes hostnames, resolves IPs, blocks private/loopback/reserved/link-local IPs unless the flag is enabled, and always blocks disallowed domains/subdomains (including plane.so and the request host). Field validator updated to _validate_domain_for_webhook.

Changes

Cohort / File(s) Summary
Webhook serializer and helpers
apps/api/plane/app/serializers/webhook.py
Added ALLOW_PRIVATE_WEBHOOKS (from PLANE_ALLOW_PRIVATE_WEBHOOKS) and module-level warning; added _validate_webhook_url(url, request) to parse/normalize host, resolve IPs, and enforce IP-based blocking (skipped when flag true) plus domain/subdomain blocking (always enforced); added _validate_domain_for_webhook(value) to conditionally skip validate_domain; replaced validate_domain with _validate_domain_for_webhook in WebhookSerializer.url validators; routed create() and update() through _validate_webhook_url; added os and logging imports.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant Serializer as WebhookSerializer
participant Request
participant DNS as DNSResolver
participant Response
Client->>Serializer: submit webhook URL + Request
Serializer->>Serializer: _validate_domain_for_webhook(value)
Serializer->>Serializer: _validate_webhook_url(url, Request)
Serializer->>DNS: resolve hostname -> IPs
DNS-->>Serializer: IP list
alt ALLOW_PRIVATE_WEBHOOKS = false
Serializer->>Serializer: block private/loopback/reserved/link-local IPs
else ALLOW_PRIVATE_WEBHOOKS = true
Serializer->>Serializer: skip IP-level blocking (log warning)
end
Serializer->>Serializer: enforce disallowed domains/subdomains (plane.so, request host)
Serializer-->>Response: validation result (accept/reject)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I sniff the URL with careful hops,
A flag can open secret private stops,
Domains stay fenced, the rules I prop,
I bounce, I check, then give a gentle chop. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main change: introducing support for private/loopback webhook URLs for self-hosted instances.
Description check ✅ Passed The description provides comprehensive coverage of problem, solution, changes, and testing, but lacks explicit matching to the repository's PR template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/api/plane/app/serializers/webhook.py (2)

14-15: Implementation is correct; ensure operators understand the security trade-off.

The env var parsing is sound. However, since webhook_task.py performs no independent IP validation at delivery time, enabling this flag completely removes SSRF protection for webhooks. This is the intended behavior for self-hosted instances, but consider:

  1. Adding a startup log warning when this flag is enabled to increase operator awareness
  2. Documenting this flag's security implications in deployment documentation
💡 Optional: Add startup warning when flag is enabled
 # Allow private/loopback webhook URLs for self-hosted instances
 ALLOW_PRIVATE_WEBHOOKS = os.environ.get("PLANE_ALLOW_PRIVATE_WEBHOOKS", "0").lower() in ("1", "true", "yes")
+
+if ALLOW_PRIVATE_WEBHOOKS:
+    import logging
+    logging.getLogger(__name__).warning(
+        "PLANE_ALLOW_PRIVATE_WEBHOOKS is enabled - webhooks can target private/internal IPs. "
+        "Only enable this in trusted self-hosted environments."
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/app/serializers/webhook.py` around lines 14 - 15, Add an
explicit startup warning when ALLOW_PRIVATE_WEBHOOKS is enabled: detect the
ALLOW_PRIVATE_WEBHOOKS flag at application startup (where configuration is
initialized), and emit a clear, high-visibility log message (e.g., warning
level) explaining that enabling ALLOW_PRIVATE_WEBHOOKS disables SSRF protection
for webhooks and pointing to webhook_task.py for delivery behavior; also
add/update deployment documentation to describe the security trade-offs and
recommended usage for self-hosted instances.

79-83: Consider extracting duplicated URL validation logic.

The IP validation logic (lines 79-83) is identical to lines 43-47 in create(). The entire URL validation block (hostname extraction, DNS resolution, IP checks, domain checks) is duplicated between both methods.

♻️ Proposed refactor to reduce duplication
+def _validate_webhook_url(url: str, request) -> None:
+    """Validate webhook URL for IP restrictions and disallowed domains."""
+    hostname = urlparse(url).hostname
+    if not hostname:
+        raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
+
+    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."})
+
+    if not ALLOW_PRIVATE_WEBHOOKS:
+        for addr in ip_addresses:
+            ip = ipaddress.ip_address(addr[4][0])
+            if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
+                raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
+
+    disallowed_domains = ["plane.so"]
+    if request:
+        request_host = request.get_host().split(":")[0]
+        disallowed_domains.append(request_host)
+
+    if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
+        raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
+

 class WebhookSerializer(DynamicBaseSerializer):
     url = serializers.URLField(validators=[validate_schema, validate_domain])

     def create(self, validated_data):
         url = validated_data.get("url", None)
-        # ... duplicated validation code ...
+        _validate_webhook_url(url, self.context.get("request"))
         return Webhook.objects.create(**validated_data)

     def update(self, instance, validated_data):
         url = validated_data.get("url", None)
         if url:
-            # ... duplicated validation code ...
+            _validate_webhook_url(url, self.context.get("request"))
         return super().update(instance, validated_data)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/app/serializers/webhook.py` around lines 79 - 83, Extract the
duplicated URL validation block used in create() and the other webhook method
into a single helper function (e.g., _validate_webhook_url or
validate_webhook_url) inside the serializer module; move hostname extraction,
DNS resolution, IP parsing, the ALLOW_PRIVATE_WEBHOOKS conditional, and the
ip.is_private/is_loopback/is_reserved/is_link_local checks into that helper so
it raises serializers.ValidationError on failure, then call this helper from
both create() and the other method to preserve existing behavior and flags
(including ALLOW_PRIVATE_WEBHOOKS) without changing logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/plane/app/serializers/webhook.py`:
- Around line 14-15: Add an explicit startup warning when ALLOW_PRIVATE_WEBHOOKS
is enabled: detect the ALLOW_PRIVATE_WEBHOOKS flag at application startup (where
configuration is initialized), and emit a clear, high-visibility log message
(e.g., warning level) explaining that enabling ALLOW_PRIVATE_WEBHOOKS disables
SSRF protection for webhooks and pointing to webhook_task.py for delivery
behavior; also add/update deployment documentation to describe the security
trade-offs and recommended usage for self-hosted instances.
- Around line 79-83: Extract the duplicated URL validation block used in
create() and the other webhook method into a single helper function (e.g.,
_validate_webhook_url or validate_webhook_url) inside the serializer module;
move hostname extraction, DNS resolution, IP parsing, the ALLOW_PRIVATE_WEBHOOKS
conditional, and the ip.is_private/is_loopback/is_reserved/is_link_local checks
into that helper so it raises serializers.ValidationError on failure, then call
this helper from both create() and the other method to preserve existing
behavior and flags (including ALLOW_PRIVATE_WEBHOOKS) without changing logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f801a05-83d9-4f11-9900-a8039da25217

📥 Commits

Reviewing files that changed from the base of the PR and between 5747dc6 and 131f88f.

📒 Files selected for processing (1)
  • apps/api/plane/app/serializers/webhook.py

@sriramveeraghanta
Copy link
Copy Markdown
Member

Changes looks good to me. Please sign the CLA.

@prue-starfield prue-starfield force-pushed the fix/allow-private-webhook-urls branch from 131f88f to 2e3799e Compare March 31, 2026 13:26
@prue-starfield
Copy link
Copy Markdown
Author

Thanks @sriramveeraghanta! I've updated the PR:

  1. Fixed commit author email — was using a local hostname email, now linked to my GitHub account so CLA should pass
  2. Added startup log warning when PLANE_ALLOW_PRIVATE_WEBHOOKS is enabled (CodeRabbit suggestion)
  3. Extracted _validate_webhook_url() helper — removed the duplicated validation logic between create() and update() (CodeRabbit suggestion)
  4. Removed the unrelated issue.py change — that webhook-on-update fix was already merged upstream in fix: add missing model_activity.delay() for API-driven issue webhooks #8792

Clean single-file diff now. Will sign the CLA shortly.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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`:
- Line 40: The hostname extraction at hostname = urlparse(url).hostname (and the
similar comparisons around the block handling lines 63-67) must normalize
hostnames before comparing to blocked domains: trim any trailing dots, convert
to lowercase, and apply IDNA/punycode normalization if relevant; then use the
normalized hostname for domain checks and comparisons to the blocked-domain list
(e.g., replace raw hostname usage in the webhook validation functions with the
normalized_hostname variable) so canonical variants cannot bypass the blocklist.
- Line 71: The URLField is always using validate_domain which overrides the
ALLOW_PRIVATE_WEBHOOKS opt-in; change the serializer to apply validate_domain
conditionally: either remove validate_domain from
serializers.URLField(validators=[...]) and in the serializer's __init__ (or a
classmethod like get_validators) append validate_domain only when
settings.ALLOW_PRIVATE_WEBHOOKS is False, or modify validate_domain itself to
early-return/skip loopback checks when settings.ALLOW_PRIVATE_WEBHOOKS is True;
target the URLField declaration and the serializer class initializer and
reference validate_schema, validate_domain, and the ALLOW_PRIVATE_WEBHOOKS
setting so private loopback hosts are allowed when the flag is enabled.
🪄 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: e51c716c-d710-4147-9692-c63d3654fa48

📥 Commits

Reviewing files that changed from the base of the PR and between 131f88f and 2e3799e.

📒 Files selected for processing (1)
  • apps/api/plane/app/serializers/webhook.py

Add PLANE_ALLOW_PRIVATE_WEBHOOKS environment variable that bypasses
IP validation for webhook URLs when set to '1', 'true', or 'yes'.
Disabled by default.

Self-hosted Plane instances commonly need webhooks pointing to local
services (notification relays, CI runners, internal APIs) but the SSRF
protection blocks all private/loopback/reserved/link-local IPs.

Changes:
- Gated private-IP bypass via env var (default: disabled)
- Startup log warning when flag is enabled
- Extracted shared URL validation into _validate_webhook_url() helper
- Zero impact on existing behavior when not set

Refs: makeplane#5248
@prue-starfield prue-starfield force-pushed the fix/allow-private-webhook-urls branch from 2e3799e to bbea154 Compare March 31, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants