mirror of
https://github.com/makeplane/plane.git
synced 2026-05-18 06:48:47 -05:00
Merge branch 'canary' of github.com:makeplane/plane into preview
This commit is contained in:
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
ENV CI=1
|
||||
|
||||
RUN corepack enable pnpm
|
||||
|
||||
@@ -27,15 +27,26 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
def _validate_webhook_url(self, url):
|
||||
"""Validate a webhook URL against SSRF and disallowed domain rules."""
|
||||
try:
|
||||
validate_url(url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
|
||||
validate_url(
|
||||
url,
|
||||
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
|
||||
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Webhook URL validation failed for %s: %s", url, e)
|
||||
raise serializers.ValidationError({"url": "Invalid or disallowed webhook URL."})
|
||||
|
||||
hostname = (urlparse(url).hostname or "").rstrip(".").lower()
|
||||
|
||||
# Hosts explicitly trusted via WEBHOOK_ALLOWED_HOSTS bypass the
|
||||
# disallowed-domain check — they're already trusted for SSRF, so
|
||||
# the loop-back guard would only get in the way of legitimate
|
||||
# sibling services that share a parent domain with Plane.
|
||||
if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
|
||||
return
|
||||
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"]
|
||||
disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0].rstrip(".").lower()
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
@@ -327,7 +327,11 @@ def webhook_send_task(
|
||||
|
||||
try:
|
||||
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
|
||||
validate_url(webhook.url, allowed_ips=settings.WEBHOOK_ALLOWED_IPS)
|
||||
validate_url(
|
||||
webhook.url,
|
||||
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
|
||||
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
|
||||
)
|
||||
|
||||
# Send the webhook event
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
@@ -49,6 +49,29 @@ for _cidr in _webhook_allowed_ips_raw.split(","):
|
||||
except ValueError:
|
||||
_logger.warning("WEBHOOK_ALLOWED_IPS: skipping invalid entry %r", _cidr)
|
||||
|
||||
# Webhook hostname allowlist — comma-separated hostnames that bypass the
|
||||
# private-IP SSRF check. Useful for trusted internal services whose IPs are
|
||||
# dynamic in containerised deployments (e.g. docker-compose service DNS,
|
||||
# kubernetes service hostnames).
|
||||
# Example: "silo,silo.namespace.svc.cluster.local,internal-api.lan"
|
||||
_webhook_allowed_hosts_raw = os.environ.get("WEBHOOK_ALLOWED_HOSTS", "")
|
||||
WEBHOOK_ALLOWED_HOSTS = [
|
||||
_host.strip().rstrip(".").lower()
|
||||
for _host in _webhook_allowed_hosts_raw.split(",")
|
||||
if _host.strip()
|
||||
]
|
||||
|
||||
# Webhook disallowed domains — comma-separated hostnames. Webhooks targeting
|
||||
# these domains or any of their subdomains are rejected (the request host is
|
||||
# always appended at validation time as a loop-back guard). Empty by default
|
||||
# for self-hosted deployments; set to e.g. "plane.so" to block specific domains.
|
||||
_webhook_disallowed_domains_raw = os.environ.get("WEBHOOK_DISALLOWED_DOMAINS", "")
|
||||
WEBHOOK_DISALLOWED_DOMAINS = [
|
||||
_d.strip().rstrip(".").lower()
|
||||
for _d in _webhook_disallowed_domains_raw.split(",")
|
||||
if _d.strip()
|
||||
]
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||
|
||||
|
||||
@@ -88,6 +88,48 @@ class TestValidateUrlAllowlist:
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://example.com", allowed_ips=allowed)
|
||||
|
||||
def test_allowed_hosts_bypasses_private_ip_check(self):
|
||||
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
|
||||
trusted internal services (e.g. Silo) whose IPs are dynamic in
|
||||
containerised deployments."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
|
||||
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
|
||||
|
||||
def test_allowed_hosts_matches_case_insensitively(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
validate_url(
|
||||
"http://Silo.Namespace.Svc.Cluster.Local/x",
|
||||
allowed_hosts=["silo.namespace.svc.cluster.local"],
|
||||
) # Should not raise
|
||||
|
||||
def test_allowed_hosts_skips_dns_lookup(self):
|
||||
"""When the hostname is explicitly trusted we shouldn't even resolve it —
|
||||
protects against operators who allowlist a name that isn't resolvable
|
||||
from the API container."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
validate_url("http://silo/hook", allowed_hosts=["silo"])
|
||||
mock_dns.assert_not_called()
|
||||
|
||||
def test_allowed_hosts_requires_exact_match(self):
|
||||
"""Subdomains of an allowed host must NOT bypass — a hostile
|
||||
``attacker.silo.internal`` should still be blocked when only
|
||||
``silo.internal`` is allowed."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url(
|
||||
"http://attacker.silo.internal/x",
|
||||
allowed_hosts=["silo.internal"],
|
||||
)
|
||||
|
||||
def test_allowed_hosts_empty_does_not_bypass(self):
|
||||
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://silo/hook", allowed_hosts=[])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafeGet:
|
||||
|
||||
@@ -8,7 +8,7 @@ import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_url(url, allowed_ips=None):
|
||||
def validate_url(url, allowed_ips=None, allowed_hosts=None):
|
||||
"""
|
||||
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
|
||||
|
||||
@@ -17,6 +17,11 @@ def validate_url(url, allowed_ips=None):
|
||||
allowed_ips: Optional list of ipaddress.ip_network objects. IPs falling within
|
||||
these networks are permitted even if they are private/loopback/reserved.
|
||||
Typically sourced from the WEBHOOK_ALLOWED_IPS setting.
|
||||
allowed_hosts: Optional iterable of hostnames that bypass IP-based blocking
|
||||
(exact, case-insensitive match against the URL hostname).
|
||||
Typically sourced from the WEBHOOK_ALLOWED_HOSTS setting and
|
||||
used for trusted internal services (e.g. Silo) whose IPs are
|
||||
dynamic in containerised deployments.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL is invalid or resolves to a blocked IP.
|
||||
@@ -30,6 +35,12 @@ def validate_url(url, allowed_ips=None):
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
normalized_host = hostname.rstrip(".").lower()
|
||||
if allowed_hosts and normalized_host in {
|
||||
(h or "").rstrip(".").lower() for h in allowed_hosts if h
|
||||
}:
|
||||
return
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
ENV CI=1
|
||||
|
||||
RUN corepack enable pnpm
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:22-alpine AS base
|
||||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
|
||||
@@ -51,3 +51,12 @@ API_KEY_RATE_LIMIT=60/minute
|
||||
|
||||
# Live Server Secret Key
|
||||
LIVE_SERVER_SECRET_KEY=htbqvBJAgpm9bzvf3r4urJer0ENReatceh
|
||||
|
||||
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
|
||||
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
|
||||
WEBHOOK_ALLOWED_IPS=
|
||||
|
||||
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
|
||||
# SSRF check. Useful for trusted internal services whose container/service IPs are
|
||||
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
|
||||
WEBHOOK_ALLOWED_HOSTS=
|
||||
|
||||
@@ -58,6 +58,8 @@ x-app-env: &app-env
|
||||
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
|
||||
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
|
||||
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
|
||||
WEBHOOK_ALLOWED_IPS: ${WEBHOOK_ALLOWED_IPS:-}
|
||||
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
|
||||
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -80,3 +80,12 @@ API_KEY_RATE_LIMIT=60/minute
|
||||
# Live server environment variables
|
||||
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
|
||||
LIVE_SERVER_SECRET_KEY=
|
||||
|
||||
# Webhook IP allowlist — comma-separated IPs or CIDR ranges allowed as webhook targets
|
||||
# even if they resolve to private networks (e.g. "10.0.0.0/8,192.168.1.0/24,172.16.0.5")
|
||||
WEBHOOK_ALLOWED_IPS=
|
||||
|
||||
# Webhook hostname allowlist — comma-separated hostnames that bypass the private-IP
|
||||
# SSRF check. Useful for trusted internal services whose container/service IPs are
|
||||
# dynamic (e.g. "silo,silo.namespace.svc.cluster.local")
|
||||
WEBHOOK_ALLOWED_HOSTS=
|
||||
|
||||
Reference in New Issue
Block a user