254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""US08-01: the configurable trust boundary and its authentication gate.
|
|
|
|
Until now, reaching the app proved ownership of it: it answered only to loopback
|
|
names. A container behind a reverse proxy answers to a real hostname, so these tests
|
|
pin the two halves that replace that proof — the app refuses to start exposed without
|
|
an access secret, and the secret is the only way to obtain the session every other
|
|
route already required (US07-02, unchanged and re-asserted here).
|
|
|
|
The suite's ``conftest`` bootstraps a session for any ``TestClient`` automatically,
|
|
which is precisely what an unauthenticated caller does not get; ``raw_client``
|
|
pre-seeds a placeholder CSRF header to opt out of that convenience.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from photo_pipeline.api.app import ConfigurationRefused, create_app
|
|
from photo_pipeline.api.security import ACCESS_SECRET_HEADER, CSRF_HEADER, SESSION_COOKIE
|
|
from photo_pipeline.config import Config
|
|
|
|
SECRET = "operator-secret-value"
|
|
HOSTNAME = "photos.example.com"
|
|
# What Starlette reports as the peer address of an in-process request.
|
|
TESTCLIENT_ADDRESS = "testclient"
|
|
|
|
# One of each route class: a read, a mutation, and a media endpoint.
|
|
PROTECTED = [
|
|
("GET", "/api/v1/workflow", None),
|
|
("POST", "/api/v1/albums/proposals", {}),
|
|
("GET", "/api/v1/assets/unknown-asset/thumbnail?size=256", None),
|
|
]
|
|
|
|
|
|
def config(tmp_path, **overrides) -> Config:
|
|
return Config(data_dir=tmp_path / "data", **overrides)
|
|
|
|
|
|
def raw_client(app, base_url="http://127.0.0.1") -> TestClient:
|
|
client = TestClient(app, base_url=base_url)
|
|
client.headers[CSRF_HEADER] = "placeholder"
|
|
return client
|
|
|
|
|
|
def exchange(client, secret=SECRET, headers=None):
|
|
return client.get("/api/v1/session", headers={ACCESS_SECRET_HEADER: secret, **(headers or {})})
|
|
|
|
|
|
# ── startup: exposure without a secret is refused, loopback is unchanged ──────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exposure,exposed",
|
|
[({"allowed_hosts": (HOSTNAME,)}, HOSTNAME), ({"host": "0.0.0.0"}, "0.0.0.0")],
|
|
)
|
|
def test_an_exposed_configuration_refuses_to_serve_without_a_secret(tmp_path, exposure, exposed):
|
|
with pytest.raises(ConfigurationRefused) as refused:
|
|
create_app(config(tmp_path, **exposure))
|
|
assert "PHOTO_PIPELINE_ACCESS_SECRET" in str(refused.value)
|
|
# The message names what is exposed, so the operator knows which setting did it.
|
|
assert exposed in str(refused.value)
|
|
|
|
|
|
def test_the_serve_command_reports_the_refusal_instead_of_binding(tmp_path, monkeypatch, capsys):
|
|
"""Exit before the port, the lock, and the database, with a sentence not a trace."""
|
|
from photo_pipeline.__main__ import main
|
|
|
|
monkeypatch.setenv("PHOTO_PIPELINE_DATA_DIR", str(tmp_path / "data"))
|
|
monkeypatch.setenv("PHOTO_PIPELINE_ALLOWED_HOSTS", HOSTNAME)
|
|
monkeypatch.delenv("PHOTO_PIPELINE_ACCESS_SECRET", raising=False)
|
|
|
|
assert main(["serve"]) == 4
|
|
assert "PHOTO_PIPELINE_ACCESS_SECRET" in capsys.readouterr().err
|
|
|
|
|
|
def test_an_exposed_configuration_with_a_secret_starts(tmp_path):
|
|
app = create_app(config(tmp_path, allowed_hosts=(HOSTNAME,), access_secret=SECRET))
|
|
with raw_client(app, base_url=f"http://{HOSTNAME}") as client:
|
|
assert exchange(client).status_code == 200
|
|
|
|
|
|
def test_a_loopback_configuration_still_needs_no_secret(tmp_path):
|
|
"""An unset trust boundary must behave exactly as it did before this story."""
|
|
with raw_client(create_app(config(tmp_path))) as client:
|
|
response = client.get("/api/v1/session")
|
|
assert response.status_code == 200
|
|
assert response.json()["csrf_token"]
|
|
assert "secure" not in response.headers["set-cookie"].lower()
|
|
|
|
|
|
# ── the exchange: secret in, session out ─────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def gated(tmp_path):
|
|
app = create_app(
|
|
config(
|
|
tmp_path,
|
|
allowed_hosts=(HOSTNAME,),
|
|
access_secret=SECRET,
|
|
trusted_proxies=(TESTCLIENT_ADDRESS,),
|
|
)
|
|
)
|
|
with raw_client(app, base_url=f"http://{HOSTNAME}") as client:
|
|
yield client
|
|
|
|
|
|
def test_the_secret_buys_the_session_and_the_session_buys_the_routes(gated):
|
|
response = exchange(gated)
|
|
assert response.status_code == 200
|
|
cookie = response.headers["set-cookie"].lower()
|
|
assert "httponly" in cookie and "samesite=strict" in cookie
|
|
gated.headers[CSRF_HEADER] = response.json()["csrf_token"]
|
|
|
|
# The session and CSRF requirements behind the gate are the ones US07-02 set.
|
|
assert gated.get("/api/v1/workflow").status_code == 200
|
|
assert gated.post("/api/v1/albums/proposals", json={}).status_code == 200
|
|
refused = gated.post("/api/v1/albums/proposals", json={}, headers={CSRF_HEADER: "guessed"})
|
|
assert refused.json()["error"]["code"] == "csrf_failed"
|
|
|
|
|
|
@pytest.mark.parametrize("offered", ["", "wrong-secret", SECRET + "x", SECRET.upper()])
|
|
def test_a_wrong_secret_buys_nothing(gated, offered):
|
|
response = exchange(gated, secret=offered)
|
|
assert response.status_code == 401
|
|
assert response.json()["error"]["code"] == "access_denied"
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_a_refusal_never_echoes_the_secret_or_the_session(gated, caplog):
|
|
with caplog.at_level("WARNING"):
|
|
response = exchange(gated, secret="wrong-secret")
|
|
assert SECRET not in response.text and "wrong-secret" not in response.text
|
|
assert SECRET not in caplog.text
|
|
# Logged as an event with its caller, without the session it did not get.
|
|
assert "access secret rejected" in caplog.text
|
|
|
|
|
|
def test_guessing_is_rate_limited(gated):
|
|
codes = [exchange(gated, secret=f"guess-{n}").status_code for n in range(6)]
|
|
assert codes.count(401) == 5 and codes[-1] == 429
|
|
assert gated.get("/api/v1/session").status_code == 429
|
|
# The right secret is refused too while the limiter holds: that is the point.
|
|
blocked = exchange(gated)
|
|
assert blocked.status_code == 429
|
|
assert SECRET not in blocked.text
|
|
|
|
|
|
def test_every_route_class_is_unreachable_without_the_secret(gated):
|
|
for method, path, body in PROTECTED:
|
|
response = gated.request(method, path, json=body)
|
|
assert response.status_code == 401, path
|
|
assert response.json()["error"]["code"] == "unauthenticated", path
|
|
# Health stays open: an orchestrator restarting the container holds no secret.
|
|
assert gated.get("/api/v1/health/live").status_code == 200
|
|
assert gated.get("/api/v1/health/ready").status_code == 200
|
|
|
|
|
|
def test_a_session_from_another_process_is_not_replayable(tmp_path):
|
|
"""Sessions live in the process, so a cookie captured from a previous one — a
|
|
restarted container, or a second deployment — must not open this one."""
|
|
settings = dict(allowed_hosts=(HOSTNAME,), access_secret=SECRET)
|
|
first, second = (create_app(config(tmp_path / str(n), **settings)) for n in (1, 2))
|
|
with raw_client(first, base_url=f"http://{HOSTNAME}") as client:
|
|
exchange(client)
|
|
stolen = client.cookies[SESSION_COOKIE]
|
|
|
|
with raw_client(second, base_url=f"http://{HOSTNAME}") as client:
|
|
client.cookies.set(SESSION_COOKIE, stolen, domain=HOSTNAME)
|
|
response = client.get("/api/v1/workflow")
|
|
assert response.status_code == 401
|
|
assert response.json()["error"]["code"] == "unauthenticated"
|
|
|
|
|
|
def test_a_cross_site_request_is_still_refused_behind_the_gate(gated):
|
|
gated.headers[CSRF_HEADER] = exchange(gated).json()["csrf_token"]
|
|
refused = gated.post(
|
|
"/api/v1/albums/proposals", json={}, headers={"Origin": "https://evil.example"}
|
|
)
|
|
assert refused.json()["error"]["code"] == "origin_not_allowed"
|
|
embedded = gated.get(
|
|
"/api/v1/assets/unknown-asset/thumbnail?size=256", headers={"Sec-Fetch-Site": "cross-site"}
|
|
)
|
|
assert embedded.json()["error"]["code"] == "cross_site_blocked"
|
|
|
|
|
|
def test_an_unconfigured_host_is_refused_even_with_a_valid_session(gated):
|
|
gated.headers[CSRF_HEADER] = exchange(gated).json()["csrf_token"]
|
|
for host in ("other.example.com", "192.168.1.10"):
|
|
response = gated.get("/api/v1/workflow", headers={"Host": host})
|
|
assert response.status_code == 403, host
|
|
assert response.json()["error"]["code"] == "host_not_allowed", host
|
|
|
|
|
|
# ── forwarded headers: believed from the proxy, ignored from anyone else ──────
|
|
|
|
|
|
def test_a_trusted_proxys_https_makes_the_cookie_secure(gated):
|
|
"""The proxy speaks HTTPS outward and HTTP to this app, so only the header knows."""
|
|
assert "secure" in exchange(gated, headers={"X-Forwarded-Proto": "https"}).headers[
|
|
"set-cookie"
|
|
].lower()
|
|
assert "secure" not in exchange(gated).headers["set-cookie"].lower()
|
|
|
|
|
|
def test_the_external_scheme_is_part_of_the_accepted_origin(gated):
|
|
gated.headers[CSRF_HEADER] = exchange(gated).json()["csrf_token"]
|
|
allowed = gated.post(
|
|
"/api/v1/albums/proposals",
|
|
json={},
|
|
headers={"X-Forwarded-Proto": "https", "Origin": f"https://{HOSTNAME}"},
|
|
)
|
|
assert allowed.status_code == 200
|
|
# The scheme is part of the origin: the same name over plain HTTP is not it.
|
|
refused = gated.post(
|
|
"/api/v1/albums/proposals",
|
|
json={},
|
|
headers={"X-Forwarded-Proto": "https", "Origin": f"http://{HOSTNAME}"},
|
|
)
|
|
assert refused.json()["error"]["code"] == "origin_not_allowed"
|
|
|
|
|
|
def test_a_trusted_proxys_forwarded_host_is_the_host_that_is_judged(tmp_path):
|
|
"""The proxy terminates the operator's hostname and dials this app by address."""
|
|
app = create_app(
|
|
config(
|
|
tmp_path,
|
|
allowed_hosts=(HOSTNAME,),
|
|
access_secret=SECRET,
|
|
trusted_proxies=(TESTCLIENT_ADDRESS,),
|
|
)
|
|
)
|
|
with raw_client(app, base_url="http://10.0.0.5") as client:
|
|
forwarded = {"X-Forwarded-Host": HOSTNAME}
|
|
assert exchange(client, headers=forwarded).status_code == 200
|
|
# Without the header the address it was dialled by is not an allowed name.
|
|
assert exchange(client).json()["error"]["code"] == "host_not_allowed"
|
|
|
|
|
|
def test_forwarded_headers_from_an_untrusted_client_are_ignored(tmp_path):
|
|
"""Otherwise any caller could declare the hostname and scheme of its choosing."""
|
|
app = create_app(config(tmp_path, allowed_hosts=(HOSTNAME,), access_secret=SECRET))
|
|
with raw_client(app, base_url="http://evil.example") as client:
|
|
forged = exchange(client, headers={"X-Forwarded-Host": HOSTNAME})
|
|
assert forged.json()["error"]["code"] == "host_not_allowed"
|
|
|
|
with raw_client(app, base_url=f"http://{HOSTNAME}") as client:
|
|
# A forged scheme would flip the cookie's Secure flag on a plain connection,
|
|
# which is how a cookie gets set and then never sent again.
|
|
response = exchange(client, headers={"X-Forwarded-Proto": "https"})
|
|
assert response.status_code == 200
|
|
assert "secure" not in response.headers["set-cookie"].lower()
|