Domains are infrastructure, and in v2 infrastructure is the platform owner's alone (D9). A tenant gets one automatic demo host and nothing else. - admin_domains.py (owner-only): list every route with its owning app, attach an extra host, detach with the host typed back as confirmation (audited), re-sync the router from the database, and read the suggested MCP gateway route. - domain/platform/rules.py: validate_extra_host. Two shapes only — a single label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS is on. A wildcard is refused in both (it would match another tenant's subdomain), and so are 30 protected platform labels (git, mcp, panel, api...). Enforced in the router *and* again in the adapter. - Both router adapters gained routes_fingerprint / list_all_routes / sync_routes. Re-sync writes and reloads only when the rendered state differs: a reload drops every in-flight connection on the host, including live demos. Adapter-level extras, not additions to the frozen DomainRouterPort. - The member-facing preview-domain routes are owner-only now. They are kept because the demo host is still created through them at app-creation time. Tests: 473 API (+58); ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-4.md
369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""Platform-wide validation rules carried over from the existing infrastructure.
|
|
|
|
These are almost all pure functions with no I/O, so both the application layer
|
|
*and* the adapters can enforce them. Defence in depth: a rule that is only
|
|
checked in one place is a rule that will eventually be bypassed.
|
|
|
|
The one exception is :func:`assert_remote_host_allowed`, which resolves a
|
|
hostname. It has to: a *name* tells you nothing about where it points, and the
|
|
whole point of the check is to refuse a name that resolves into the private
|
|
network. The address classification itself is pure
|
|
(:func:`is_blocked_address`), so the policy is testable without DNS.
|
|
|
|
Reference: ``docs/current-infra-notes.md`` §1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import socket
|
|
from urllib.parse import urlsplit
|
|
|
|
DEFAULT_ROOT_DOMAIN = "msa.artacloud.ir"
|
|
|
|
#: Ports that are never allocatable (system services + data stores).
|
|
SYSTEM_RESERVED_PORTS: frozenset[int] = frozenset(
|
|
{22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}
|
|
)
|
|
|
|
#: Ports of internal platform services. A tenant preview must never proxy to
|
|
#: these, otherwise a public hostname would expose another component.
|
|
SYSTEM_PROTECTED_PORTS: frozenset[int] = (
|
|
frozenset({3000, 35555, 35556, 35557}) | SYSTEM_RESERVED_PORTS
|
|
)
|
|
|
|
DEFAULT_PORT_RANGE_START = 8080
|
|
DEFAULT_PORT_RANGE_END = 8999
|
|
|
|
LABEL_MIN = 3
|
|
LABEL_MAX = 40
|
|
_LABEL_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
_PROXY_TARGET_RE = re.compile(r"^(https?://)?(localhost|127\.0\.0\.1):(\d{1,5})$")
|
|
|
|
|
|
class RuleViolation(ValueError):
|
|
"""Raised when a request violates a platform safety rule."""
|
|
|
|
|
|
def validate_subdomain_label(label: str) -> str:
|
|
"""Normalise and validate a single DNS label.
|
|
|
|
Only ``a-z0-9-``, must start and end alphanumeric, 3-40 characters.
|
|
"""
|
|
clean = (label or "").strip().lower()
|
|
if not (LABEL_MIN <= len(clean) <= LABEL_MAX):
|
|
raise RuleViolation(
|
|
f"نام زیردامنه باید بین {LABEL_MIN} تا {LABEL_MAX} کاراکتر باشد."
|
|
)
|
|
if not _LABEL_RE.match(clean):
|
|
raise RuleViolation("نام زیردامنه فقط میتواند شامل حروف کوچک، عدد و خط تیره باشد.")
|
|
return clean
|
|
|
|
|
|
def build_host(label: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
|
return f"{validate_subdomain_label(label)}.{root_domain.strip().lower()}"
|
|
|
|
|
|
def validate_managed_host(host: str, root_domain: str = DEFAULT_ROOT_DOMAIN) -> str:
|
|
"""Validate a fully-qualified host against the managed wildcard domain.
|
|
|
|
Rejects anything that is not a single label under the root domain — this is
|
|
the anti-injection check that keeps a crafted host from breaking out of a
|
|
Caddy site block or pointing at an arbitrary host.
|
|
"""
|
|
clean = (host or "").strip().lower()
|
|
suffix = f".{root_domain.strip().lower()}"
|
|
if not clean.endswith(suffix):
|
|
raise RuleViolation(f"دامنه باید زیرمجموعهٔ «{root_domain}» باشد.")
|
|
label = clean[: -len(suffix)]
|
|
if "." in label:
|
|
raise RuleViolation("فقط یک برچسب (single label) مجاز است.")
|
|
validate_subdomain_label(label)
|
|
return clean
|
|
|
|
|
|
def validate_proxy_target(target: str) -> tuple[str, int]:
|
|
"""Ensure the reverse-proxy target is a loopback address with a numeric port.
|
|
|
|
Blocks SSRF: a tenant must not be able to proxy a public hostname to an
|
|
internal service.
|
|
"""
|
|
clean = (target or "").strip()
|
|
match = _PROXY_TARGET_RE.match(clean)
|
|
if not match:
|
|
raise RuleViolation(
|
|
"مقصد reverse proxy باید localhost یا 127.0.0.1 با پورت عددی باشد."
|
|
)
|
|
port = int(match.group(3))
|
|
if not (1 <= port <= 65535):
|
|
raise RuleViolation("شمارهٔ پورت نامعتبر است.")
|
|
return match.group(2), port
|
|
|
|
|
|
def is_port_allocatable(
|
|
port: int,
|
|
*,
|
|
start: int = DEFAULT_PORT_RANGE_START,
|
|
end: int = DEFAULT_PORT_RANGE_END,
|
|
) -> bool:
|
|
if port in SYSTEM_RESERVED_PORTS:
|
|
return False
|
|
return start <= port <= end
|
|
|
|
|
|
def assert_port_allocatable(
|
|
port: int,
|
|
*,
|
|
start: int = DEFAULT_PORT_RANGE_START,
|
|
end: int = DEFAULT_PORT_RANGE_END,
|
|
) -> int:
|
|
if not is_port_allocatable(port, start=start, end=end):
|
|
raise RuleViolation(f"پورت {port} قابل تخصیص نیست.")
|
|
return port
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extra hosts (v2: only the platform owner may attach one)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Labels that must never become a tenant host. They are either platform
|
|
#: services or would shadow one: `git` is the forge, `mcp` the MCP gateway,
|
|
#: `panel`/`admin` the console, `api` the backend itself. A tenant host that
|
|
#: resolves to one of these is a phishing surface and, worse, a routing
|
|
#: conflict with a real service.
|
|
PROTECTED_HOST_LABELS: frozenset[str] = frozenset(
|
|
{
|
|
"admin",
|
|
"api",
|
|
"app",
|
|
"assets",
|
|
"billing",
|
|
"cdn",
|
|
"console",
|
|
"dashboard",
|
|
"ftp",
|
|
"git",
|
|
"gitea",
|
|
"grafana",
|
|
"internal",
|
|
"mail",
|
|
"mcp",
|
|
"media",
|
|
"monitor",
|
|
"ns",
|
|
"panel",
|
|
"registry",
|
|
"sandbox",
|
|
"smtp",
|
|
"ssh",
|
|
"static",
|
|
"status",
|
|
"storage",
|
|
"support",
|
|
"webhook",
|
|
"www",
|
|
}
|
|
)
|
|
|
|
#: A strict fully-qualified domain name. Deliberately does **not** allow a
|
|
#: wildcard (`*.example.com` in a Caddy site block matches every subdomain,
|
|
#: including one belonging to another tenant) and requires at least one dot.
|
|
_FQDN_RE = re.compile(
|
|
r"^(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-))+$"
|
|
)
|
|
|
|
#: Longest host we will ever write into a config file.
|
|
MAX_HOST_LENGTH = 253
|
|
|
|
|
|
def validate_extra_host(
|
|
host: str, *, root_domain: str = DEFAULT_ROOT_DOMAIN, allow_custom: bool = False
|
|
) -> str:
|
|
"""Validate a host the owner wants to attach to an app.
|
|
|
|
Two shapes are allowed:
|
|
|
|
* a **single label under the managed root** (``shop.msa.artacloud.ir``), which
|
|
is the default and needs no DNS work from anyone; and
|
|
* a **full custom FQDN** (``app.customer.com``), but only when the operator
|
|
has turned ``ALLOW_CUSTOM_DOMAINS`` on — a custom host needs its own TLS
|
|
certificate and DNS record, and silently accepting one that will never
|
|
resolve produces a domain that looks configured and is not.
|
|
|
|
A wildcard is refused in both cases. So is anything whose first label is a
|
|
protected platform name, in either shape.
|
|
"""
|
|
clean = (host or "").strip().lower().rstrip(".")
|
|
if not clean:
|
|
raise RuleViolation("نام دامنه خالی است.")
|
|
if len(clean) > MAX_HOST_LENGTH:
|
|
raise RuleViolation("نام دامنه بیش از حد طولانی است.")
|
|
if "*" in clean:
|
|
raise RuleViolation("دامنهٔ wildcard مجاز نیست.")
|
|
if not _FQDN_RE.match(clean):
|
|
raise RuleViolation("قالب نام دامنه نامعتبر است.")
|
|
|
|
first_label = clean.split(".", 1)[0]
|
|
if first_label in PROTECTED_HOST_LABELS:
|
|
raise RuleViolation(f"برچسب «{first_label}» برای سرویسهای سکو رزرو شده است.")
|
|
|
|
suffix = f".{root_domain.strip().lower()}"
|
|
if clean.endswith(suffix):
|
|
return validate_managed_host(clean, root_domain)
|
|
|
|
if clean == root_domain.strip().lower():
|
|
raise RuleViolation("دامنهٔ ریشه قابل استفاده نیست.")
|
|
if not allow_custom:
|
|
raise RuleViolation(
|
|
"افزودن دامنهٔ سفارشی غیرفعال است؛ فقط زیردامنههای سکو مجازند."
|
|
)
|
|
return clean
|
|
|
|
|
|
def assert_not_protected_port(port: int) -> int:
|
|
"""A preview domain must never point at an internal platform port."""
|
|
if port in SYSTEM_PROTECTED_PORTS:
|
|
raise RuleViolation(f"اتصال به پورت محافظتشدهٔ {port} ممنوع است.")
|
|
return port
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSRF: user-supplied remote hosts (git remotes, deploy targets)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Ports that are always refused on a user-supplied host, whatever the address.
|
|
#: A remote git or deploy target has no business talking to a database, a cache
|
|
#: or the Docker daemon. Port 22 is deliberately *not* here: git-over-ssh and
|
|
#: SFTP deploys are legitimate.
|
|
BLOCKED_REMOTE_PORTS: frozenset[int] = frozenset(
|
|
{2375, 2376, 3306, 5432, 6379, 9200, 11211, 27017}
|
|
)
|
|
|
|
#: Hostnames that are never a valid user-supplied remote. `localhost` is a name,
|
|
#: not an address, so it needs its own rule — DNS would resolve it to loopback,
|
|
#: but only if we remembered to resolve.
|
|
_BLOCKED_HOSTNAMES: frozenset[str] = frozenset(
|
|
{
|
|
"localhost",
|
|
"localhost.localdomain",
|
|
"ip6-localhost",
|
|
"ip6-loopback",
|
|
"metadata",
|
|
"metadata.google.internal",
|
|
"instance-data",
|
|
}
|
|
)
|
|
|
|
_ALLOWED_REMOTE_SCHEMES: frozenset[str] = frozenset(
|
|
{"http", "https", "ssh", "git", "ftp", "ftps", "sftp"}
|
|
)
|
|
|
|
|
|
def is_blocked_address(address: str) -> bool:
|
|
"""True when an IP literal must not be the target of a user-supplied remote.
|
|
|
|
Covers every way to reach something that is not the public internet:
|
|
loopback, RFC1918 private, link-local (including the cloud metadata address
|
|
``169.254.169.254``), unique-local IPv6, multicast, reserved, unspecified,
|
|
and IPv4-mapped IPv6 forms of all of the above.
|
|
"""
|
|
try:
|
|
ip = ipaddress.ip_address(address)
|
|
except ValueError:
|
|
return False # not an IP literal; the DNS path handles it
|
|
|
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
# ::ffff:127.0.0.1 is loopback in disguise.
|
|
ip = ip.ipv4_mapped
|
|
|
|
return bool(
|
|
ip.is_loopback
|
|
or ip.is_private
|
|
or ip.is_link_local
|
|
or ip.is_multicast
|
|
or ip.is_reserved
|
|
or ip.is_unspecified
|
|
)
|
|
|
|
|
|
def assert_remote_host_allowed(
|
|
url: str, *, allow_private: bool = False, resolver=None
|
|
) -> tuple[str, int | None]:
|
|
"""Validate a user-supplied remote URL and return ``(host, port)``.
|
|
|
|
Runs before *any* outbound connection — a git remote test, a deploy
|
|
connection — because a user-supplied URL is the classic SSRF vector:
|
|
``http://169.254.169.254/`` reaches cloud instance credentials, and
|
|
``http://127.0.0.1:6379/`` reaches Redis.
|
|
|
|
``resolver`` is injectable so the DNS step can be faked in tests; it defaults
|
|
to ``socket.getaddrinfo``. Every address the name resolves to must pass, not
|
|
just the first: a name can legitimately have both a public and a private
|
|
address, and returning either would defeat the check.
|
|
"""
|
|
clean = (url or "").strip()
|
|
if not clean:
|
|
raise RuleViolation("آدرس مقصد خالی است.")
|
|
if any(ch.isspace() for ch in clean):
|
|
raise RuleViolation("آدرس مقصد نباید فاصله داشته باشد.")
|
|
|
|
# A bare ``git@host:path`` scp-style remote has no scheme; normalise it so
|
|
# urlsplit can do its job.
|
|
if "://" not in clean and re.match(r"^[^/@\s]+@[^/:\s]+:", clean):
|
|
clean = "ssh://" + clean.replace(":", "/", 1)
|
|
|
|
parts = urlsplit(clean)
|
|
if parts.scheme.lower() not in _ALLOWED_REMOTE_SCHEMES:
|
|
raise RuleViolation(
|
|
"پروتکل آدرس مقصد پشتیبانی نمیشود."
|
|
)
|
|
host = parts.hostname or ""
|
|
if not host:
|
|
raise RuleViolation("میزبان آدرس مقصد مشخص نیست.")
|
|
if parts.password is not None:
|
|
# Credentials in a URL end up in logs, in `ps` and in the git remote
|
|
# config. They must be supplied as credentials, not as part of the URL.
|
|
raise RuleViolation("آدرس مقصد نباید شامل نام کاربری و رمز باشد.")
|
|
|
|
port = parts.port
|
|
if port is not None:
|
|
if not (1 <= port <= 65535):
|
|
raise RuleViolation("شمارهٔ پورت مقصد نامعتبر است.")
|
|
if port in BLOCKED_REMOTE_PORTS:
|
|
raise RuleViolation(f"پورت {port} برای مقصد مجاز نیست.")
|
|
|
|
if allow_private:
|
|
return host, port
|
|
|
|
lowered = host.lower().strip(".")
|
|
if lowered in _BLOCKED_HOSTNAMES or lowered.endswith(".localhost"):
|
|
raise RuleViolation("اتصال به میزبان محلی مجاز نیست.")
|
|
|
|
# An IP literal can be judged directly, without DNS.
|
|
try:
|
|
ipaddress.ip_address(lowered.strip("[]"))
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
if is_blocked_address(lowered.strip("[]")):
|
|
raise RuleViolation("اتصال به آدرسهای داخلی مجاز نیست.")
|
|
return host, port
|
|
|
|
lookup = resolver or socket.getaddrinfo
|
|
try:
|
|
infos = lookup(host, port or 0, type=socket.SOCK_STREAM)
|
|
except (socket.gaierror, UnicodeError, OSError) as exc:
|
|
raise RuleViolation("میزبان مقصد پیدا نشد.") from exc
|
|
|
|
# ``getaddrinfo`` returns a family-dependent sockaddr, so the address is
|
|
# coerced to str explicitly rather than relying on the overload's inference.
|
|
addresses: set[str] = {str(info[4][0]) for info in infos}
|
|
if not addresses:
|
|
raise RuleViolation("میزبان مقصد پیدا نشد.")
|
|
for address in addresses:
|
|
if is_blocked_address(address):
|
|
raise RuleViolation("اتصال به آدرسهای داخلی مجاز نیست.")
|
|
|
|
return host, port
|