mirror of
https://github.com/Katakate/k7.git
synced 2026-09-22 01:53:19 +00:00
a939e693d0
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.
188 lines
6.6 KiB
Python
188 lines
6.6 KiB
Python
"""Unit tests for API authentication (mocked file I/O, no k8s)."""
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
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()
|
|
|
|
|
|
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}
|
|
|
|
|
|
@pytest.fixture()
|
|
def keys_file(tmp_path: Path) -> Path:
|
|
return tmp_path / "api_keys.json"
|
|
|
|
|
|
@pytest.fixture()
|
|
def _patch_keys_file(keys_file: Path):
|
|
with patch("k7.api.main.API_KEYS_FILE", keys_file):
|
|
yield
|
|
|
|
|
|
# --- load_api_keys ---
|
|
|
|
|
|
class TestLoadApiKeys:
|
|
def test_missing_file(self, _patch_keys_file, keys_file: Path):
|
|
assert load_api_keys() == {}
|
|
|
|
def test_empty_file_fails_loud(self, _patch_keys_file, keys_file: Path):
|
|
# An existing-but-unparseable store is a deployment bug: it must be
|
|
# a loud 500, not a silent {} that rejects every key as "invalid".
|
|
from fastapi import HTTPException
|
|
|
|
keys_file.write_text("")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
load_api_keys()
|
|
assert exc_info.value.status_code == 500
|
|
assert "corrupt" in exc_info.value.detail
|
|
|
|
def test_valid_json(self, _patch_keys_file, keys_file: Path):
|
|
data = _make_keys_data()
|
|
keys_file.write_text(json.dumps(data))
|
|
result = load_api_keys()
|
|
assert TEST_KEY_HASH in result
|
|
assert result[TEST_KEY_HASH]["name"] == "test-key"
|
|
|
|
def test_expired_keys_purged(self, _patch_keys_file, keys_file: Path):
|
|
expired_ts = int(time.time()) - 3600
|
|
data = _make_keys_data(expires=expired_ts)
|
|
keys_file.write_text(json.dumps(data))
|
|
result = load_api_keys()
|
|
assert TEST_KEY_HASH not in result
|
|
|
|
|
|
# --- verify_api_key via httpx.AsyncClient ---
|
|
|
|
|
|
class TestVerifyApiKey:
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, _patch_keys_file, keys_file: Path):
|
|
future_ts = int(time.time()) + 86400
|
|
data = _make_keys_data(expires=future_ts)
|
|
keys_file.write_text(json.dumps(data))
|
|
self.keys_file = keys_file
|
|
|
|
async def test_valid_x_api_key(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/health")
|
|
assert resp.status_code == 200
|
|
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
# May fail with 400/500 (no k8s), but should NOT be 401
|
|
assert resp.status_code != 401
|
|
|
|
async def test_valid_bearer_token(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"Authorization": f"Bearer {TEST_KEY}"},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code != 401
|
|
|
|
async def test_missing_key_returns_401(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_wrong_key_returns_401(self):
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": "wrong-key-value"},
|
|
json={"name": "t", "image": "alpine"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_expired_key_returns_401(self):
|
|
expired_ts = int(time.time()) - 3600
|
|
data = _make_keys_data(expires=expired_ts)
|
|
self.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.post(
|
|
"/api/v1/sandboxes",
|
|
headers={"X-API-Key": TEST_KEY},
|
|
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
|