1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 09:23:25 +00:00

fix(gateway)!: replace token cookies with revocable sessions and harden abuse controls

- Replace user:token cookies with opaque server-side sessions, revocable on logout, delete, recreate and expiry; bind every credential to the path user and add cross-user regression tests.
- Rate-limit /users and /login, add optional signup invite codes and form same-origin checks, and unify login failures to 401.
- Require explicit ALLOW_INSECURE_COOKIES for plain HTTP, use __Host-dsh_gw_session when secure, and fix secure cookie deletion.
- Strip client-supplied forwarding/SSO headers, add an optional upstream allowlist, per-user in-flight and WebSocket message caps, credential-free handshake errors, and sweep-time state pruning.
- Digest-pin base images, hash-pin gateway dependencies, verify DSH_COMMIT in the build, and run the gateway image as non-root with a pinned Python base.
- Update manifests, production checklist, architecture/security docs and add an SSO preflight script.

BREAKING CHANGE: existing dsh_gw_session=<user>:<token> cookies are invalid; users must sign in again. Unknown users on POST /login now return 401 instead of 404, and the gateway refuses COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1.
This commit is contained in:
SREDevOpsAgents
2026-10-31 00:54:05 -03:00
parent 24f23e09ae
commit ae8aba8241
33 changed files with 5450 additions and 319 deletions

View File

@@ -18,10 +18,12 @@ from types import SimpleNamespace
import httpx
import pytest
from kubernetes.client.exceptions import ApiException
from starlette.responses import Response
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from gateway import (
Session,
Settings,
UserExists,
claim_name,
@@ -219,6 +221,39 @@ def add_user(k8s, user="alice", token=TOKEN, ready=True, pod_name=None, suffix="
return sandbox_name, pod_name
# --- browser sessions ------------------------------------------------------
#
# A browser session is an opaque server-side id. Tests must never forge a
# cookie (or a bearer token); they mint a real session through the gateway so
# the production lookup path is exercised.
def mint_session(env, user="alice") -> tuple[str, str]:
"""Mint a server-side session for ``user``; returns ``(cookie_name, sid)``."""
response = Response()
sid = env.gateway.set_session(response, user)
return env.gateway.settings.cookie_name, sid
def session_dict(env, user="alice") -> dict:
name, sid = mint_session(env, user)
return {name: sid}
def session_cookie(env, user="alice") -> str:
name, sid = mint_session(env, user)
return f"{name}={sid}"
def response_session_user(env, response) -> str:
"""The user of the server-side session a response just set."""
cookie = response.headers["set-cookie"]
value = cookie.split(";", 1)[0].split("=", 1)[1]
session = env.gateway.state.sessions.get(value)
assert session is not None, "response did not set a server-side session"
return session.user
@pytest.fixture
def env():
settings = Settings(
@@ -463,6 +498,12 @@ def test_settings_from_env(monkeypatch):
monkeypatch.setenv("MAX_BODY_MB", "2")
monkeypatch.setenv("HTTP_TIMEOUT", "12.5")
monkeypatch.setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
monkeypatch.setenv("AUTH_RATE_LIMIT", "5")
monkeypatch.setenv("AUTH_RATE_WINDOW", "30")
monkeypatch.setenv("SIGNUP_INVITE_CODE", "invite-x")
monkeypatch.setenv("UPSTREAM_HEADER_ALLOWLIST", "X-Custom, Accept")
monkeypatch.setenv("MAX_IN_FLIGHT_PER_USER", "4")
monkeypatch.setenv("WS_MAX_MESSAGE_BYTES", "1024")
settings = Settings.from_env()
assert settings.namespace == "ns-x"
assert settings.pool == "pool-x"
@@ -476,13 +517,56 @@ def test_settings_from_env(monkeypatch):
assert settings.max_body_bytes == 2 * 1024 * 1024
assert settings.http_timeout == 12.5
assert settings.auth_proxy_header == "X-Forwarded-User"
assert settings.auth_rate_limit == 5
assert settings.auth_rate_window == 30
assert settings.signup_invite_code == "invite-x"
assert settings.upstream_header_allowlist == frozenset({"x-custom", "accept"})
assert settings.max_in_flight_per_user == 4
assert settings.ws_max_message_bytes == 1024
def test_settings_auth_proxy_header_defaults_to_none(monkeypatch):
monkeypatch.delenv("AUTH_PROXY_HEADER", raising=False)
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
assert Settings.from_env().auth_proxy_header is None
def test_settings_from_env_rejects_insecure_cookies_by_default(monkeypatch):
monkeypatch.delenv("COOKIE_SECURE", raising=False)
monkeypatch.delenv("ALLOW_INSECURE_COOKIES", raising=False)
with pytest.raises(RuntimeError, match="insecure session cookie"):
Settings.from_env()
def test_settings_from_env_allows_insecure_cookies_with_the_explicit_flag(monkeypatch):
monkeypatch.delenv("COOKIE_SECURE", raising=False)
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
monkeypatch.delenv("COOKIE_NAME", raising=False)
settings = Settings.from_env()
assert settings.cookie_secure is False
assert settings.cookie_name == "dsh_gw_session"
def test_settings_default_cookie_name_is_host_prefixed_when_secure(monkeypatch):
monkeypatch.setenv("COOKIE_SECURE", "true")
monkeypatch.delenv("COOKIE_NAME", raising=False)
assert Settings.from_env().cookie_name == "__Host-dsh_gw_session"
def test_settings_explicit_cookie_name_wins(monkeypatch):
monkeypatch.setenv("COOKIE_SECURE", "true")
monkeypatch.setenv("COOKIE_NAME", "sid")
assert Settings.from_env().cookie_name == "sid"
def test_settings_upstream_header_allowlist_unset_means_denylist_mode(monkeypatch):
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
monkeypatch.delenv("UPSTREAM_HEADER_ALLOWLIST", raising=False)
assert Settings.from_env().upstream_header_allowlist is None
monkeypatch.setenv("UPSTREAM_HEADER_ALLOWLIST", "")
assert Settings.from_env().upstream_header_allowlist == frozenset()
# --- Kubernetes adapter ----------------------------------------------------
@@ -635,11 +719,22 @@ async def test_landing_page_has_signup_and_login_forms(env):
assert "text/html" in response.headers["content-type"]
assert 'action="/users"' in response.text
assert 'action="/login"' in response.text
# No invite code configured => no invite input on the landing page.
assert 'name="invite"' not in response.text
@pytest.mark.asyncio
async def test_landing_page_shows_invite_input_only_when_configured(env):
configure(env, signup_invite_code="let-me-in")
async with asgi_client(env) as client:
response = await client.get("/")
assert response.status_code == 200
assert 'name="invite"' in response.text
@pytest.mark.asyncio
async def test_landing_page_shows_session_when_cookie_present(env):
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/")
assert "Signed in as" in response.text
assert "/u/alice/" in response.text
@@ -708,11 +803,14 @@ async def test_signup_json_stores_only_hash_and_returns_token(env, monkeypatch):
assert pool == "test-pool"
assert token_hash == hashlib.sha256(body["token"].encode()).hexdigest()
assert token_hash != body["token"]
# The JSON API also gets a browser session cookie (HttpOnly, SameSite=Lax).
# The JSON API also gets a browser session cookie (HttpOnly, SameSite=Lax)
# carrying only an opaque server-side session id, never the user/token.
cookie = response.headers["set-cookie"]
assert "dsh_gw_session=alice:" in cookie
assert "dsh_gw_session=" in cookie
assert f"dsh_gw_session={TOKEN}" not in cookie
assert "HttpOnly" in cookie
assert "SameSite=lax" in cookie
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
@@ -741,13 +839,7 @@ async def test_signup_form_redirects_and_sets_session_cookie(env, monkeypatch):
)
assert response.status_code == 303
assert response.headers["location"] == "/u/bob/"
cookie = response.headers["set-cookie"]
assert re_cookie_user(cookie) == "bob"
def re_cookie_user(set_cookie: str) -> str:
value = set_cookie.split(";", 1)[0].split("=", 1)[1]
return value.split(":", 1)[0]
assert response_session_user(env, response) == "bob"
@pytest.mark.asyncio
@@ -774,7 +866,29 @@ async def test_login_form_sets_cookie_and_redirects(env):
)
assert response.status_code == 303
assert response.headers["location"] == "/u/alice/"
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
async def test_secure_session_cookie_uses_the_host_prefix_and_flags(env):
configure(env, cookie_secure=True, cookie_name="__Host-dsh_gw_session")
add_user(env.k8s)
async with asgi_client(env) as client:
response = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={"content-type": "application/x-www-form-urlencoded"},
)
cookie = response.headers["set-cookie"]
assert cookie.startswith("__Host-dsh_gw_session=")
assert "Secure" in cookie
assert "HttpOnly" in cookie
assert "Path=/" in cookie
assert "SameSite=lax" in cookie
assert "Domain=" not in cookie
# The value is an opaque session id, not <user>:<token>.
assert "alice" not in cookie.split(";", 1)[0]
assert TOKEN not in cookie
@pytest.mark.asyncio
@@ -786,17 +900,197 @@ async def test_login_json_and_failures(env):
missing = await client.post("/login", json={"user": "ghost", "token": TOKEN})
assert ok.status_code == 200
assert bad.status_code == 401
assert missing.status_code == 404
# An unknown user is a 401 too: login must not reveal which users exist.
assert missing.status_code == 401
@pytest.mark.asyncio
async def test_logout_clears_cookie(env):
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async def test_logout_clears_cookie_and_revokes_the_session(env):
add_user(env.k8s)
name, sid = mint_session(env)
assert env.gateway.session_for(sid) is not None
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post("/logout")
assert response.status_code == 303
assert response.headers["location"] == "/"
assert "dsh_gw_session=" in response.headers["set-cookie"]
assert "Max-Age=0" in response.headers["set-cookie"]
# The old sid is dead server-side, not just deleted from the browser.
assert env.gateway.session_for(sid) is None
async with asgi_client(env, cookies={name: sid}) as client:
replay = await client.get("/users/alice")
assert replay.status_code == 401
@pytest.mark.asyncio
async def test_logout_deletion_cookie_is_secure_for_the_host_prefix(env):
"""A __Host- cookie can only be cleared by a Secure deletion Set-Cookie."""
configure(env, cookie_secure=True, cookie_name="__Host-dsh_gw_session")
add_user(env.k8s)
name, sid = mint_session(env)
assert name == "__Host-dsh_gw_session"
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post("/logout")
assert response.status_code == 303
cookie = response.headers["set-cookie"]
assert cookie.startswith("__Host-dsh_gw_session=")
assert "Max-Age=0" in cookie
assert "Path=/" in cookie
assert "Secure" in cookie
assert "Domain=" not in cookie
# --- signup invite / auth abuse controls -----------------------------------
def configure(env, **overrides):
"""Return a copy of the fixture settings with overrides applied."""
env.settings = Settings(**{**env.settings.__dict__, **overrides})
env.gateway.settings = env.settings
return env
@pytest.mark.asyncio
async def test_signup_requires_invite_when_configured(env, monkeypatch):
configure(env, signup_invite_code="invite-code")
async def not_ready(user, timeout):
return None
monkeypatch.setattr(env.gateway, "wait_ready", not_ready)
async with asgi_client(env) as client:
missing = await client.post("/users", json={"user": "alice"})
wrong = await client.post("/users", json={"user": "alice", "invite": "nope"})
good = await client.post("/users", json={"user": "alice", "invite": "invite-code"})
assert missing.status_code == 403
assert wrong.status_code == 403
assert good.status_code == 202
assert [created[0] for created in env.k8s.created] == ["alice"]
@pytest.mark.asyncio
async def test_invite_is_checked_before_the_user_lookup(env):
configure(env, signup_invite_code="invite-code")
add_user(env.k8s)
async with asgi_client(env) as client:
response = await client.post("/users", json={"user": "alice"})
# 403, not 409: an unauthenticated caller cannot probe for existing users.
assert response.status_code == 403
@pytest.mark.asyncio
async def test_invite_accepts_the_header_and_the_form_field(env, monkeypatch):
configure(env, signup_invite_code="invite-code")
async def not_ready(user, timeout):
return None
monkeypatch.setattr(env.gateway, "wait_ready", not_ready)
async with asgi_client(env) as client:
via_header = await client.post(
"/users",
json={"user": "alice"},
headers={"X-Signup-Invite": "invite-code"},
)
via_form = await client.post(
"/users",
data={"user": "bob", "invite": "invite-code"},
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert via_header.status_code == 202
assert via_form.status_code == 303
@pytest.mark.asyncio
async def test_form_auth_rejects_cross_origin_requests(env):
add_user(env.k8s)
form = {"content-type": "application/x-www-form-urlencoded"}
async with asgi_client(env) as client:
signup = await client.post(
"/users", data={"user": "bob"}, headers={**form, "Origin": "http://evil.test"}
)
login = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Origin": "http://evil.test"},
)
referer = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Referer": "http://evil.test/login"},
)
same_origin = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Origin": "http://gateway.test"},
)
assert signup.status_code == 403
assert login.status_code == 403
assert referer.status_code == 403
assert same_origin.status_code == 303
@pytest.mark.asyncio
async def test_auth_rate_limit_returns_429_with_retry_after(env):
configure(env, auth_rate_limit=2, auth_rate_window=60)
add_user(env.k8s)
async with asgi_client(env) as client:
first = await client.post("/login", json={"user": "alice", "token": "nope"})
second = await client.post("/login", json={"user": "alice", "token": "nope"})
third = await client.post("/login", json={"user": "alice", "token": "nope"})
assert first.status_code == 401
assert second.status_code == 401
assert third.status_code == 429
assert int(third.headers["retry-after"]) >= 1
@pytest.mark.asyncio
async def test_auth_rate_limit_is_per_client_via_rightmost_forwarded_for(env):
configure(env, auth_rate_limit=1, auth_rate_window=60)
add_user(env.k8s)
async with asgi_client(env) as client:
first = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "1.1.1.1, 10.0.0.1"},
)
# A spoofed leftmost entry cannot escape the same rightmost-client bucket.
same_client = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "9.9.9.9, 10.0.0.1"},
)
other_client = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "10.0.0.2"},
)
assert first.status_code == 401
assert same_client.status_code == 429
assert other_client.status_code == 401
@pytest.mark.asyncio
async def test_auth_rate_limit_zero_disables_it(env):
configure(env, auth_rate_limit=0)
add_user(env.k8s)
async with asgi_client(env) as client:
responses = [
await client.post("/login", json={"user": "alice", "token": "nope"}) for _ in range(40)
]
assert {response.status_code for response in responses} == {401}
@pytest.mark.asyncio
async def test_signup_is_rate_limited_before_the_body_is_read(env):
"""429 wins over parsing: an abusive client cannot even submit a body."""
configure(env, auth_rate_limit=1, auth_rate_window=60)
async with asgi_client(env) as client:
# Spend the single allowed auth attempt on a login, then hit signup.
await client.post("/login", json={"user": "alice", "token": "nope"})
limited = await client.post("/users", content=b"not json at all")
assert limited.status_code == 429
assert env.k8s.created == []
# --- auth ------------------------------------------------------------------
@@ -810,11 +1104,11 @@ async def test_get_user_requires_valid_bearer_or_cookie(env):
bearer = await client.get("/users/alice", headers={"Authorization": f"Bearer {TOKEN}"})
cookie = await client.get(
"/users/alice",
headers={"Cookie": f"dsh_gw_session=alice:{TOKEN}"},
headers={"Cookie": session_cookie(env)},
)
wrong_user_cookie = await client.get(
"/users/alice",
headers={"Cookie": f"dsh_gw_session=bob:{TOKEN}"},
headers={"Cookie": session_cookie(env, "bob")},
)
assert forbidden.status_code == 401
assert bearer.status_code == 200
@@ -852,7 +1146,7 @@ async def test_delete_user_requires_auth(env):
async def test_delete_with_cookie_requires_same_origin(env):
add_user(env.k8s)
env.gateway.state.handshake[("alice", POD_UID, "gateway.test")] = "dsh-auth-a=x"
cookies = {"dsh_gw_session": f"alice:{TOKEN}"}
cookies = session_dict(env)
async with asgi_client(env, cookies=cookies) as client:
missing = await client.delete("/users/alice")
cross = await client.delete("/users/alice", headers={"Origin": "http://evil.test"})
@@ -890,13 +1184,78 @@ async def test_unexpected_api_error_propagates(env):
await client.get("/users/alice")
@pytest.mark.asyncio
async def test_bearer_token_never_authorizes_another_user(env):
add_user(env.k8s, "alice", token=TOKEN)
add_user(env.k8s, "bob", token="bob-token")
async with asgi_client(env) as client:
alice_on_bob = await client.get("/users/bob", headers={"Authorization": f"Bearer {TOKEN}"})
bob_on_alice = await client.get(
"/users/alice", headers={"Authorization": "Bearer bob-token"}
)
alice_on_alice = await client.get(
"/users/alice", headers={"Authorization": f"Bearer {TOKEN}"}
)
assert alice_on_bob.status_code == 401
assert bob_on_alice.status_code == 401
assert alice_on_alice.status_code == 200
@pytest.mark.asyncio
async def test_session_cookie_never_authorizes_another_user(env):
add_user(env.k8s, "alice", token=TOKEN)
add_user(env.k8s, "bob", token="bob-token")
bob_cookie = session_cookie(env, "bob")
async with asgi_client(env) as client:
user = await client.get("/users/alice", headers={"Cookie": bob_cookie})
proxy = await client.get("/u/alice/api/state", headers={"Cookie": bob_cookie})
assert user.status_code == 401
assert proxy.status_code == 401
@pytest.mark.asyncio
async def test_opaque_session_cookie_is_not_a_bearer_token(env):
add_user(env.k8s)
_name, sid = mint_session(env)
async with asgi_client(env) as client:
response = await client.get("/users/alice", headers={"Authorization": f"Bearer {sid}"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_expired_session_is_revoked_and_treated_as_anonymous(env):
add_user(env.k8s)
name, sid = mint_session(env)
env.gateway.state.sessions[sid] = Session(user="alice", expires_at=time.time() - 1)
async with asgi_client(env, cookies={name: sid}) as client:
user = await client.get("/users/alice")
landing = await client.get("/")
assert user.status_code == 401
assert "Signed in as" not in landing.text
assert sid not in env.gateway.state.sessions
@pytest.mark.asyncio
async def test_delete_user_revokes_all_its_sessions(env):
add_user(env.k8s)
cookie = session_dict(env)
other = session_dict(env) # a second browser session for the same user
async with asgi_client(env, cookies=cookie) as client:
response = await client.delete("/users/alice", headers={"Origin": "http://gateway.test"})
assert response.status_code == 200
assert env.gateway.state.sessions == {}
async with asgi_client(env, cookies=other) as client:
landing = await client.get("/")
assert "Signed in as" not in landing.text
# --- DSH handshake ---------------------------------------------------------
@pytest.mark.asyncio
async def test_proxy_handshakes_and_uses_browser_host(env):
add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert response.text == "hello from DSH"
@@ -912,7 +1271,7 @@ async def test_proxy_handshakes_and_uses_browser_host(env):
@pytest.mark.asyncio
async def test_dsh_cookie_is_cached_per_pod_uid_and_host(env):
add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
first = await client.get("/u/alice/api/one")
second = await client.get("/u/alice/api/two")
assert first.status_code == second.status_code == 200
@@ -924,7 +1283,7 @@ async def test_dsh_cookie_is_cached_per_pod_uid_and_host(env):
async def test_missing_launch_token_returns_503_with_retry_after(env):
_sandbox_name, pod_name = add_user(env.k8s)
env.k8s.logs[pod_name] = "no token printed yet\n"
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
assert response.headers["retry-after"] == "10"
@@ -935,7 +1294,7 @@ async def test_missing_launch_token_returns_503_with_retry_after(env):
async def test_handshake_4xx_is_503(env):
add_user(env.k8s)
env.upstream.handshake_status = 401
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
@@ -944,11 +1303,27 @@ async def test_handshake_4xx_is_503(env):
async def test_missing_cookie_in_handshake_is_503(env):
add_user(env.k8s)
env.upstream.handshake_cookies = ["other=1; Path=/"]
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
@pytest.mark.asyncio
async def test_handshake_error_never_leaks_the_launch_token_or_url(env):
_sandbox_name, pod_name = add_user(env.k8s)
secret = "super-secret-launch-token"
env.k8s.logs[pod_name] = f"dsh web: http://10.0.0.7:3080/?token={secret}\n"
env.upstream.handshake_status = 500
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
body = response.text
assert secret not in body
assert FQDN not in body
assert "10.0.0.7" not in body
assert "dsh-auth" not in body
@pytest.mark.asyncio
async def test_pod_annotation_overrides_sandbox_name(env):
sandbox_name, pod_name = add_user(env.k8s)
@@ -957,7 +1332,7 @@ async def test_pod_annotation_overrides_sandbox_name(env):
)
env.k8s.pods["custom-pod"] = {"metadata": {"uid": "other-uid"}}
env.k8s.logs["custom-pod"] = env.k8s.logs[pod_name]
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert ("alice", "other-uid", "gateway.test") in env.gateway.state.handshake
@@ -984,7 +1359,7 @@ async def test_proxy_strips_gateway_auth_cookie_and_hop_headers(env):
env.upstream.proxy_handler = handler
headers = {
"Authorization": f"Bearer {TOKEN}",
"Cookie": "dsh_gw_session=alice:x; browser=1",
"Cookie": session_cookie(env) + "; browser=1",
"X-Forwarded-For": "1.2.3.4",
"X-Forwarded-Proto": "https",
"Host": "gateway.test",
@@ -1009,6 +1384,83 @@ async def test_proxy_strips_gateway_auth_cookie_and_hop_headers(env):
assert "connection" not in response.headers
@pytest.mark.asyncio
async def test_proxy_strips_client_supplied_forwarding_and_sso_headers(env):
add_user(env.k8s)
headers = {
"Authorization": f"Bearer {TOKEN}",
"Forwarded": "for=evil.test;proto=https",
"X-Forwarded-User": "bob",
"X-Forwarded-Host": "evil.test",
"X-Auth-Request-User": "bob",
"X-Auth-Request-Email": "bob@evil.test",
"X-Real-IP": "6.6.6.6",
"Origin": "http://gateway.test",
}
async with asgi_client(env) as client:
response = await client.get("/u/alice/api/state", headers=headers)
assert response.status_code == 200
proxy = env.upstream.proxy_requests[0]
for name in (
"forwarded",
"x-forwarded-user",
"x-auth-request-user",
"x-auth-request-email",
"x-real-ip",
):
assert name not in proxy.headers
# The gateway's own hop metadata comes from the real connection instead.
assert proxy.headers["x-forwarded-for"] == "127.0.0.1"
assert proxy.headers["x-forwarded-host"] == "gateway.test"
@pytest.mark.asyncio
async def test_proxy_upstream_header_allowlist_drops_unlisted_headers(env):
configure(env, upstream_header_allowlist=frozenset({"x-custom-ok"}))
add_user(env.k8s)
headers = {
"Authorization": f"Bearer {TOKEN}",
"X-Custom-Ok": "keep",
"X-Custom-Drop": "drop",
"X-Forwarded-User": "bob",
"Origin": "http://gateway.test",
}
async with asgi_client(env) as client:
response = await client.get("/u/alice/api/state", headers=headers)
assert response.status_code == 200
proxy = env.upstream.proxy_requests[0]
assert proxy.headers["x-custom-ok"] == "keep"
assert "x-custom-drop" not in proxy.headers
# Allowlisting never re-enables gateway-owned or spoofable headers.
assert "authorization" not in proxy.headers
assert "x-forwarded-user" not in proxy.headers
assert "cookie" not in proxy.headers or proxy.headers["cookie"] == "dsh-auth-a=one"
@pytest.mark.asyncio
async def test_proxy_rejects_requests_over_the_per_user_in_flight_cap(env):
configure(env, max_in_flight_per_user=1)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
assert "retry-after" in response.headers
assert env.gateway.state.in_flight["alice"] == 1
assert env.upstream.proxy_requests == []
@pytest.mark.asyncio
async def test_proxy_accepts_requests_below_the_per_user_in_flight_cap(env):
configure(env, max_in_flight_per_user=2)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert env.gateway.state.in_flight["alice"] == 1 # the slot was released again
@pytest.mark.asyncio
async def test_proxy_rewrites_absolute_path_location(env):
add_user(env.k8s)
@@ -1019,7 +1471,7 @@ async def test_proxy_rewrites_absolute_path_location(env):
("set-cookie", "upstream=leak; Path=/"),
],
)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/redirect", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/u/alice/?next=1"
@@ -1040,7 +1492,7 @@ async def test_proxy_refreshes_cookie_once_on_upstream_401(env):
return dsh_response(200, b"fresh")
env.upstream.proxy_handler = handler
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert response.text == "fresh"
@@ -1053,7 +1505,7 @@ async def test_proxy_refreshes_cookie_once_on_upstream_401(env):
async def test_proxy_does_not_refresh_401_without_cached_cookie(env):
add_user(env.k8s)
env.upstream.proxy_handler = lambda request: dsh_response(401, b"no")
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 401
assert len(env.upstream.proxy_requests) == 1
@@ -1067,7 +1519,7 @@ async def test_proxy_upstream_connection_error_is_502(env):
raise httpx.ConnectError("boom", request=request)
env.upstream.proxy_handler = handler
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 502
assert env.gateway.state.in_flight["alice"] == 0
@@ -1106,7 +1558,7 @@ async def test_proxy_client_disconnect_mid_body_returns_499(env):
"/u/alice/api/echo",
headers=[
(b"origin", b"http://gateway.test"),
(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode()),
(b"cookie", session_cookie(env).encode()),
(b"content-type", b"application/json"),
],
),
@@ -1167,7 +1619,7 @@ async def test_proxy_client_disconnect_mid_stream_frees_slot(env):
raw_scope(
"GET",
"/u/alice/api/stream",
headers=[(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode())],
headers=[(b"cookie", session_cookie(env).encode())],
),
receive,
)
@@ -1234,7 +1686,7 @@ async def test_proxy_client_disconnect_survives_cancelled_upstream_close(env):
raw_scope(
"GET",
"/u/alice/api/stream",
headers=[(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode())],
headers=[(b"cookie", session_cookie(env).encode())],
),
receive,
)
@@ -1258,7 +1710,7 @@ async def test_proxy_wakes_suspended_sandbox(env, monkeypatch):
return env.k8s.sandboxes[sandbox_name]
monkeypatch.setattr(env.gateway, "wait_ready", ready)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert ("dsh-alice-sbx", "Running") in env.k8s.patches
@@ -1273,7 +1725,7 @@ async def test_proxy_wake_timeout_returns_503(env, monkeypatch):
return None
monkeypatch.setattr(env.gateway, "wait_ready", never)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
assert response.headers["retry-after"] == "10"
@@ -1297,7 +1749,7 @@ async def test_proxy_requires_auth(env):
@pytest.mark.asyncio
async def test_proxy_cookie_mutation_requires_same_origin(env):
add_user(env.k8s)
cookies = {"dsh_gw_session": f"alice:{TOKEN}"}
cookies = session_dict(env)
async with asgi_client(env, cookies=cookies) as client:
cross = await client.post(
"/u/alice/api/mutate", headers={"Origin": "http://evil.test"}, content=b"{}"
@@ -1385,7 +1837,7 @@ def test_ws_cookie_cross_origin_closes_4401(env):
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={
"cookie": f"dsh_gw_session=alice:{TOKEN}",
"cookie": session_cookie(env),
"origin": "http://evil.test",
},
) as ws:
@@ -1425,7 +1877,7 @@ def test_ws_cookie_same_origin_bridges_frames(env):
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={
"cookie": f"dsh_gw_session=alice:{TOKEN}",
"cookie": session_cookie(env),
"origin": "http://testserver",
},
) as ws:
@@ -1464,6 +1916,70 @@ def test_ws_upstream_connect_failure_closes_1011(env):
assert exc.value.code == 1011
def test_ws_strips_client_supplied_forwarding_and_sso_headers(env):
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={
"Authorization": f"Bearer {TOKEN}",
"Forwarded": "for=evil.test",
"X-Forwarded-User": "bob",
"X-Auth-Request-User": "bob",
},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
headers = env.ws_connect.calls[0][1]["additional_headers"]
assert "forwarded" not in headers
assert "x-forwarded-user" not in headers
assert "x-auth-request-user" not in headers
assert headers["cookie"] == "dsh-auth-a=one"
def test_ws_per_user_in_flight_cap_closes_1011(env):
configure(env, max_in_flight_per_user=1)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
with pytest.raises(WebSocketDisconnect) as exc:
ws.receive_text()
assert exc.value.code == 1011
assert env.ws_connect.calls == []
def test_ws_passes_the_default_max_message_size(env):
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
assert env.ws_connect.calls[0][1]["max_size"] == 16 * 1024 * 1024
def test_ws_max_message_size_is_configurable(env):
configure(env, ws_max_message_bytes=2048)
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
assert env.ws_connect.calls[0][1]["max_size"] == 2048
# --- idle sweeper ----------------------------------------------------------
@@ -1576,14 +2092,46 @@ async def test_sweeper_survives_transient_api_error(env):
assert env.gateway.state.maintenance["alice"] is False
@pytest.mark.asyncio
async def test_sweeper_prunes_orphan_and_expired_state(env):
now = time.time()
# No claim for "ghost": every per-user entry is orphaned.
env.gateway.state.last_activity["ghost"] = now
env.gateway.state.in_flight["ghost"] = 0
env.gateway.state.maintenance["ghost"] = False
env.gateway.state.handshake[("ghost", POD_UID, "gateway.test")] = "dsh-auth-a=x"
_ghost_name, ghost_sid = mint_session(env, "ghost")
env.gateway.state.auth_rate["1.2.3.4"] = (now - 3600, 5)
# A user with requests still in flight is preserved even without a claim.
env.gateway.state.in_flight["busy"] = 2
# A live claim keeps its user's current session...
env.k8s.claims["alice"] = ready_claim()
env.k8s.sandboxes["dsh-alice-sbx"] = ready_sandbox()
env.gateway.state.last_activity["alice"] = now
_alice_name, alice_sid = mint_session(env, "alice")
# ...but an expired session is pruned even for a live user.
_old_name, expired_sid = mint_session(env, "alice")
env.gateway.state.sessions[expired_sid] = Session(user="alice", expires_at=now - 1)
await env.gateway.sweep_once()
assert "ghost" not in env.gateway.state.last_activity
assert "ghost" not in env.gateway.state.in_flight
assert "ghost" not in env.gateway.state.maintenance
assert ("ghost", POD_UID, "gateway.test") not in env.gateway.state.handshake
assert ghost_sid not in env.gateway.state.sessions
assert env.gateway.state.auth_rate == {}
assert env.gateway.state.in_flight["busy"] == 2
assert alice_sid in env.gateway.state.sessions
assert expired_sid not in env.gateway.state.sessions
# --- SSO (reverse-proxy identity) ------------------------------------------
def enable_sso(env, header="X-Forwarded-User"):
"""Flip an env into SSO mode: the gateway trusts the named identity header."""
env.settings = Settings(**{**env.settings.__dict__, "auth_proxy_header": header})
env.gateway.settings = env.settings
return env
return configure(env, auth_proxy_header=header)
def test_remote_user_none_when_sso_disabled(env):
@@ -1611,7 +2159,7 @@ async def test_sso_landing_new_user_shows_provisioning(env):
assert "Signed in as <strong>alice</strong>" in response.text
assert "Provisioning" in response.text
assert "Recreate sandbox" not in response.text
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
assert response_session_user(env, response) == "alice"
assert [c[0] for c in env.k8s.created] == ["alice"]
assert env.k8s.token_patches == []
@@ -1620,7 +2168,7 @@ async def test_sso_landing_new_user_shows_provisioning(env):
async def test_sso_landing_ready_connects_and_offers_recreate_only(env):
enable_sso(env)
add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
assert "Signed in as <strong>alice</strong>" in response.text
@@ -1642,11 +2190,55 @@ async def test_sso_landing_rotates_token_when_session_missing(env):
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
assert "Recreate sandbox" in response.text
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
assert response_session_user(env, response) == "alice"
assert [p[0] for p in env.k8s.token_patches] == ["alice"]
assert env.k8s.created == []
@pytest.mark.asyncio
async def test_sso_landing_reuses_a_valid_session_for_the_remote_user(env):
enable_sso(env)
add_user(env.k8s)
name, sid = mint_session(env)
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
assert "Recreate sandbox" in response.text
# Untouched: no cookie is re-issued and no token is rotated.
assert "set-cookie" not in response.headers
assert [p[0] for p in env.k8s.token_patches] == []
assert env.gateway.state.sessions[sid].user == "alice"
@pytest.mark.asyncio
async def test_sso_landing_revokes_a_foreign_session(env):
enable_sso(env)
add_user(env.k8s)
bob_name, bob_sid = mint_session(env, "bob")
async with asgi_client(env, cookies={bob_name: bob_sid}) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
# A session for another identity is never reused and is revoked server-side.
assert bob_sid not in env.gateway.state.sessions
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
async def test_recreate_revokes_the_old_session_and_mints_a_new_one(env):
enable_sso(env)
add_user(env.k8s)
name, sid = mint_session(env)
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post(
"/recreate",
headers={"X-Forwarded-User": "alice", "Origin": "http://gateway.test"},
)
assert response.status_code == 303
assert sid not in env.gateway.state.sessions
new_sid = response.headers["set-cookie"].split(";", 1)[0].split("=", 1)[1]
assert env.gateway.state.sessions[new_sid].user == "alice"
@pytest.mark.asyncio
async def test_recreate_deletes_and_reprovisions(env):
enable_sso(env)
@@ -1657,7 +2249,7 @@ async def test_recreate_deletes_and_reprovisions(env):
assert response.headers["location"] == "/u/alice/"
assert env.k8s.deleted == ["alice"]
assert [c[0] for c in env.k8s.created] == ["alice"]
assert re_cookie_user(response.headers["set-cookie"]) == "alice"
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
@@ -1673,7 +2265,7 @@ async def test_recreate_requires_sso(env):
async def test_recreate_rejects_cross_origin(env):
enable_sso(env)
add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client:
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.post(
"/recreate",
headers={"X-Forwarded-User": "alice", "Origin": "http://evil.test"},