Skip to content
Open
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
21 changes: 13 additions & 8 deletions apps/api/plane/app/serializers/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# See the LICENSE file for details.

# Python imports
import os
import socket
import ipaddress
from urllib.parse import urlparse
Expand All @@ -15,6 +16,8 @@
from plane.db.models import Webhook, WebhookLog
from plane.db.models.webhook import validate_domain, validate_schema

ENABLE_WEBHOOK_SSRF_PROTECTION = os.environ.get("ENABLE_WEBHOOK_SSRF_PROTECTION", "1") != "0"
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The new ENABLE_WEBHOOK_SSRF_PROTECTION environment variable is not documented in .env.example, whereas all other env vars (like DEBUG, USE_MINIO, ENABLE_READ_REPLICA, API_KEY_RATE_LIMIT, etc.) are listed there. Without a corresponding entry, self-hosted operators who consult .env.example as the reference for available configuration options will not discover this new security toggle.

An entry with a comment explaining its purpose and the default value (1 = protection enabled, 0 = disabled) should be added to .env.example.

Copilot uses AI. Check for mistakes.


class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
Expand All @@ -36,10 +39,11 @@ def create(self, validated_data):
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})

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."})
if ENABLE_WEBHOOK_SSRF_PROTECTION:
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."})
Comment on lines +42 to +46
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The new conditional SSRF protection behavior in WebhookSerializer.create() and .update() is not covered by any tests. The codebase has a dedicated unit test directory for serializers (apps/api/plane/tests/unit/serializers/) with tests for other serializers (e.g., test_workspace.py, test_label.py). Given this existing testing convention, tests should be added for:

  1. Default behavior: webhook creation with a private/loopback IP is blocked when ENABLE_WEBHOOK_SSRF_PROTECTION is not set (or set to "1").
  2. Opt-out behavior: webhook creation with a private/loopback IP succeeds when ENABLE_WEBHOOK_SSRF_PROTECTION=0.

Similarly, the conditional behavior in validate_domain (in webhook.py) should be tested.

Copilot uses AI. Check for mistakes.

# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
Expand Down Expand Up @@ -71,10 +75,11 @@ def update(self, instance, validated_data):
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})

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."})
if ENABLE_WEBHOOK_SSRF_PROTECTION:
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."})

# Additional validation for multiple request domains and their subdomains
request = self.context.get("request")
Expand Down
6 changes: 4 additions & 2 deletions apps/api/plane/db/models/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# See the LICENSE file for details.

# Python imports
import os
from uuid import uuid4
from urllib.parse import urlparse

Expand All @@ -27,8 +28,9 @@ def validate_schema(value):
def validate_domain(value):
parsed_url = urlparse(value)
domain = parsed_url.netloc
if domain in ["localhost", "127.0.0.1"]:
raise ValidationError("Local URLs are not allowed.")
if os.environ.get("ENABLE_WEBHOOK_SSRF_PROTECTION", "1") != "0":
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The ENABLE_WEBHOOK_SSRF_PROTECTION flag is evaluated at module import time in the serializer (line 19 caches the boolean), but is read fresh from os.environ on every call inside the validate_domain function in the model. This inconsistency means that if the env var were ever changed after startup (unlikely but possible in some environments), the two checks would diverge. More importantly, this makes the behavior harder to reason about and test, because the serializer's flag is fixed at import time while the model validator reads it dynamically.

The recommended fix is to use the same approach in both places. Since a module-level constant (evaluated once at startup) is the more efficient and predictable pattern, validate_domain in webhook.py should also use a module-level constant instead of calling os.environ.get(...) inline. Alternatively, both places could call os.environ.get(...) on every invocation, but that's less common for this type of configuration flag.

Copilot uses AI. Check for mistakes.
if domain in ["localhost", "127.0.0.1"]:
raise ValidationError("Local URLs are not allowed.")
Comment on lines 29 to +33
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Python's urllib.parse.urlparse, what is the difference between ParseResult.netloc and ParseResult.hostname? Does hostname strip ports/credentials and normalize the host casing?

💡 Result:

  • ParseResult.netloc is the raw “network location” / authority string exactly as parsed from the URL (no further sub-splitting), e.g. it can contain credentials and port: username:password@Host.EXAMPLE:8443 (and IPv6 brackets, etc.). Also, URL parsing does not expand % escapes in components. [1]

  • ParseResult.hostname is a derived convenience property that extracts just the host part from netloc:

    • strips userinfo (user:pass@) and port (:8443)
    • strips IPv6 [...] brackets when present
    • returns the host in lower case (with a special-case: for scoped IPv6 like fe80::1%Zone, only the part before % is lowercased; the zone is preserved) [1][2]

So yes: hostname strips ports/credentials and normalizes host casing to lowercase (with the IPv6 zone exception above). [1][2]

Sources: Python urllib.parse docs [1], CPython urllib.parse implementation [2].


Use parsed_url.hostname instead of parsed_url.netloc to prevent SSRF bypass.

The current validator compares the raw netloc string against literals like "localhost", which fails to match:

  • URLs with ports: localhost:8000 has netloc = "localhost:8000"
  • URLs with credentials: user@localhost has netloc = "user@localhost"
  • Uppercase hostnames: LOCALHOST will not equal the literal "localhost"

The hostname property extracts only the host portion, strips ports and credentials, and normalizes to lowercase, ensuring the check is effective.

Suggested fix
 def validate_domain(value):
     parsed_url = urlparse(value)
-    domain = parsed_url.netloc
+    domain = parsed_url.hostname
     if os.environ.get("ENABLE_WEBHOOK_SSRF_PROTECTION", "1") != "0":
-        if domain in ["localhost", "127.0.0.1"]:
+        if domain in {"localhost", "127.0.0.1"}:
             raise ValidationError("Local URLs are not allowed.")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parsed_url = urlparse(value)
domain = parsed_url.netloc
if domain in ["localhost", "127.0.0.1"]:
raise ValidationError("Local URLs are not allowed.")
if os.environ.get("ENABLE_WEBHOOK_SSRF_PROTECTION", "1") != "0":
if domain in ["localhost", "127.0.0.1"]:
raise ValidationError("Local URLs are not allowed.")
parsed_url = urlparse(value)
domain = parsed_url.hostname
if os.environ.get("ENABLE_WEBHOOK_SSRF_PROTECTION", "1") != "0":
if domain in {"localhost", "127.0.0.1"}:
raise ValidationError("Local URLs are not allowed.")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/db/models/webhook.py` around lines 29 - 33, The SSRF host
check in the webhook URL validator uses parsed_url.netloc which can include
ports/credentials and vary in case; change it to use parsed_url.hostname (e.g.,
replace references to parsed_url.netloc with parsed_url.hostname) so the host
portion is normalized and stripped of ports/credentials, and ensure you handle
None (missing hostname) before comparing to literals like "localhost" or
"127.0.0.1" when enforcing ENABLE_WEBHOOK_SSRF_PROTECTION in the validation
logic.



class Webhook(BaseModel):
Expand Down