From a939e693d024352e2fe0350ff77f4501b4e4bea2 Mon Sep 17 00:00:00 2001 From: G Date: Sun, 16 Aug 2026 00:16:48 +0200 Subject: [PATCH] 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. --- CHALLENGES.md | 30 +++++ CHANGELOG.md | 34 +++++ PERFORMANCE.md | 22 +++ SECURITY.md | 26 +++- bench/docker-perf/render.py | 2 +- debian/changelog | 6 + debian/control | 2 +- debian/rules | 4 + pyproject.toml | 2 +- setup.py | 2 +- src/k7/__init__.py | 2 +- src/k7/api/main.py | 128 +++++++++++++----- src/k7/cli/build.sh | 2 +- src/k7/cli/k7.py | 33 ++++- src/k7/core/core.py | 103 +++++++++++++- src/k7_sdk/__init__.py | 2 +- tests/integration/bench_docker_perf.py | 50 +++++-- tests/integration/test_api.py | 180 ++++++++++++++++++++++++- tests/unit/test_api_auth.py | 48 ++++++- tests/unit/test_cli_api_keys.py | 64 +++++++++ tests/unit/test_core_subprocess.py | 10 ++ tests/unit/test_registry_ssrf.py | 88 ++++++++++++ uv.lock | 2 +- 23 files changed, 772 insertions(+), 70 deletions(-) create mode 100644 tests/unit/test_cli_api_keys.py create mode 100644 tests/unit/test_registry_ssrf.py 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" },