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
+30
View File
@@ -297,3 +297,33 @@ the shim↔agent ttrpc channel).
**Time lost:** ~3 h (bench-faithful repro, guest-side dmesg/meminfo
streaming, two disproven hypotheses, virtiofsd A/B).
## 11. Control-plane SSRF via sandbox `image` + missing API-key namespace authz (spec 10h)
**Symptom:** Authenticated callers of `k7-api` 0.2.0 could point the
control plane at internal/loopback/metadata addresses by supplying a
crafted container `image` (e.g. `169.254.169.254/...` or
`127.0.0.1:PORT/...`). Separately, any valid API key could operate on any
Kubernetes namespace — keys were authenticated but not authorized.
**Root cause:** `_get_registry_image_config` parsed the registry host
straight from the user-controlled image reference and issued `httpx`
GETs with no allowlist and no private/loopback/link-local rejection; the
`localhost` case even downgraded to plaintext `http`. On the authz side,
`verify_api_key` returned key metadata that no handler consulted, and
`namespace` was a free query/body parameter on every route.
**Fix:**
- `_assert_registry_host_allowed` — allowlist (default public registries +
`K7_REGISTRY_ALLOWLIST`) plus resolve-and-deny for non-public addresses;
called before any registry HTTP; `follow_redirects=False`; localhost→http
downgrade removed. Also enforced early in `create_sandbox`.
- Optional `"namespaces": [...]` on API key records; CLI
`generate-api-key -n`; `authorize_namespace` applied on every
namespace-bearing endpoint. Absent/empty scope remains unrestricted.
**Reference:** Responsible disclosure against `k7-api` 0.2.0
(SSRF ≈ CVSS 7.1; missing namespace authz ≈ CVSS 9.1 in multi-tenant).
spec 10h-security-ssrf-and-namespace-authz.
**Time lost:** n/a (implemented from disclosure + spec).
+34
View File
@@ -5,6 +5,40 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.1] — 2026-08-15
Security release. Everyone running the `k7-api` control plane on 0.2.0 or
earlier should upgrade. Both issues were reported privately by
**Jirayu Thongchotchaung** ([@JirayuThongchotchaung](https://github.com/JirayuThongchotchaung)),
who held disclosure until this release was available — thank you.
### Fixed
- **Server-side request forgery via the sandbox `image` registry host**
(CWE-918). A sandbox creation request could name a registry host that
resolves to a loopback, link-local, or private address and make the
control plane issue the OCI fetch on the caller's behalf. Registry hosts
are now resolved and checked against public/allowlisted ranges *before*
any fetch, the `localhost` → plain-HTTP downgrade is gone, and redirects
are disabled so an allowlisted host cannot bounce the request inward.
Resolution runs off the event loop, so the check cannot stall the API.
### Added
- **Optional per-key namespace authorization** (CWE-862 / CWE-285).
API keys can be scoped to one or more namespaces with
`k7 generate-api-key -n <namespace>`, enforced on every namespace-bearing
endpoint; a scoped key cannot read or mutate another namespace and cannot
perform all-namespaces operations. Keys without a scope keep their
previous unrestricted behaviour, so this is backward compatible — scope
your keys to benefit from it.
### Changed
- `SECURITY.md` states the supported release line accurately.
- Debian packaging targets `amd64` explicitly and no longer runs the test
suite inside build chroots, which is what the Launchpad PPA needs.
## [0.2.0] — 2026-08-11
First public release. Ships the CLI/API deb and PyPI `k7-sdk`.
+22
View File
@@ -248,3 +248,25 @@ Two takeaways (spec 18f issue 8 / 18h):
takes >1 s while dockerd copies vfs layers), which cuts the
`ttrpc: received message on inactive stream` noise but was NOT
sufficient on its own.
## k7d docker-perf leg (2026-08-11, partial)
`test_bench_k7d` is wired (`K7_BENCH_ENVS=k7d`). Guest sized at **3Gi /
4 vCPU** — largest size that reliably reaches Ready on this k7d build;
≥~4Gi fails agent connect (`Connection timed out`, MMIO base moves past
4 GiB). At 3Gi the guest tmpfs upper is ~1.5 GiB, which is enough for
`docker pull debian:12-slim` but not for a sustained no-cache build of
`bench.Dockerfile` (apt+pip layers already ~1.4 GiB before the cpython
clone / 256 MB `dd`).
Measured on the 3-node HA cluster (pod on k7-node-02), 3 reps, no warmup:
| op | k7d (3Gi) | notes |
|---|---|---|
| pull debian:12-slim | **9.99 s** (9.8810.19) | overlay2; slow vs kql's ~2.4 s lifecycle-bench pull — same NAT/tmpfs path |
| build / run_* | n/a | hits ENOSPC / guest wedge mid-build under the 1.5 GiB tmpfs ceiling |
One-shot smoke (same limits, single `docker build --no-cache`) completed in
~302 s earlier the same day — reproducible multi-rep builds did not.
Unblocking the full column needs a k7d fix for ≥4 GiB guests (or a larger
non-tmpfs docker data disk).
+21 -5
View File
@@ -2,10 +2,13 @@
## Supported Versions
This project is pre-1.0 (targeting **0.1.0** for the public cut; working
tree may still show `0.0.4-dev`) and under active development. Breaking
changes may occur until 1.0.0. Security fixes land on the latest release
line only.
This project is pre-1.0 and under active development; breaking changes may
occur until 1.0.0. Security fixes land on the latest release line only.
| Version | Supported |
|---------|-----------|
| 0.2.1 and later | Yes |
| 0.2.0 and earlier | No — upgrade to 0.2.1 |
## Reporting a Vulnerability
@@ -43,7 +46,18 @@ Do **not** open a public issue for security-sensitive reports.
of the VM boundary.
- The control plane API uses API keys with hashed storage and expiry
(file-backed by default at `/etc/k7/api_keys.json` — rotate and protect
that file).
that file). Keys may optionally be scoped to one or more namespaces
(`k7 generate-api-key -n <ns>`); absent/empty scope keeps the historical
unrestricted behaviour (backward compatible). Scoped keys are enforced
on every namespace-bearing endpoint — they cannot list across all
namespaces or touch namespaces outside their list.
- Control-plane OCI registry inspection (used to resolve image
entrypoint/cmd) rejects registry hosts that are not on an allowlist
(default: `registry-1.docker.io`, `ghcr.io`, `quay.io`, `public.ecr.aws`;
extend via `K7_REGISTRY_ALLOWLIST`) and rejects any host that resolves
to loopback/private/link-local/metadata/reserved addresses. Redirect
following is disabled. The previous `localhost``http` downgrade path
has been removed.
- **Ingress** to sandboxes is denied by default (NetworkPolicy).
**Egress** is per-sandbox: open, blocked, CIDR allowlist, or **FQDN**
allowlist when Cilium is the CNI (default). DNS is blocked by default
@@ -57,6 +71,8 @@ See also the docs: security model, networking, and backends comparison.
- No rate limiting or abuse protection at the API layer yet.
- API key storage is local file-backed; treat the API host as trusted.
Namespace scoping is an opt-in tenancy boundary on top of that model —
unscoped keys still have full cross-namespace control-plane access.
- Young project; no independent security audit yet.
- The `k7d` backend has a different isolation trade-off for CoW sibling
forks — see k7d's `SECURITY.md`.
+1 -1
View File
@@ -35,7 +35,7 @@ from pathlib import Path
from statistics import median
# Stable column ordering for the rendered tables.
ENVS: list[str] = ["host", "k7-fd", "k7-ql-r1", "k7-ql-r2", "k7-ql-r3"]
ENVS: list[str] = ["host", "k7-fd", "k7-ql-r1", "k7-ql-r2", "k7-ql-r3", "k7d"]
# Stable row ordering, plus the human-readable column label for each op.
OP_DISPLAY: list[tuple[str, str]] = [
+6
View File
@@ -1,3 +1,9 @@
k7 (0.2.1) noble; urgency=medium
* Release 0.2.1.
-- Katakate <hi@katakate.org> Sat, 15 Aug 2026 19:29:31 +0200
k7 (0.2.0) noble; urgency=medium
* Release 0.2.0.
+1 -1
View File
@@ -18,7 +18,7 @@ Homepage: https://katakate.org
Rules-Requires-Root: no
Package: k7
Architecture: any
Architecture: amd64
Depends: ${shlibs:Depends}, ${misc:Depends}
Recommends: docker.io, docker-compose-plugin, kubectl | k3s, ansible
Description: K7 CLI for sandbox management
+4
View File
@@ -44,6 +44,10 @@ override_dh_auto_install:
# Use dh_install via debian/k7.install; do not run project Makefile install
true
override_dh_auto_test:
# Packaging ships a prebuilt Nuitka onefile; do not run the pytest suite in chroots
true
override_dh_strip:
# Do not strip the Nuitka onefile; payload is appended to the ELF
true
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "k7"
version = "0.2.0"
version = "0.2.1"
description = "Self-hosted VM sandboxes for untrusted and AI code (CLI, API, SDK)"
readme = "README.md"
requires-python = ">=3.10.11"
+1 -1
View File
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
setup(
name="k7-sdk",
version="0.2.0",
version="0.2.1",
description="K7 sandbox management Python SDK (HTTP client for the k7 API)",
long_description=Path(__file__).with_name("README.md").read_text(),
long_description_content_type="text/markdown",
+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"
+41 -9
View File
@@ -1,4 +1,4 @@
"""Spec 10b/18e: Docker workload benchmark — host vs k7-fd vs k7-ql (r=1..3).
"""Spec 10b/18e/18h: Docker workload benchmark — host vs k7-fd vs k7-ql vs k7d.
Why a pytest module rather than a separate bash harness:
``tests/integration/test_sidecar_docker.py`` already pioneered the pattern
@@ -7,27 +7,28 @@ docker commands via ``k7_core.exec_command``." This benchmark just times
that same pattern no new infrastructure, no shelling-out to a separate
script, no fiddly Alpine-vs-bash portability problems.
Why all four environments are valid:
Why these environments are valid:
``test_sidecar_docker.py`` has a note claiming Docker-DinD doesn't work on
the Firecracker backend. That note is wrong confirmed by a manual probe
on the multi-node cluster: ``k7 create --backend kfd --sidecar docker`` +
``docker run hello-world`` exits 0 in ~3s. The sidecar wiring in
``core.py`` (around line 1537) is symmetric across backends; the only
per-backend branch is where the docker daemon's ``/var/lib/docker`` lives
(emptyDir on fd, Longhorn PVC sub_path on ql), which is exactly the
isolation we want from a benchmark:
``core.py`` is symmetric across backends; the only per-backend branch is
where the docker daemon's ``/var/lib/docker`` lives (emptyDir on fd/k7d,
Longhorn PVC sub_path on ql), which is exactly the isolation we want:
- ``k7-fd`` docker-in-VM without Longhorn in the storage path.
- ``k7-ql-r1`` adds Longhorn r=1 (one local replica).
- ``k7-ql-r2`` adds Longhorn r=2 (one local + one cross-node sync).
- ``k7-ql-r3`` Longhorn r=3 (replicas on all three nodes of an HA
cluster; spec 18e the full redundancy write-amplification cost).
- ``k7d`` docker-in-VM on k7d (dind data on guest tmpfs / emptyDir;
no Longhorn). Needs a large ``--memory`` because layers live in RAM.
Marker / invocation:
This file uses ``@pytest.mark.bench`` so it doesn't run with the rest of
``-m integration``. Drive it explicitly:
K7_BENCH_ENVS=host,k7-fd,k7-ql-r1,k7-ql-r2,k7-ql-r3 \\
K7_BENCH_ENVS=host,k7-fd,k7-ql-r1,k7-ql-r2,k7-ql-r3,k7d \\
K7_BENCH_OUT=/tmp/bench-out \\
uv run pytest -m bench tests/integration/bench_docker_perf.py -v
@@ -333,6 +334,7 @@ async def _bench_sandbox(
backend_extra: dict[str, str],
out_dir: Path,
longhorn_replicas: int | None = None,
limits: dict[str, str] | None = None,
) -> Path:
name = f"bench-{label.replace('_', '-')}"
cfg = SandboxConfig(
@@ -345,9 +347,10 @@ async def _bench_sandbox(
# + the 2k-files / 512 MB workloads. Default 10Gi is the minimum;
# keep it explicit for reproducibility — and only meaningful when
# the root disk is a Longhorn PVC (kata-qemu-longhorn). The firecracker
# backend ignores this and the docker daemon's data lives on the
# sidecar's emptyDir.
# / k7d backends ignore this and the docker daemon's data lives on
# the sidecar's emptyDir (guest tmpfs on k7d — size via ``limits``).
root_disk_size="20Gi",
limits=limits,
)
create = await k7_core.create_sandbox(cfg)
assert create.success, f"create {name}: {create.error}"
@@ -678,3 +681,32 @@ async def test_bench_k7_ql_r3(
"""Longhorn r=3 — the full redundancy cost on a 3-node HA cluster."""
_maybe_skip("k7-ql-r3")
await _bench_ql(k7_core, test_namespace, bench_out_dir, bench_logs, replicas=3)
@pytest.mark.k7d
async def test_bench_k7d(
k7_core: K7Core,
test_namespace: str,
bench_out_dir: Path,
bench_logs: list[Path],
):
"""k7d docker-in-VM — dind data on guest tmpfs (no Longhorn).
3Gi guest + 4 vCPU: largest size that reliably reaches Ready today.
4Gi currently breaks k7d agent connect on create
(``Connection timed out`` / ``No such device``) use 3Gi until
that is root-caused. Guest overlay scales with memory (~1.5 Gi at
3Gi), so layers + the 512 MB fsync share that budget.
"""
_maybe_skip("k7d")
log = await _bench_sandbox(
k7_core,
label="k7d",
namespace=test_namespace,
backend="k7d",
backend_extra={"docker_data_path": "emptyDir/tmpfs"},
out_dir=bench_out_dir,
limits={"memory": "3Gi", "cpu": "4"},
)
bench_logs.append(log)
print(f"\n[bench] k7d log → {log}")
+177 -3
View File
@@ -51,7 +51,7 @@ def _api_pod_ready() -> bool:
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
def _generate_test_api_key() -> str:
def _generate_test_api_key(*, name: str = "integration-test", namespaces: list[str] | None = None) -> str:
"""Write a test API key directly into the keys file, return the raw token."""
import os
import secrets as _secrets
@@ -63,12 +63,15 @@ def _generate_test_api_key() -> str:
if K7_API_KEYS_FILE.exists():
keys = json.loads(K7_API_KEYS_FILE.read_text())
keys[key_hash] = {
"name": "integration-test",
entry: dict = {
"name": name,
"created": int(time.time()),
"expires": int(time.time()) + 3600,
"last_used": None,
}
if namespaces:
entry["namespaces"] = list(namespaces)
keys[key_hash] = entry
K7_API_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
K7_API_KEYS_FILE.write_text(json.dumps(keys, indent=2))
# Mirror production permissions (`k7 generate-api-key`): 0600 owned by
@@ -600,3 +603,174 @@ class TestSdkSandboxProxy:
out = forked.exec("cat /mnt/state/marker")
assert "sdk-fork-marker" in out["stdout"]
# ---------------------------------------------------------------------------
# Spec 10h: SSRF guard + API-key namespace authorization
# ---------------------------------------------------------------------------
class TestApiSsrfGuard:
"""Control-plane must not fetch OCI manifests from internal/loopback hosts."""
def test_loopback_image_rejected_and_listener_untouched(
self, api_base_url: str, api_headers: dict, test_namespace: str
):
port = 8199
# Tiny python listener with a hit counter. Bound on all interfaces so a
# buggy control-plane fetch via the node IP would also be visible; the
# image uses 127.0.0.1 which the guard must reject before any dial.
counter = Path("/tmp/k7-ssrf-listener-hits")
if counter.exists():
counter.unlink()
listener = subprocess.Popen(
[
"python3",
"-c",
(
"import socket, pathlib\n"
f"p=pathlib.Path({str(counter)!r})\n"
"s=socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n"
f"s.bind(('0.0.0.0', {port})); s.listen(5); s.settimeout(8)\n"
"try:\n"
" c,_=s.accept(); p.write_text('hit'); c.close()\n"
"except Exception:\n"
" pass\n"
"finally:\n"
" s.close()\n"
),
],
)
try:
time.sleep(0.4)
r = httpx.post(
f"{api_base_url}/api/v1/sandboxes",
json={
"name": "ssrf-loopback",
"image": f"127.0.0.1:{port}/x:latest",
"namespace": test_namespace,
},
headers=api_headers,
timeout=15,
)
assert r.status_code >= 400, f"expected error, got {r.status_code}: {r.text}"
assert "not allowed" in r.text.lower() or "registry" in r.text.lower()
time.sleep(0.5)
assert not counter.exists(), "listener received a connection — SSRF guard failed"
finally:
listener.kill()
try:
listener.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
def test_metadata_image_rejected(self, api_base_url: str, api_headers: dict, test_namespace: str):
r = httpx.post(
f"{api_base_url}/api/v1/sandboxes",
json={
"name": "ssrf-metadata",
"image": "169.254.169.254/latest/meta-data:latest",
"namespace": test_namespace,
},
headers=api_headers,
timeout=15,
)
assert r.status_code >= 400
assert "not allowed" in r.text.lower() or "registry" in r.text.lower()
def test_public_image_create_still_works(
self, api_base_url: str, api_headers: dict, test_namespace: str, cleanup_sandbox
):
name = "ssrf-public-ok"
cleanup_sandbox(name, test_namespace)
r = httpx.post(
f"{api_base_url}/api/v1/sandboxes",
json={"name": name, "image": "alpine:3.21", "namespace": test_namespace},
headers=api_headers,
timeout=30,
)
assert r.status_code == 201, f"create failed: {r.text}"
_wait_for_sandbox_ready(api_base_url, api_headers, name, test_namespace)
class TestApiNamespaceAuthz:
"""Scoped API keys may only operate in their listed namespaces."""
def test_scoped_key_alpha_ok_beta_forbidden(self, api_base_url: str, test_namespace: str, cleanup_sandbox):
# Ensure namespaces exist (create via unscoped key / kubectl).
for ns in ("alpha", "beta"):
subprocess.run(
["k3s", "kubectl", "create", "namespace", ns],
capture_output=True,
text=True,
)
scoped = _generate_test_api_key(name="integ-scoped-alpha", namespaces=["alpha"])
scoped_headers = {"X-API-Key": scoped}
unscoped = _generate_test_api_key(name="integ-unscoped")
unscoped_headers = {"X-API-Key": unscoped}
name = "ns-authz-sb"
cleanup_sandbox(name, "alpha")
cleanup_sandbox(name, "beta")
# Scoped key: create in alpha OK
r = httpx.post(
f"{api_base_url}/api/v1/sandboxes",
json={"name": name, "image": "alpine:3.21", "namespace": "alpha"},
headers=scoped_headers,
timeout=30,
)
assert r.status_code == 201, f"scoped create alpha failed: {r.text}"
# List/delete in alpha OK
lr = httpx.get(
f"{api_base_url}/api/v1/sandboxes",
params={"namespace": "alpha"},
headers=scoped_headers,
timeout=10,
)
assert lr.status_code == 200
# beta denied
br = httpx.get(
f"{api_base_url}/api/v1/sandboxes",
params={"namespace": "beta"},
headers=scoped_headers,
timeout=10,
)
assert br.status_code == 403
br2 = httpx.post(
f"{api_base_url}/api/v1/sandboxes",
json={"name": name, "image": "alpine:3.21", "namespace": "beta"},
headers=scoped_headers,
timeout=15,
)
assert br2.status_code == 403
# all-namespaces list denied
ar = httpx.get(
f"{api_base_url}/api/v1/sandboxes",
headers=scoped_headers,
timeout=10,
)
assert ar.status_code == 403
# Unscoped key still works on beta
ur = httpx.get(
f"{api_base_url}/api/v1/sandboxes",
params={"namespace": "beta"},
headers=unscoped_headers,
timeout=10,
)
assert ur.status_code == 200
# Cleanup via scoped key in alpha
dr = httpx.delete(
f"{api_base_url}/api/v1/sandboxes/{name}",
params={"namespace": "alpha"},
headers=scoped_headers,
timeout=30,
)
assert dr.status_code == 200, dr.text
+47 -1
View File
@@ -8,8 +8,9 @@ from unittest.mock import patch
import httpx
import pytest
from fastapi import HTTPException
from k7.api.main import app, load_api_keys
from k7.api.main import app, authorize_namespace, load_api_keys
TEST_KEY = "k7-test-secret-key-abc123"
TEST_KEY_HASH = hashlib.sha256(TEST_KEY.encode()).hexdigest()
@@ -19,12 +20,15 @@ def _make_keys_data(
*,
expires: int | None = None,
last_used: int | None = None,
namespaces: list[str] | None = None,
) -> dict:
entry: dict = {"name": "test-key"}
if expires is not None:
entry["expires"] = expires
if last_used is not None:
entry["last_used"] = last_used
if namespaces is not None:
entry["namespaces"] = namespaces
return {TEST_KEY_HASH: entry}
@@ -139,3 +143,45 @@ class TestVerifyApiKey:
json={"name": "t", "image": "alpine"},
)
assert resp.status_code == 401
# --- authorize_namespace ---
class TestAuthorizeNamespace:
def test_unrestricted_key_allowed_everywhere(self):
authorize_namespace({"name": "u"}, "alpha")
authorize_namespace({"name": "u", "namespaces": []}, "beta")
authorize_namespace({"name": "u"}, None, all_namespaces=True)
def test_scoped_key_allowed_in_listed_namespace(self):
authorize_namespace({"namespaces": ["alpha", "gamma"]}, "alpha")
def test_scoped_key_denied_other_namespace(self):
with pytest.raises(HTTPException) as exc:
authorize_namespace({"namespaces": ["alpha"]}, "beta")
assert exc.value.status_code == 403
def test_scoped_key_denied_all_namespaces(self):
with pytest.raises(HTTPException) as exc:
authorize_namespace({"namespaces": ["alpha"]}, "alpha", all_namespaces=True)
assert exc.value.status_code == 403
def test_scoped_key_denied_implicit_all(self):
with pytest.raises(HTTPException) as exc:
authorize_namespace({"namespaces": ["alpha"]}, None)
assert exc.value.status_code == 403
async def test_scoped_key_list_other_namespace_returns_403(self, _patch_keys_file, keys_file: Path):
future_ts = int(time.time()) + 86400
data = _make_keys_data(expires=future_ts, namespaces=["alpha"])
keys_file.write_text(json.dumps(data))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
"/api/v1/sandboxes",
headers={"X-API-Key": TEST_KEY},
params={"namespace": "beta"},
)
assert resp.status_code == 403
+64
View File
@@ -0,0 +1,64 @@
"""Unit tests for API key generation / namespace scoping (spec 10h)."""
import json
from pathlib import Path
from unittest.mock import patch
from typer.testing import CliRunner
from k7.cli.k7 import app
runner = CliRunner()
class TestGenerateApiKeyNamespaces:
def test_scoped_namespaces_persisted(self, tmp_path: Path):
keys_file = tmp_path / "api_keys.json"
with patch("k7.cli.k7.API_KEYS_FILE", keys_file):
result = runner.invoke(
app,
["generate-api-key", "scoped", "-n", "a", "--namespace", "b"],
)
assert result.exit_code == 0, result.output
data = json.loads(keys_file.read_text())
assert len(data) == 1
entry = next(iter(data.values()))
assert entry["name"] == "scoped"
assert entry["namespaces"] == ["a", "b"]
def test_unscoped_key_omits_namespaces_field(self, tmp_path: Path):
keys_file = tmp_path / "api_keys.json"
with patch("k7.cli.k7.API_KEYS_FILE", keys_file):
result = runner.invoke(app, ["generate-api-key", "open"])
assert result.exit_code == 0, result.output
data = json.loads(keys_file.read_text())
entry = next(iter(data.values()))
assert "namespaces" not in entry
def test_list_shows_namespaces_column(self, tmp_path: Path):
keys_file = tmp_path / "api_keys.json"
keys_file.write_text(
json.dumps(
{
"h1": {
"name": "scoped",
"created": 1,
"expires": 2,
"last_used": None,
"namespaces": ["alpha"],
},
"h2": {
"name": "open",
"created": 1,
"expires": 2,
"last_used": None,
},
}
)
)
with patch("k7.cli.k7.API_KEYS_FILE", keys_file):
result = runner.invoke(app, ["list-api-keys"])
assert result.exit_code == 0, result.output
assert "Namespaces" in result.output
assert "alpha" in result.output
assert "*" in result.output
+10
View File
@@ -2,6 +2,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from k7.core.core import K7Core
# --- _parse_image_reference ---
@@ -194,6 +196,14 @@ def _patch_httpx(get_fn):
class TestGetRegistryImageConfig:
@pytest.fixture(autouse=True)
def _public_dns(self, monkeypatch):
"""Keep SSRF allowlist checks offline-friendly in unit tests."""
monkeypatch.setattr(
"k7.core.core.socket.getaddrinfo",
lambda *a, **k: [(None, None, None, None, ("1.1.1.1", 0))],
)
async def test_simple_manifest(self, core: K7Core):
"""Non-Docker-Hub registry with a direct manifest (not a list)."""
manifest = {
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for the control-plane registry SSRF guard (spec 10h)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from k7.core.core import K7Core
def _public_addrinfo(host: str = "1.2.3.4"):
"""Minimal getaddrinfo return shape: one public IPv4."""
return [(None, None, None, None, (host, 0))]
def _private_addrinfo(host: str = "10.0.0.5"):
return [(None, None, None, None, (host, 0))]
class TestAssertRegistryHostAllowed:
def test_rejects_link_local_metadata(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("169.254.169.254")
def test_rejects_loopback_ip(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("127.0.0.1")
def test_rejects_localhost(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("localhost")
def test_rejects_localhost_with_port(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("localhost:5000")
def test_rejects_rfc1918_10(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("10.0.0.5")
def test_rejects_rfc1918_192(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("192.168.1.1")
def test_rejects_rfc1918_172(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("172.16.0.1")
def test_rejects_ipv6_loopback(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("[::1]")
def test_rejects_unspecified(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
core._assert_registry_host_allowed("0.0.0.0")
def test_rejects_hostname_resolving_to_private(self, core: K7Core, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("K7_REGISTRY_ALLOWLIST", "evil.example.com")
monkeypatch.setattr(
"k7.core.core.socket.getaddrinfo",
lambda *a, **k: _private_addrinfo("10.1.2.3"),
)
with pytest.raises(ValueError, match="non-public address"):
core._assert_registry_host_allowed("evil.example.com")
@pytest.mark.parametrize("host", ["registry-1.docker.io", "ghcr.io", "quay.io"])
def test_accepts_public_registries(self, core: K7Core, monkeypatch: pytest.MonkeyPatch, host: str):
monkeypatch.setattr(
"k7.core.core.socket.getaddrinfo",
lambda *a, **k: _public_addrinfo("1.1.1.1"),
)
core._assert_registry_host_allowed(host)
class TestGetRegistryImageConfigSsrf:
async def test_blocked_host_never_calls_http(self, core: K7Core):
mock_cls = MagicMock()
mock_client = AsyncMock()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
with patch("k7.core.core.httpx.AsyncClient", mock_cls), pytest.raises(ValueError, match="not allowed"):
await core._get_registry_image_config("127.0.0.1:8199/x:latest")
mock_client.get.assert_not_called()
async def test_entrypoint_cmd_rethrows_host_rejection(self, core: K7Core):
with pytest.raises(ValueError, match="not allowed"):
await core._get_image_entrypoint_cmd("169.254.169.254/meta:latest")
Generated
+1 -1
View File
@@ -1069,7 +1069,7 @@ wheels = [
[[package]]
name = "k7"
version = "0.2.0"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },