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:
G
2026-08-16 00:16:48 +02:00
parent ecb7630168
commit a939e693d0
23 changed files with 772 additions and 70 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""K7 Sandbox Management System"""
__version__ = "0.2.0"
__version__ = "0.2.1"
+94 -34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -8,4 +8,4 @@ __all__ = [
"SandboxProxy",
]
__version__ = "0.2.0"
__version__ = "0.2.1"