feat(delivery): git workspaces + SSRF host check (v2 phase A3-2)
The platform's side of D6: git runs on the host, in a gitdir that is never mounted into a container, so repository credentials never reach the model. - adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at /workspace + one linked worktree per task under work/.worktrees/<id> (excluded via the gitdir's info/exclude, not the project's .gitignore). Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off, quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off, one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH key as a 0600 temp file with a pinned known_hosts. Everything deleted in a finally; only a *path* ever travels through the environment. - The bug this phase existed to find: --git-dir=<shared> does not address a linked worktree, so `git commit` in a task worktree committed straight to `dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and never read the worktree's .git file, which a repository can rewrite. - domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed (resolves, refuses if any address is loopback/private/link-local/metadata). Shared by the git remote test and, later, every deploy target. - ProjectExternalGit domain model + repository; container_delivery wires the real adapter behind GIT_WORKSPACE=local with a resolver that joins the project, its repo and its (decrypted) credentials. Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-2.md
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
"""Platform-wide validation rules carried over from the existing infrastructure.
|
||||
|
||||
These are 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.
|
||||
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"
|
||||
|
||||
@@ -119,3 +128,143 @@ def assert_not_protected_port(port: int) -> int:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user