diff --git a/CHALLENGES.md b/CHALLENGES.md index 4dee48b..b02f27f 100644 --- a/CHALLENGES.md +++ b/CHALLENGES.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index a85ad76..b7a2bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, 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`. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 52aca40..3eb6437 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -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.88–10.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). diff --git a/SECURITY.md b/SECURITY.md index 3302bf0..95abf81 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 `); 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`. diff --git a/bench/docker-perf/render.py b/bench/docker-perf/render.py index 6c14962..db7ad7d 100644 --- a/bench/docker-perf/render.py +++ b/bench/docker-perf/render.py @@ -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]] = [ diff --git a/debian/changelog b/debian/changelog index 95b31f3..4c9e674 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +k7 (0.2.1) noble; urgency=medium + + * Release 0.2.1. + + -- Katakate Sat, 15 Aug 2026 19:29:31 +0200 + k7 (0.2.0) noble; urgency=medium * Release 0.2.0. diff --git a/debian/control b/debian/control index cd19f44..0bdea54 100644 --- a/debian/control +++ b/debian/control @@ -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 diff --git a/debian/rules b/debian/rules index dcf856d..36e7b82 100755 --- a/debian/rules +++ b/debian/rules @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6d18510..9ab76f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/setup.py b/setup.py index 2324c07..7f14a43 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/src/k7/__init__.py b/src/k7/__init__.py index c5cc4ac..aac3086 100644 --- a/src/k7/__init__.py +++ b/src/k7/__init__.py @@ -1,3 +1,3 @@ """K7 Sandbox Management System""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/k7/api/main.py b/src/k7/api/main.py index 4a5148d..c74db9a 100644 --- a/src/k7/api/main.py +++ b/src/k7/api/main.py @@ -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)), ) diff --git a/src/k7/cli/build.sh b/src/k7/cli/build.sh index e43b086..11c5fe4 100755 --- a/src/k7/cli/build.sh +++ b/src/k7/cli/build.sh @@ -57,7 +57,7 @@ Version: __VERSION__ Section: utils Priority: optional Architecture: ${DEB_ARCH} -Maintainer: K7 Team +Maintainer: K7 Team Description: K7 CLI for sandbox management Provides the \`k7\` command with embedded installer playbook. EOF diff --git a/src/k7/cli/k7.py b/src/k7/cli/k7.py index 527462b..0daef4d 100644 --- a/src/k7/cli/k7.py +++ b/src/k7/cli/k7.py @@ -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) diff --git a/src/k7/core/core.py b/src/k7/core/core.py index 297f84b..2507318 100644 --- a/src/k7/core/core.py +++ b/src/k7/core/core.py @@ -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() diff --git a/src/k7_sdk/__init__.py b/src/k7_sdk/__init__.py index db7b3eb..80229f2 100644 --- a/src/k7_sdk/__init__.py +++ b/src/k7_sdk/__init__.py @@ -8,4 +8,4 @@ __all__ = [ "SandboxProxy", ] -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/tests/integration/bench_docker_perf.py b/tests/integration/bench_docker_perf.py index 2698bea..a00210b 100644 --- a/tests/integration/bench_docker_perf.py +++ b/tests/integration/bench_docker_perf.py @@ -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}") diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 34dd3c5..db9abbe 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -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 diff --git a/tests/unit/test_api_auth.py b/tests/unit/test_api_auth.py index aeafcdc..a91edb7 100644 --- a/tests/unit/test_api_auth.py +++ b/tests/unit/test_api_auth.py @@ -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 diff --git a/tests/unit/test_cli_api_keys.py b/tests/unit/test_cli_api_keys.py new file mode 100644 index 0000000..10997c1 --- /dev/null +++ b/tests/unit/test_cli_api_keys.py @@ -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 diff --git a/tests/unit/test_core_subprocess.py b/tests/unit/test_core_subprocess.py index 0469185..b975432 100644 --- a/tests/unit/test_core_subprocess.py +++ b/tests/unit/test_core_subprocess.py @@ -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 = { diff --git a/tests/unit/test_registry_ssrf.py b/tests/unit/test_registry_ssrf.py new file mode 100644 index 0000000..347d52f --- /dev/null +++ b/tests/unit/test_registry_ssrf.py @@ -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") diff --git a/uv.lock b/uv.lock index 3393016..da423b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1069,7 +1069,7 @@ wheels = [ [[package]] name = "k7" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "fastapi" },