makeplane/plane · warning · ValidationError

Local URLs are not allowed.

Error message

Local URLs are not allowed.

What it means

Django ValidationError raised by `validate_domain` (webhook.py:25) when the parsed URL's netloc is exactly `localhost` or `127.0.0.1`. The check is intentionally narrow - it only blocks those two literals, so other loopback/private addresses (e.g. `0.0.0.0`, `::1`, `127.0.0.2`, `169.254.169.254`, internal RFC1918 IPs) are NOT blocked, which is a known SSRF gap.

Source

Thrown at apps/api/plane/db/models/webhook.py:31

# Module imports
from plane.db.models import BaseModel, ProjectBaseModel


def generate_token():
    return "plane_wh_" + uuid4().hex


def validate_schema(value):
    parsed_url = urlparse(value)
    if parsed_url.scheme not in ["http", "https"]:
        raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")


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.")


class Webhook(BaseModel):
    workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks")
    url = models.URLField(validators=[validate_schema, validate_domain], max_length=1024)
    is_active = models.BooleanField(default=True)
    secret_key = models.CharField(max_length=255, default=generate_token)
    project = models.BooleanField(default=False)
    issue = models.BooleanField(default=False)
    module = models.BooleanField(default=False)
    cycle = models.BooleanField(default=False)
    issue_comment = models.BooleanField(default=False)
    is_internal = models.BooleanField(default=False)
    version = models.CharField(default="v1", max_length=50)

    def __str__(self):
        return f"{self.workspace.slug} {self.url}"

View on GitHub (pinned to 1c8a60f858)

Solutions

  1. Use a non-localhost hostname or a public DNS name for the webhook target.
  2. If developing locally, expose the receiver via a tunnel (e.g. a public ngrok/cloudflare-tunnel URL) so the netloc is not localhost/127.0.0.1.
  3. To actually prevent SSRF, harden `validate_domain` to also reject 0.0.0.0, ::1, 127.0.0.0/8, 169.254.169.254, and RFC1918 ranges - the current allow-list is incomplete.
  4. Resolve the host and reject if it points to a private/loopback IP.

Example fix

# before - blocked
def validate_domain(value):
    domain = urlparse(value).netloc
    if domain in ["localhost", "127.0.0.1"]:
        raise ValidationError("Local URLs are not allowed.")

# after - broader SSRF guard
import ipaddress, socket
def validate_domain(value):
    domain = urlparse(value).netloc.split(":")[0]
    try:
        ip = ipaddress.ip_address(domain)
    except ValueError:
        ip = ipaddress.ip_address(socket.gethostbyname(domain))
    if ip.is_private or ip.is_loopback or ip.is_link_local:
        raise ValidationError("Local/private URLs are not allowed.")
Defensive patterns

Strategy: validation

Validate before calling

from urllib.parse import urlparse

def is_blocked_local_url(url: str) -> bool:
    # mirrors the NARROW current check in webhook.py:27
    # NOTE: only these two literals are blocked; 0.0.0.0, ::1, private IPs are NOT
    return urlparse(url).netloc in ('localhost', '127.0.0.1')

# stronger guard for callers that need real SSRF protection:
import ipaddress, socket
def is_local_or_private(url: str) -> bool:
    host = urlparse(url).netloc.split(':')[0]
    try:
        ip = ipaddress.ip_address(host)
    except ValueError:
        ip = ipaddress.ip_address(socket.gethostbyname(host))
    return ip.is_loopback or ip.is_private or ip.is_link_local

Try / catch

from django.core.exceptions import ValidationError
try:
    webhook.full_clean()
except ValidationError as e:
    if 'Local URLs' in str(e):
        use_public_tunnel()  # e.g. ngrok

Prevention

When it happens

Trigger: Creating/updating a Webhook whose URL's host is literally `localhost` or `127.0.0.1` (e.g. `http://localhost:9000/hook` or `http://127.0.0.1/hook`). Any other host string passes, including `0.0.0.0`, `[::1]`, `localhost.localdomain`, or any private IP.

Common situations: Local development against a webhook receiver on the same machine; security testing that expects full SSRF protection but finds only two hosts blocked; CI environments where the callback host resolves to localhost under a different name.

Related errors


AI-assisted analysis of makeplane/plane@1c8a60f858 (2026-08-12). Data as JSON: /api/errors/10b77207b1c79ff1. Report an issue: GitHub.