mirror of
https://github.com/Katakate/k7.git
synced 2026-09-22 18:03:19 +00:00
Release 0.2.1
Security release for the k7-api control plane. Fixes a server-side request forgery reachable through a sandbox's image registry host: registry hosts are now resolved and checked against public/allowlisted ranges before any OCI fetch, the localhost-to-plain-HTTP downgrade is gone, and redirects are disabled so an allowlisted host cannot bounce the request inward. Adds optional per-key namespace authorization, so an API key can be confined to the namespaces it owns and cannot perform all-namespaces operations. Keys without a scope keep their previous unrestricted behaviour, so upgrading changes nothing until you scope your keys. Both issues were reported privately by Jirayu Thongchotchaung, who held disclosure until this release was available. See CHANGELOG.md and the published advisories for detail.
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""K7 Sandbox Management System"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
+94
-34
@@ -110,6 +110,38 @@ async def verify_api_key(
|
||||
return valid_data
|
||||
|
||||
|
||||
def authorize_namespace(
|
||||
key_data: dict,
|
||||
namespace: str | None,
|
||||
*,
|
||||
all_namespaces: bool = False,
|
||||
) -> None:
|
||||
"""Enforce optional per-key namespace scoping.
|
||||
|
||||
Absent/empty ``namespaces`` on the key ⇒ unrestricted (backward compatible).
|
||||
Scoped keys may only touch listed namespaces; cross-namespace listing
|
||||
(``namespace=None`` or ``all_namespaces=True``) is denied with 403.
|
||||
"""
|
||||
allowed = key_data.get("namespaces") or []
|
||||
if not allowed:
|
||||
return
|
||||
if all_namespaces:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="API key is not authorized for all-namespaces operations",
|
||||
)
|
||||
if namespace is None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="API key is namespace-scoped; pass an explicit allowed namespace",
|
||||
)
|
||||
if namespace not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"API key is not authorized for namespace '{namespace}'",
|
||||
)
|
||||
|
||||
|
||||
def success_response(
|
||||
data: Any, status_code: int = status.HTTP_200_OK, headers: dict[str, str] | None = None
|
||||
) -> JSONResponse:
|
||||
@@ -155,11 +187,12 @@ async def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes", dependencies=[Depends(verify_api_key)])
|
||||
async def create_sandbox(config: dict):
|
||||
@app.post("/api/v1/sandboxes")
|
||||
async def create_sandbox(config: dict, key_data: dict = Depends(verify_api_key)):
|
||||
"""Create a new sandbox."""
|
||||
try:
|
||||
sandbox_config = SandboxConfig.from_dict(config)
|
||||
authorize_namespace(key_data, sandbox_config.namespace)
|
||||
core = K7Core()
|
||||
result = await core.create_sandbox(sandbox_config)
|
||||
|
||||
@@ -173,21 +206,25 @@ async def create_sandbox(config: dict):
|
||||
return success_response(resource, status_code=status.HTTP_201_CREATED, headers={"Location": location})
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/v1/sandboxes", dependencies=[Depends(verify_api_key)])
|
||||
async def list_sandboxes(namespace: str | None = None):
|
||||
@app.get("/api/v1/sandboxes")
|
||||
async def list_sandboxes(namespace: str | None = None, key_data: dict = Depends(verify_api_key)):
|
||||
"""List all sandboxes."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
sandboxes = await core.list_sandboxes(namespace)
|
||||
return success_response([sandbox.to_dict() for sandbox in sandboxes])
|
||||
|
||||
|
||||
@app.get("/api/v1/sandboxes/{name}", dependencies=[Depends(verify_api_key)])
|
||||
async def get_sandbox(name: str, namespace: str = "default"):
|
||||
@app.get("/api/v1/sandboxes/{name}")
|
||||
async def get_sandbox(name: str, namespace: str = "default", key_data: dict = Depends(verify_api_key)):
|
||||
"""Get a single sandbox by name."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
items = await core.list_sandboxes(namespace)
|
||||
for s in items:
|
||||
@@ -196,9 +233,10 @@ async def get_sandbox(name: str, namespace: str = "default"):
|
||||
raise HTTPException(status_code=404, detail=f"Sandbox {name} not found in namespace {namespace}")
|
||||
|
||||
|
||||
@app.delete("/api/v1/sandboxes/{name}", dependencies=[Depends(verify_api_key)])
|
||||
async def delete_sandbox(name: str, namespace: str = "default"):
|
||||
@app.delete("/api/v1/sandboxes/{name}")
|
||||
async def delete_sandbox(name: str, namespace: str = "default", key_data: dict = Depends(verify_api_key)):
|
||||
"""Delete a sandbox."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.delete_sandbox(name, namespace)
|
||||
|
||||
@@ -208,9 +246,10 @@ async def delete_sandbox(name: str, namespace: str = "default"):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.delete("/api/v1/sandboxes", dependencies=[Depends(verify_api_key)])
|
||||
async def delete_all_sandboxes(namespace: str = "default"):
|
||||
@app.delete("/api/v1/sandboxes")
|
||||
async def delete_all_sandboxes(namespace: str = "default", key_data: dict = Depends(verify_api_key)):
|
||||
"""Delete all sandboxes in a namespace."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.delete_all_sandboxes(namespace)
|
||||
|
||||
@@ -220,8 +259,8 @@ async def delete_all_sandboxes(namespace: str = "default"):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes/{name}/pause", dependencies=[Depends(verify_api_key)])
|
||||
async def pause_sandbox(name: str, body: dict | None = None):
|
||||
@app.post("/api/v1/sandboxes/{name}/pause")
|
||||
async def pause_sandbox(name: str, body: dict | None = None, key_data: dict = Depends(verify_api_key)):
|
||||
"""Pause a sandbox (scale to 0) and optionally take a Longhorn VolumeSnapshot.
|
||||
|
||||
Body keys (all optional):
|
||||
@@ -230,6 +269,7 @@ async def pause_sandbox(name: str, body: dict | None = None):
|
||||
"""
|
||||
body = body or {}
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.pause_sandbox(
|
||||
name=name,
|
||||
@@ -241,11 +281,12 @@ async def pause_sandbox(name: str, body: dict | None = None):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes/{name}/resume", dependencies=[Depends(verify_api_key)])
|
||||
async def resume_sandbox(name: str, body: dict | None = None):
|
||||
@app.post("/api/v1/sandboxes/{name}/resume")
|
||||
async def resume_sandbox(name: str, body: dict | None = None, key_data: dict = Depends(verify_api_key)):
|
||||
"""Resume a paused sandbox (scale back to 1)."""
|
||||
body = body or {}
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.resume_sandbox(name=name, namespace=namespace)
|
||||
if result.success:
|
||||
@@ -253,8 +294,8 @@ async def resume_sandbox(name: str, body: dict | None = None):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes/{name}/fork", dependencies=[Depends(verify_api_key)])
|
||||
async def fork_sandbox(name: str, body: dict):
|
||||
@app.post("/api/v1/sandboxes/{name}/fork")
|
||||
async def fork_sandbox(name: str, body: dict, key_data: dict = Depends(verify_api_key)):
|
||||
"""Fork a kata-qemu-longhorn sandbox into a new name with a cloned root disk.
|
||||
|
||||
Required body key: new_name. Optional: namespace, snapshot.
|
||||
@@ -264,6 +305,7 @@ async def fork_sandbox(name: str, body: dict):
|
||||
if not new_name or not isinstance(new_name, str):
|
||||
raise HTTPException(status_code=400, detail="new_name is required")
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, namespace)
|
||||
snapshot = body.get("snapshot")
|
||||
core = K7Core()
|
||||
result = await core.fork_sandbox(
|
||||
@@ -289,15 +331,17 @@ async def fork_sandbox(name: str, body: dict):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.get("/api/v1/sandboxes/{name}/logs", dependencies=[Depends(verify_api_key)])
|
||||
@app.get("/api/v1/sandboxes/{name}/logs")
|
||||
async def get_sandbox_logs(
|
||||
name: str,
|
||||
namespace: str = "default",
|
||||
container: str = "sandbox",
|
||||
tail: int = 200,
|
||||
since: int = 0,
|
||||
key_data: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Read pod logs (snapshot; no streaming yet — see Spec 10g risks)."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.get_logs(
|
||||
sandbox_name=name,
|
||||
@@ -314,9 +358,15 @@ async def get_sandbox_logs(
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes/{name}/exec", dependencies=[Depends(verify_api_key)])
|
||||
async def exec_command(name: str, command_data: dict, namespace: str = "default"):
|
||||
@app.post("/api/v1/sandboxes/{name}/exec")
|
||||
async def exec_command(
|
||||
name: str,
|
||||
command_data: dict,
|
||||
namespace: str = "default",
|
||||
key_data: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""Execute a command in a sandbox."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
command = command_data.get("command", "")
|
||||
if not command:
|
||||
raise HTTPException(status_code=400, detail="Command is required")
|
||||
@@ -351,9 +401,10 @@ async def get_nodes_storage():
|
||||
return success_response(await core.nodes_storage())
|
||||
|
||||
|
||||
@app.get("/api/v1/sandboxes/metrics", dependencies=[Depends(verify_api_key)])
|
||||
async def get_sandbox_metrics(namespace: str | None = None):
|
||||
@app.get("/api/v1/sandboxes/metrics")
|
||||
async def get_sandbox_metrics(namespace: str | None = None, key_data: dict = Depends(verify_api_key)):
|
||||
"""Get resource usage metrics for sandboxes."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
metrics = await core.get_sandbox_metrics(namespace)
|
||||
return success_response(metrics)
|
||||
@@ -377,14 +428,16 @@ def _parse_keep_fork_for(value: str | None) -> timedelta:
|
||||
return timedelta(seconds=int(value))
|
||||
|
||||
|
||||
@app.get("/api/v1/snapshots", dependencies=[Depends(verify_api_key)])
|
||||
@app.get("/api/v1/snapshots")
|
||||
async def list_snapshots(
|
||||
namespace: str | None = "default",
|
||||
all_namespaces: bool = False,
|
||||
sandbox: str | None = None,
|
||||
kind: str | None = None,
|
||||
key_data: dict = Depends(verify_api_key),
|
||||
):
|
||||
"""List VolumeSnapshots, optionally filtered by namespace / sandbox / kind."""
|
||||
authorize_namespace(key_data, None if all_namespaces else namespace, all_namespaces=all_namespaces)
|
||||
core = K7Core()
|
||||
snaps = await core.list_snapshots(
|
||||
namespace=namespace,
|
||||
@@ -395,9 +448,10 @@ async def list_snapshots(
|
||||
return success_response([s.to_dict() for s in snaps])
|
||||
|
||||
|
||||
@app.get("/api/v1/snapshots/{name}", dependencies=[Depends(verify_api_key)])
|
||||
async def get_snapshot(name: str, namespace: str = "default"):
|
||||
@app.get("/api/v1/snapshots/{name}")
|
||||
async def get_snapshot(name: str, namespace: str = "default", key_data: dict = Depends(verify_api_key)):
|
||||
"""Inspect a single VolumeSnapshot by name."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
snap = await core.get_snapshot(name, namespace=namespace)
|
||||
if snap is None:
|
||||
@@ -405,8 +459,8 @@ async def get_snapshot(name: str, namespace: str = "default"):
|
||||
return success_response(snap.to_dict())
|
||||
|
||||
|
||||
@app.post("/api/v1/sandboxes/{name}/snapshot", dependencies=[Depends(verify_api_key)])
|
||||
async def create_snapshot(name: str, body: dict):
|
||||
@app.post("/api/v1/sandboxes/{name}/snapshot")
|
||||
async def create_snapshot(name: str, body: dict, key_data: dict = Depends(verify_api_key)):
|
||||
"""Snapshot a running sandbox's root PVC without pausing it (kind=named).
|
||||
|
||||
Body keys: ``snapshot_name`` (required), ``namespace`` (default ``"default"``).
|
||||
@@ -415,6 +469,7 @@ async def create_snapshot(name: str, body: dict):
|
||||
if not snapshot_name or not isinstance(snapshot_name, str):
|
||||
raise HTTPException(status_code=400, detail="snapshot_name is required")
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.create_snapshot(sandbox_name=name, snapshot_name=snapshot_name, namespace=namespace)
|
||||
if result.success:
|
||||
@@ -427,9 +482,10 @@ async def create_snapshot(name: str, body: dict):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.delete("/api/v1/snapshots/{name}", dependencies=[Depends(verify_api_key)])
|
||||
async def delete_snapshot(name: str, namespace: str = "default"):
|
||||
@app.delete("/api/v1/snapshots/{name}")
|
||||
async def delete_snapshot(name: str, namespace: str = "default", key_data: dict = Depends(verify_api_key)):
|
||||
"""Delete a VolumeSnapshot by name."""
|
||||
authorize_namespace(key_data, namespace)
|
||||
core = K7Core()
|
||||
result = await core.delete_snapshot(name, namespace=namespace)
|
||||
if result.success:
|
||||
@@ -439,8 +495,8 @@ async def delete_snapshot(name: str, namespace: str = "default"):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/snapshots/{name}/restore", dependencies=[Depends(verify_api_key)])
|
||||
async def restore_snapshot(name: str, body: dict):
|
||||
@app.post("/api/v1/snapshots/{name}/restore")
|
||||
async def restore_snapshot(name: str, body: dict, key_data: dict = Depends(verify_api_key)):
|
||||
"""Boot a brand-new sandbox from a standalone VolumeSnapshot (Spec 10f).
|
||||
|
||||
Body keys:
|
||||
@@ -455,6 +511,7 @@ async def restore_snapshot(name: str, body: dict):
|
||||
if not new_name or not isinstance(new_name, str):
|
||||
raise HTTPException(status_code=400, detail="new_sandbox_name is required")
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, namespace)
|
||||
keep_snapshot = bool(body.get("keep_snapshot", True))
|
||||
|
||||
overrides_dict = body.get("overrides") or {}
|
||||
@@ -493,8 +550,8 @@ async def restore_snapshot(name: str, body: dict):
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
|
||||
@app.post("/api/v1/snapshots/gc", dependencies=[Depends(verify_api_key)])
|
||||
async def gc_snapshots(body: dict | None = None):
|
||||
@app.post("/api/v1/snapshots/gc")
|
||||
async def gc_snapshots(body: dict | None = None, key_data: dict = Depends(verify_api_key)):
|
||||
"""Sweep stale ``kind=fork`` snapshots older than ``keep_fork_for``.
|
||||
|
||||
Body (all optional):
|
||||
@@ -504,11 +561,14 @@ async def gc_snapshots(body: dict | None = None):
|
||||
``dry_run`` (default ``false``).
|
||||
"""
|
||||
body = body or {}
|
||||
all_namespaces = bool(body.get("all_namespaces", False))
|
||||
namespace = body.get("namespace", "default")
|
||||
authorize_namespace(key_data, None if all_namespaces else namespace, all_namespaces=all_namespaces)
|
||||
keep_for = _parse_keep_fork_for(body.get("keep_fork_for"))
|
||||
core = K7Core()
|
||||
result = await core.gc_snapshots(
|
||||
namespace=body.get("namespace", "default"),
|
||||
all_namespaces=bool(body.get("all_namespaces", False)),
|
||||
namespace=namespace,
|
||||
all_namespaces=all_namespaces,
|
||||
keep_fork_for=keep_for,
|
||||
dry_run=bool(body.get("dry_run", False)),
|
||||
)
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ Version: __VERSION__
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: ${DEB_ARCH}
|
||||
Maintainer: K7 Team <support@example.com>
|
||||
Maintainer: K7 Team <hi@katakate.org>
|
||||
Description: K7 CLI for sandbox management
|
||||
Provides the \`k7\` command with embedded installer playbook.
|
||||
EOF
|
||||
|
||||
+30
-3
@@ -1519,7 +1519,16 @@ def top(
|
||||
|
||||
|
||||
@app.command()
|
||||
def generate_api_key(name: str, expires_days: int = typer.Option(365, help="API key expiration in days")):
|
||||
def generate_api_key(
|
||||
name: str,
|
||||
expires_days: int = typer.Option(365, help="API key expiration in days"),
|
||||
namespace: builtins.list[str] | None = typer.Option(
|
||||
None,
|
||||
"--namespace",
|
||||
"-n",
|
||||
help="Restrict key to this namespace (repeatable). Omit for unrestricted access.",
|
||||
),
|
||||
):
|
||||
"""Generate a new API key."""
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
@@ -1530,18 +1539,33 @@ def generate_api_key(name: str, expires_days: int = typer.Option(365, help="API
|
||||
api_keys = json.load(f)
|
||||
|
||||
expiry_timestamp = int((datetime.now() + timedelta(days=expires_days)).timestamp())
|
||||
api_keys[key_hash] = {
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"created": int(time.time()),
|
||||
"expires": expiry_timestamp,
|
||||
"last_used": None,
|
||||
}
|
||||
if namespace:
|
||||
# Preserve order, drop empties/duplicates.
|
||||
seen: set[str] = set()
|
||||
scoped: builtins.list[str] = []
|
||||
for ns in namespace:
|
||||
if ns and ns not in seen:
|
||||
seen.add(ns)
|
||||
scoped.append(ns)
|
||||
if scoped:
|
||||
entry["namespaces"] = scoped
|
||||
api_keys[key_hash] = entry
|
||||
|
||||
_write_api_keys(api_keys)
|
||||
|
||||
typer.echo(f"Generated API key for '{name}':")
|
||||
typer.echo(f"API Key: {api_key}")
|
||||
typer.echo(f"Expires: {datetime.fromtimestamp(expiry_timestamp)}")
|
||||
if entry.get("namespaces"):
|
||||
typer.echo(f"Namespaces: {', '.join(entry['namespaces'])}")
|
||||
else:
|
||||
typer.echo("Namespaces: * (unrestricted)")
|
||||
typer.echo("Keep this key secure - it won't be shown again!")
|
||||
|
||||
|
||||
@@ -1561,6 +1585,7 @@ def list_api_keys():
|
||||
table.add_column("Created", style="blue")
|
||||
table.add_column("Expires", style="yellow")
|
||||
table.add_column("Last Used", style="green")
|
||||
table.add_column("Namespaces", style="magenta")
|
||||
|
||||
for _key_hash, key_data in api_keys.items():
|
||||
created = datetime.fromtimestamp(key_data["created"]).strftime("%Y-%m-%d %H:%M")
|
||||
@@ -1568,8 +1593,10 @@ def list_api_keys():
|
||||
last_used = "Never"
|
||||
if key_data["last_used"]:
|
||||
last_used = datetime.fromtimestamp(key_data["last_used"]).strftime("%Y-%m-%d %H:%M")
|
||||
namespaces = key_data.get("namespaces") or []
|
||||
ns_col = "*" if not namespaces else ", ".join(namespaces)
|
||||
|
||||
table.add_row(key_data["name"], created, expires, last_used)
|
||||
table.add_row(key_data["name"], created, expires, last_used, ns_col)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
+96
-7
@@ -6,6 +6,7 @@ import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -214,6 +215,71 @@ class K7Core:
|
||||
repo = ref
|
||||
return registry, repo, tag
|
||||
|
||||
@staticmethod
|
||||
def _registry_hostname(registry: str) -> str:
|
||||
"""Extract hostname from a registry authority (host or host:port or [ipv6]:port)."""
|
||||
if not registry or not isinstance(registry, str):
|
||||
raise ValueError("Registry host must be a non-empty string")
|
||||
if registry.startswith("["):
|
||||
end = registry.find("]")
|
||||
if end == -1:
|
||||
raise ValueError(f"Invalid registry host: {registry}")
|
||||
return registry[1:end]
|
||||
if ":" in registry:
|
||||
host, maybe_port = registry.rsplit(":", 1)
|
||||
if maybe_port.isdigit():
|
||||
return host
|
||||
return registry
|
||||
|
||||
def _registry_allowlist(self) -> set[str]:
|
||||
"""Default public registries, extended by ``K7_REGISTRY_ALLOWLIST`` (comma-separated)."""
|
||||
allow = {"registry-1.docker.io", "ghcr.io", "quay.io", "public.ecr.aws"}
|
||||
extra = os.environ.get("K7_REGISTRY_ALLOWLIST", "").strip()
|
||||
if extra:
|
||||
allow |= {h.strip().lower() for h in extra.split(",") if h.strip()}
|
||||
return allow
|
||||
|
||||
def _assert_registry_host_allowed(self, registry: str) -> None:
|
||||
"""Reject registry hosts that resolve to non-public addresses (SSRF guard).
|
||||
|
||||
Always-on backstop: every resolved A/AAAA must be a public unicast address
|
||||
(not loopback/private/link-local/reserved/multicast/unspecified). Tightening
|
||||
layer: hostname must be in the allowlist (defaults + ``K7_REGISTRY_ALLOWLIST``).
|
||||
"""
|
||||
hostname = self._registry_hostname(registry).lower()
|
||||
if hostname in {"localhost", "metadata.google.internal"}:
|
||||
raise ValueError(f"Registry host not allowed: {registry}")
|
||||
|
||||
allowlist = self._registry_allowlist()
|
||||
if hostname not in allowlist:
|
||||
raise ValueError(
|
||||
f"Registry host not allowed: {registry} (not in allowlist; set K7_REGISTRY_ALLOWLIST to extend)"
|
||||
)
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as e:
|
||||
raise ValueError(f"Cannot resolve registry host {hostname}: {e}") from e
|
||||
if not infos:
|
||||
raise ValueError(f"Cannot resolve registry host {hostname}: no addresses")
|
||||
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ValueError(f"Registry host not allowed: {registry} resolves to non-public address {ip_str}")
|
||||
|
||||
async def _assert_registry_host_allowed_async(self, registry: str) -> None:
|
||||
"""Async wrapper so DNS resolution does not block the API event loop."""
|
||||
await asyncio.to_thread(self._assert_registry_host_allowed, registry)
|
||||
|
||||
async def _get_registry_image_config(self, image: str) -> dict:
|
||||
"""Fetch the OCI image config from a container registry.
|
||||
|
||||
@@ -221,17 +287,20 @@ class K7Core:
|
||||
OCI-compliant registry that supports anonymous pulls.
|
||||
"""
|
||||
registry, repo, tag = self._parse_image_reference(image)
|
||||
scheme = "http" if registry == "localhost" or registry.startswith("localhost:") else "https"
|
||||
base = f"{scheme}://{registry}"
|
||||
await self._assert_registry_host_allowed_async(registry)
|
||||
# Always HTTPS — the previous localhost→http downgrade was an SSRF footgun.
|
||||
base = f"https://{registry}"
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
if "docker.io" in registry:
|
||||
async with httpx.AsyncClient(follow_redirects=False) as http_client:
|
||||
# Token endpoint is a fixed public host; only for the real Docker Hub registry.
|
||||
if registry in {"registry-1.docker.io", "docker.io"}:
|
||||
try:
|
||||
token_resp = await http_client.get(
|
||||
f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull",
|
||||
timeout=10,
|
||||
follow_redirects=False,
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
token = token_resp.json()["token"]
|
||||
@@ -250,7 +319,7 @@ class K7Core:
|
||||
headers["Accept"] = accept
|
||||
|
||||
manifest_url = f"{base}/v2/{repo}/manifests/{tag}"
|
||||
manifest_resp = await http_client.get(manifest_url, headers=headers, timeout=15)
|
||||
manifest_resp = await http_client.get(manifest_url, headers=headers, timeout=15, follow_redirects=False)
|
||||
manifest_resp.raise_for_status()
|
||||
manifest = manifest_resp.json()
|
||||
|
||||
@@ -265,7 +334,10 @@ class K7Core:
|
||||
"application/vnd.docker.distribution.manifest.v2+json"
|
||||
)
|
||||
inner_resp = await http_client.get(
|
||||
f"{base}/v2/{repo}/manifests/{digest}", headers=headers, timeout=15
|
||||
f"{base}/v2/{repo}/manifests/{digest}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
follow_redirects=False,
|
||||
)
|
||||
inner_resp.raise_for_status()
|
||||
manifest = inner_resp.json()
|
||||
@@ -277,7 +349,12 @@ class K7Core:
|
||||
if not config_digest:
|
||||
raise ValueError(f"No config digest in manifest for {image}")
|
||||
|
||||
config_resp = await http_client.get(f"{base}/v2/{repo}/blobs/{config_digest}", headers=headers, timeout=15)
|
||||
config_resp = await http_client.get(
|
||||
f"{base}/v2/{repo}/blobs/{config_digest}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
follow_redirects=False,
|
||||
)
|
||||
config_resp.raise_for_status()
|
||||
return config_resp.json()
|
||||
|
||||
@@ -291,6 +368,12 @@ class K7Core:
|
||||
ep = self._normalize_image_argv(container_config.get("Entrypoint"))
|
||||
cmd = self._normalize_image_argv(container_config.get("Cmd"))
|
||||
return ep, cmd
|
||||
except ValueError as e:
|
||||
# SSRF / allowlist rejection must fail loud — do not soft-fail to [].
|
||||
msg = str(e).lower()
|
||||
if "not allowed" in msg or "cannot resolve registry" in msg:
|
||||
raise
|
||||
return [], []
|
||||
except Exception:
|
||||
return [], []
|
||||
|
||||
@@ -1584,6 +1667,12 @@ class K7Core:
|
||||
if config.limits and not self._validate_limits(config.limits):
|
||||
return OperationResult(success=False, error="Invalid resource limits")
|
||||
|
||||
# SSRF guard: refuse image registries that resolve to non-public
|
||||
# addresses before any control-plane HTTP or cluster create work.
|
||||
if config.image:
|
||||
registry, _, _ = self._parse_image_reference(config.image)
|
||||
await self._assert_registry_host_allowed_async(registry)
|
||||
|
||||
apps_v1 = await self._get_apps_v1_client()
|
||||
v1 = await self._get_core_v1_client()
|
||||
networking_v1 = await self._get_networking_v1_client()
|
||||
|
||||
@@ -8,4 +8,4 @@ __all__ = [
|
||||
"SandboxProxy",
|
||||
]
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
Reference in New Issue
Block a user