{"record":{"id":"10b77207b1c79ff1","repo":"makeplane/plane","slug":"local-urls-are-not-allowed","errorCode":null,"errorMessage":"Local URLs are not allowed.","messagePattern":"Local URLs are not allowed\\.","errorType":"validation","errorClass":"ValidationError","httpStatus":null,"severity":"warning","filePath":"apps/api/plane/db/models/webhook.py","lineNumber":31,"sourceCode":"# Module imports\nfrom plane.db.models import BaseModel, ProjectBaseModel\n\n\ndef generate_token():\n    return \"plane_wh_\" + uuid4().hex\n\n\ndef validate_schema(value):\n    parsed_url = urlparse(value)\n    if parsed_url.scheme not in [\"http\", \"https\"]:\n        raise ValidationError(\"Invalid schema. Only HTTP and HTTPS are allowed.\")\n\n\ndef validate_domain(value):\n    parsed_url = urlparse(value)\n    domain = parsed_url.netloc\n    if domain in [\"localhost\", \"127.0.0.1\"]:\n        raise ValidationError(\"Local URLs are not allowed.\")\n\n\nclass Webhook(BaseModel):\n    workspace = models.ForeignKey(\"db.Workspace\", on_delete=models.CASCADE, related_name=\"workspace_webhooks\")\n    url = models.URLField(validators=[validate_schema, validate_domain], max_length=1024)\n    is_active = models.BooleanField(default=True)\n    secret_key = models.CharField(max_length=255, default=generate_token)\n    project = models.BooleanField(default=False)\n    issue = models.BooleanField(default=False)\n    module = models.BooleanField(default=False)\n    cycle = models.BooleanField(default=False)\n    issue_comment = models.BooleanField(default=False)\n    is_internal = models.BooleanField(default=False)\n    version = models.CharField(default=\"v1\", max_length=50)\n\n    def __str__(self):\n        return f\"{self.workspace.slug} {self.url}\"\n","sourceCodeStart":13,"sourceCodeEnd":49,"githubUrl":"https://github.com/makeplane/plane/blob/1c8a60f858d8472aa56e29994ec1c7926da2c6ce/apps/api/plane/db/models/webhook.py#L13-L49","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Use a non-localhost hostname or a public DNS name for the webhook target.","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.","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.","Resolve the host and reject if it points to a private/loopback IP."],"exampleFix":"# before - blocked\ndef validate_domain(value):\n    domain = urlparse(value).netloc\n    if domain in [\"localhost\", \"127.0.0.1\"]:\n        raise ValidationError(\"Local URLs are not allowed.\")\n\n# after - broader SSRF guard\nimport ipaddress, socket\ndef validate_domain(value):\n    domain = urlparse(value).netloc.split(\":\")[0]\n    try:\n        ip = ipaddress.ip_address(domain)\n    except ValueError:\n        ip = ipaddress.ip_address(socket.gethostbyname(domain))\n    if ip.is_private or ip.is_loopback or ip.is_link_local:\n        raise ValidationError(\"Local/private URLs are not allowed.\")","handlingStrategy":"validation","validationCode":"from urllib.parse import urlparse\n\ndef is_blocked_local_url(url: str) -> bool:\n    # mirrors the NARROW current check in webhook.py:27\n    # NOTE: only these two literals are blocked; 0.0.0.0, ::1, private IPs are NOT\n    return urlparse(url).netloc in ('localhost', '127.0.0.1')\n\n# stronger guard for callers that need real SSRF protection:\nimport ipaddress, socket\ndef is_local_or_private(url: str) -> bool:\n    host = urlparse(url).netloc.split(':')[0]\n    try:\n        ip = ipaddress.ip_address(host)\n    except ValueError:\n        ip = ipaddress.ip_address(socket.gethostbyname(host))\n    return ip.is_loopback or ip.is_private or ip.is_link_local","typeGuard":null,"tryCatchPattern":"from django.core.exceptions import ValidationError\ntry:\n    webhook.full_clean()\nexcept ValidationError as e:\n    if 'Local URLs' in str(e):\n        use_public_tunnel()  # e.g. ngrok","preventionTips":["Use a public hostname for webhook targets.","Do NOT rely on validate_domain alone for SSRF defense - harden it to block all loopback/private/link-local IPs.","Resolve and re-check the IP after DNS lookup to catch DNS rebinding."],"tags":["webhook","ssrf","validation","security"],"backgroundTag":null,"analyzedSha":"1c8a60f858d8472aa56e29994ec1c7926da2c6ce","analyzedAt":"2026-08-12T14:44:31.636Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}