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

@@ -1,16 +1,73 @@
FROM python:3.12-slim
# syntax=docker/dockerfile:1.7
#
# Example gateway image: the tenant-facing proxy in front of DSH sandboxes.
# The base image is digest-pinned in versions.env (GATEWAY_PYTHON_IMAGE) and
# scripts/check-versions.sh verifies the ARG default below.
#
# Two stages on that base:
# build installs the fully hash-pinned runtime dependencies into /venv as
# a dedicated non-root UID, so the runtime stage copies a venv that
# contains no root-owned files.
# runtime copies /venv and the app, runs as UID 10000 with HOME=/tmp and no
# writable location of its own: usable with readOnlyRootFilesystem.
ARG GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Read-only-rootfs friendly: no .pyc writes, no home directory writes.
# ---------------------------------------------------------------------------
# Build stage: hash-pinned virtualenv owned by the runtime identity.
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS build
ARG APP_UID=10000
ARG APP_GID=10000
# No .pyc writes and no user cache dirs: the runtime image stays read-only.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
HOME=/tmp
WORKDIR /app
RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app \
&& python -m venv /venv \
&& chown -R "$APP_UID:$APP_GID" /venv
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY gateway.py .
# Install as the unprivileged user so every file in /venv is owned by UID 10000.
# --require-hashes makes pip reject any requirement without a matching hash:
# the dependency set in requirements.txt is fully pinned and verified. Use the
# venv's own pip: the system pip would user-install into $HOME instead.
USER $APP_UID:$APP_GID
RUN /venv/bin/pip install --no-cache-dir --require-hashes -r requirements.txt
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS runtime
ARG APP_UID=10000
ARG APP_GID=10000
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HOME=/tmp \
PATH="/venv/bin:$PATH"
RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app
WORKDIR /app
# Dependencies come from the build stage: same interpreter, no build tools left
# in the runtime image and nothing owned by root inside /venv.
COPY --from=build --chown=$APP_UID:$APP_GID /venv /venv
COPY --chown=$APP_UID:$APP_GID gateway.py .
EXPOSE 8080
# Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image.
USER 10000

View File

@@ -10,11 +10,13 @@ It does four things:
(only its SHA-256 is stored as a claim annotation), reports derived state,
and deletes the claim (cascading the sandbox and PVC).
2. **Browser sessions** — exchanges a user token for an HttpOnly session
cookie so the DSH UI works in a browser at `/u/<user>/`. Automation can
still use `Authorization: Bearer <token>`.
cookie whose value is only an opaque, server-side session id (user and
expiry stay in the gateway's memory) so the DSH UI works in a browser at
`/u/<user>/`. Automation can still use
`Authorization: Bearer <token>`, which never comes from the cookie.
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
the sandbox's DSH Web server on `:3080`, including DSH's own cookie
handshake.
handshake. Client-supplied forwarding/identity headers are stripped.
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
header an authenticating reverse proxy (for example oauth2-proxy) forwards,
auto-provisions that user, and exposes `POST /recreate` for a clean restart.
@@ -63,10 +65,10 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. |
| `GET` | `/` | none | Landing page with signup + token login forms. With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
| `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`. JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large. |
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token, `404` for an unknown user. |
| `POST` | `/logout` | none | Clears the session cookie and `303`s to `/`. |
| `GET` | `/` | none | Landing page with signup + token login forms (plus an invite-code input when `SIGNUP_INVITE_CODE` is set). With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
| `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`; when `SIGNUP_INVITE_CODE` is set, an `invite` form field or `X-Signup-Invite` header must match it (`403` otherwise, checked before the user lookup). JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large, `429` when the auth rate limit is hit. |
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token **and** for an unknown user (no username enumeration). `429` when the auth rate limit is hit. Both auth routes reject a form post whose `Origin`/`Referer` contradicts `Host` (`403`) when the browser sent one. |
| `POST` | `/logout` | none | Revokes the server-side session, clears the cookie and `303`s to `/`. |
| `POST` | `/recreate` | trusted proxy header | **SSO only.** Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and `303`s to `/u/<user>/`. `401` without a valid identity header; cookie-authenticated calls must be same-origin. |
| `GET` | `/users/{user}` | bearer or cookie | Derived state: `Provisioning`, `Waking`, `Ready`, `Suspending`, `Suspended`. |
| `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. |
@@ -78,19 +80,49 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
* `Authorization: Bearer <token>` is checked with a timing-safe SHA-256
comparison against the `aaas.example.com/token-sha256` claim annotation.
* The browser session cookie is
`dsh_gw_session=<user>:<token>` (HttpOnly, SameSite=Lax, Path=/, optional
Secure). A session for user A never authorizes user B.
* The browser session cookie is **opaque**: its value is a random
server-side session id; the user and expiry live in gateway memory
(`GatewayState.sessions`). A cookie can never be edited into another user's
credentials, a session id is not accepted as a bearer token, and a bearer
token never authorizes a different user. The cookie name defaults to
`__Host-dsh_gw_session` when `COOKIE_SECURE=true` (HTTPS) and to
`dsh_gw_session` for plain HTTP; attributes are HttpOnly, SameSite=Lax,
Path=/ and (with `COOKIE_SECURE`) Secure.
* Sessions are revoked on `POST /logout`, on `DELETE /users/{user}`, on
`POST /recreate`, and when they expire (looked up on every request; the
sweeper also drops expired entries). `from_env()` refuses to start without
`COOKIE_SECURE=true` or the explicit `ALLOW_INSECURE_COOKIES=1` opt-out for
local HTTP.
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
cookie-authenticated **WebSocket upgrades** require the request `Origin`
(or `Referer` fallback) to match the `Host` header. Missing or mismatched
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
Form signup/login additionally reject a cross-origin `Origin`/`Referer`
when the browser sent one, before the body is read.
* `POST /users` and `POST /login` are rate limited per client:
`AUTH_RATE_LIMIT` attempts per `AUTH_RATE_WINDOW` seconds (default 30/60,
`0` disables). The client key is the rightmost `X-Forwarded-For` entry when
present (the address the last trusted proxy saw, not a spoofable leftmost
value), else the socket peer. Over-limit requests get `429` +
`Retry-After` before the body is read.
* Proxy requests strip client-supplied forwarding/identity headers in both
HTTP and WebSocket paths: `Forwarded`, every `X-Forwarded-*`
(including `X-Forwarded-User`), every `X-Auth-Request-*`, and `X-Real-IP`.
The gateway sets its own `X-Forwarded-For/Host/Proto/Port` values from the
connection. `UPSTREAM_HEADER_ALLOWLIST` optionally narrows the forwarded
client headers further (comma-separated, case-insensitive); unset keeps the
denylist behavior, and the gateway's own values are always added.
* The gateway's bearer token, the browser `Cookie` header and any `?token=`
query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
cookie is injected.
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are
absolute paths are rewritten under `/u/<user>/`, and any `token` query
parameter is stripped.
* Concurrency is capped per user: `MAX_IN_FLIGHT_PER_USER` proxied requests
(default 32) before the gateway answers `503` + `Retry-After` (HTTP) or
closes the WebSocket with `1011`. Messages received from DSH are limited to
`WS_MAX_MESSAGE_BYTES` (default 16 MiB); browser-to-DSH frames are bounded by
uvicorn's own WebSocket size limit.
### SSO (opt-in)
@@ -106,7 +138,8 @@ kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-Use
When set, the gateway treats a request carrying that header as authenticated as
that user, provided the value is a valid DNS-1123 label; the header is never
trusted when the variable is unset. The landing page then auto-provisions the
user (creates their claim idempotently, mints a rotated session token) and
user (creates their claim idempotently, rotates the claim token when a new
session is needed and mints an opaque session cookie) and
redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean
restart. The base manifests leave it unset on purpose; the
[SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to
@@ -133,14 +166,19 @@ oauth2-proxy's egress to the gateway, DNS and public HTTPS.
| --- | --- |
| Unknown user / claim | `404` |
| Missing or invalid credentials | `401` |
| Cross-origin cookie mutation | `403` |
| Unknown user on `/login` | `401` (same as a bad token; no username enumeration) |
| Cross-origin cookie mutation / cross-origin form signup or login | `403` |
| Auth route over `AUTH_RATE_LIMIT` | `429` + `Retry-After`, before the body is read |
| Request body over `MAX_BODY_MB` | `413` |
| Client aborts mid-body, before the response starts | `499` (`CLIENT_CLOSED_REQUEST`). Nothing is forwarded to DSH and nothing can be delivered; it is not an error, so no traceback is logged. Covers the body-reading control routes (`POST /users`, `POST /login`) as well as the proxy. |
| Client aborts mid-stream (SSE, long response) | The response already started, so its upstream status stands (typically `200`); the stream simply ends. No traceback, and the in-flight slot is released as the relay unwinds. |
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10` |
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10`. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
| Upstream connection/protocol failure | `502` |
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
| WS per-user concurrency cap | accept, then close `1011` |
| WS upstream/handshake failure | accept, then close `1011` |
| WS message from DSH over `WS_MAX_MESSAGE_BYTES` | the upstream/relay closes the connection (websockets' message-too-big path) |
A close code can only be delivered after the WebSocket upgrade is accepted;
Starlette would turn an early close into an HTTP 403, which is why WS auth
@@ -166,17 +204,27 @@ can also be injected into `create_app` for tests.
| `DSH_PORT` | `3080` | Upstream DSH port. |
| `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. |
| `DSH_LOG_TAIL_LINES` | `2000` | How many log lines to scan for the launch token. |
| `COOKIE_NAME` | `dsh_gw_session` | Browser session cookie name. |
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie when served over TLS. |
| `COOKIE_MAX_AGE` | `604800` | Session cookie lifetime in seconds. |
| `COOKIE_NAME` | `__Host-dsh_gw_session` when secure, else `dsh_gw_session` | Browser session cookie name. Only an opaque session id is stored in it. |
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie. Required unless `ALLOW_INSECURE_COOKIES=1` is set for local HTTP. |
| `ALLOW_INSECURE_COOKIES` | `0` | Explicit opt-out for plain-HTTP demos. Without it, `Settings.from_env()` refuses to start unless `COOKIE_SECURE=true`. |
| `COOKIE_MAX_AGE` | `604800` | Session lifetime in seconds (server-side expiry and cookie `Max-Age`). |
| `MAX_BODY_MB` | `32` | Maximum buffered request body (and `413` threshold). |
| `HTTP_TIMEOUT` | `300` | Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have **no read timeout**, so an idle event stream is not cut every five minutes. |
| `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. |
| `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. Sweeps also prune expired sessions and rate buckets and orphaned per-user state. |
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the matching `invite` form field or `X-Signup-Invite` header (timing-safe compare); the landing page then shows an invite input. Unset leaves signup open. |
| `AUTH_RATE_LIMIT` | `30` | Attempts allowed per client per `AUTH_RATE_WINDOW` on `POST /users` and `POST /login`; `0` disables the limit. |
| `AUTH_RATE_WINDOW` | `60` | Rate-limit window in seconds. |
| `UPSTREAM_HEADER_ALLOWLIST` | unset | Comma-separated, case-insensitive allowlist of client headers to forward upstream. Unset keeps the denylist behavior; forwarding/identity headers are stripped in both modes. |
| `MAX_IN_FLIGHT_PER_USER` | `32` | Concurrent proxied requests per user before `503`/WS `1011`. `0` disables the cap. |
| `WS_MAX_MESSAGE_BYTES` | `16777216` | Largest WebSocket message (16 MiB) accepted from DSH. Browser-to-DSH frames are bounded by uvicorn's own limit. |
| `AUTH_PROXY_HEADER` | unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. `X-Forwarded-User`). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see [SSO](#sso-opt-in)). |
The shipped `manifests/50-gateway.yaml` sets `IDLE_TIMEOUT=360` and
`WAKE_TIMEOUT=240` for the demo; the e2e script overrides them to 60/120 so the
smoke test does not wait minutes for the sweeper.
smoke test does not wait minutes for the sweeper. It also sets
`COOKIE_SECURE=false` together with the explicit `ALLOW_INSECURE_COOKIES=1`
acknowledgement because the demo is reached over plain HTTP (a `Secure` cookie
would never be sent). HTTPS deployments must set `COOKIE_SECURE=true`.
### Required RBAC
@@ -197,8 +245,10 @@ python -m pip install -r requirements-dev.txt
python -m pytest -q
python -m ruff check .
# Uvicorn factory (loads in-cluster credentials only on the first claim call)
uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
# Uvicorn factory (loads in-cluster credentials only on the first claim call).
# from_env() insists on an explicit acknowledgement for plain HTTP; behind TLS
# set COOKIE_SECURE=true instead.
ALLOW_INSECURE_COOKIES=1 uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
```
Container image, from the repository root:
@@ -226,18 +276,27 @@ uvicorn's access log so bearer tokens in URLs are not logged.
Log rotation, a very chatty container, or DSH changing its startup message
breaks the handshake. Prefer a first-class token/credential API when DSH
offers one.
* **Single replica, in-memory state.** The handshake cookie cache, idle
activity clocks and in-flight counters live in one process. Running more
than one replica would need shared state (or sticky routing), otherwise each
replica handshakes independently and the sweeper may race another replica.
* **Single replica, in-memory state.** The handshake cookie cache, the opaque
session table, idle activity clocks, in-flight counters and rate-limit
buckets live in one process. Running more than one replica would need shared
state (or sticky routing), otherwise each replica handshakes independently,
the sweeper may race another replica, and a session/rate budget is only seen
by the replica that created it.
* **Sessions die with the process.** An opaque session id is valid only in the
process that minted it and expires after `COOKIE_MAX_AGE`; a restart logs
every browser out (tokens are unaffected).
* **In-memory token cache has no TTL.** It is keyed by pod UID, so pod
restarts invalidate it naturally; otherwise entries survive until the pod or
claim is deleted.
* **No TLS termination.** Run behind an ingress/load balancer; set
`COOKIE_SECURE=true` there.
* **No TLS termination.** Run behind an ingress/load balancer and set
`COOKIE_SECURE=true`. `Settings.from_env()` refuses to start without it
unless `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges plain-HTTP
sessions.
* **Origin-based CSRF protection only.** Cookie-auth mutations require a
same-origin `Origin`/`Referer`; there is no per-form CSRF token, rate
limiting or login throttling.
same-origin `Origin`/`Referer`, and form signup/login reject a contradicting
origin when the browser sends one; there is no per-form CSRF token. Auth
routes have per-client rate limiting and login throttling, but the budget is
in-process (per replica) and keyed by the rightmost `X-Forwarded-For` value.
* **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not
streamed. Responses (including SSE) are streamed.
* **Upstream cookies are not forwarded.** DSH cannot rotate its own

View File

@@ -7,9 +7,12 @@ single teaching-friendly module with three jobs:
(only its SHA-256 is stored, as a claim annotation), report derived state,
and delete the claim (which cascades the sandbox and its PVC).
* **Browser sessions** -- exchange a user token for an HttpOnly session cookie
so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser requests
authenticate with that cookie; automation can keep using
``Authorization: Bearer <token>``.
whose value is an opaque, server-side session id (the user and expiry live in
memory) so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser
requests authenticate with that cookie; automation can keep using
``Authorization: Bearer <token>``. Sessions are revoked on logout, user
deletion, recreate and expiry, and a cookie is never accepted as a bearer
token (nor the other way around).
* **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the
sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the
gateway scrapes the launch token from the pod logs, performs the ``?token=``
@@ -38,6 +41,7 @@ import contextlib
import hashlib
import html
import json
import math
import os
import re
import secrets
@@ -121,6 +125,19 @@ OWNED_REQUEST_HEADERS = HOP_BY_HOP | {
"x-real-ip",
}
# Default session cookie names. ``__Host-`` is only legal over HTTPS and pins
# the cookie to Path=/ with no Domain attribute; the insecure default keeps the
# example usable over plain HTTP behind a port-forward.
DEFAULT_COOKIE_NAME = "dsh_gw_session"
SECURE_COOKIE_NAME = "__Host-dsh_gw_session"
# Header prefixes a client must never be able to smuggle upstream. The gateway
# derives its own X-Forwarded-* values from the connection; anything the browser
# sends with these names is attacker-controlled and is dropped in both the HTTP
# and WebSocket proxy paths.
FORWARDING_HEADER_PREFIXES = ("x-forwarded-", "x-auth-request-")
FORWARDING_HEADERS = frozenset({"forwarded"})
_DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)")
@@ -249,6 +266,33 @@ def same_origin(headers) -> bool:
return False
def strip_forwarding_header(name: str) -> bool:
"""True for client-supplied identity/forwarding headers we never forward.
``X-Forwarded-User`` and ``X-Auth-Request-*`` carry identities an
authenticating proxy or the gateway itself owns; ``Forwarded`` /
``X-Forwarded-*`` describe the hop chain. Forwarding any of them lets a
browser spoof its origin or a trusted SSO user upstream.
"""
lowered = name.lower()
return lowered in FORWARDING_HEADERS or lowered.startswith(FORWARDING_HEADER_PREFIXES)
def client_identity(headers, client) -> str:
"""The rate-limit bucket for a request.
The rightmost ``X-Forwarded-For`` entry is the address the last trusted
proxy saw (each proxy appends), so it cannot be spoofed by prepending
arbitrary values. Without the header fall back to the socket peer.
"""
forwarded = (headers.get("x-forwarded-for") or "").strip()
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[-1]
return getattr(client, "host", None) or "unknown"
def rewrite_location(location: str, user: str) -> str:
"""Prefix absolute-path redirects so the browser stays under ``/u/<user>/``."""
if not location.startswith("/") or location.startswith("//"):
@@ -306,6 +350,14 @@ def _env_float(name: str, default: float) -> float:
return default
def _env_allowlist(name: str) -> frozenset[str] | None:
"""Parse a comma-separated header allowlist; unset means "denylist mode"."""
raw = os.environ.get(name)
if raw is None:
return None
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
# --- Settings --------------------------------------------------------------
@@ -325,7 +377,7 @@ class Settings:
dsh_port: int = 3080
dsh_container: str = "dsh-web"
dsh_log_tail_lines: int = 2000
cookie_name: str = "dsh_gw_session"
cookie_name: str = DEFAULT_COOKIE_NAME
cookie_secure: bool = False
cookie_max_age: int = 7 * 24 * 60 * 60
max_body_bytes: int = 32 * 1024 * 1024
@@ -339,9 +391,36 @@ class Settings:
# valid user in this header are treated as authenticated (SSO); unset (None)
# disables SSO entirely and the header is never trusted.
auth_proxy_header: str | None = None
# Abuse controls. AUTH_RATE_LIMIT applies to the unauthenticated auth routes
# (POST /users and POST /login) per client and window; 0 disables it.
auth_rate_limit: int = 30
auth_rate_window: int = 60
# When set, signup requires this invite code (form field ``invite`` or the
# ``X-Signup-Invite`` header). None keeps signup open, as in the demo.
signup_invite_code: str | None = None
# Optional allowlist of client request headers to forward upstream. None
# keeps the default denylist behavior; a set (possibly empty) forwards only
# the named headers (plus the gateway's own X-Forwarded-* values).
upstream_header_allowlist: frozenset[str] | None = None
# Concurrent proxied requests per user before 503/1011 is returned.
max_in_flight_per_user: int = 32
# Largest WebSocket message accepted from DSH, in bytes. Browser-to-DSH
# frames are bounded by the ASGI server's own WebSocket limit.
ws_max_message_bytes: int = 16 * 1024 * 1024
@classmethod
def from_env(cls) -> Settings:
cookie_secure = _env_bool("COOKIE_SECURE", False)
allow_insecure = _env_bool("ALLOW_INSECURE_COOKIES", False)
if not cookie_secure and not allow_insecure:
raise RuntimeError(
"refusing to start with an insecure session cookie: set "
"COOKIE_SECURE=true behind TLS, or ALLOW_INSECURE_COOKIES=1 "
"for a local plain-HTTP demo"
)
cookie_name = os.environ.get("COOKIE_NAME") or (
SECURE_COOKIE_NAME if cookie_secure else DEFAULT_COOKIE_NAME
)
return cls(
namespace=os.environ.get("NAMESPACE", "dsh-demo"),
pool=os.environ.get("POOL", "dsh-pool"),
@@ -350,13 +429,19 @@ class Settings:
dsh_port=_env_int("DSH_PORT", 3080),
dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"),
dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000),
cookie_name=os.environ.get("COOKIE_NAME", "dsh_gw_session"),
cookie_secure=_env_bool("COOKIE_SECURE", False),
cookie_name=cookie_name,
cookie_secure=cookie_secure,
cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60),
max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024,
http_timeout=_env_float("HTTP_TIMEOUT", 300.0),
sweep_interval=_env_float("SWEEP_INTERVAL", 15.0),
auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None,
auth_rate_limit=_env_int("AUTH_RATE_LIMIT", 30),
auth_rate_window=_env_int("AUTH_RATE_WINDOW", 60),
signup_invite_code=os.environ.get("SIGNUP_INVITE_CODE") or None,
upstream_header_allowlist=_env_allowlist("UPSTREAM_HEADER_ALLOWLIST"),
max_in_flight_per_user=_env_int("MAX_IN_FLIGHT_PER_USER", 32),
ws_max_message_bytes=_env_int("WS_MAX_MESSAGE_BYTES", 16 * 1024 * 1024),
)
@@ -546,6 +631,19 @@ class Kubernetes:
# --- Gateway ---------------------------------------------------------------
@dataclass(frozen=True)
class Session:
"""A browser session stored server-side, keyed by an opaque random id.
The cookie only carries the id; the user and the expiry stay in memory, so
a cookie can never be edited into another user's credentials and a stolen
token is not recoverable from the browser.
"""
user: str
expires_at: float
@dataclass
class GatewayState:
"""In-memory, per-process state (single replica by design)."""
@@ -555,6 +653,10 @@ class GatewayState:
maintenance: dict[str, bool] = field(default_factory=dict)
# (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..."
handshake: dict[tuple[str, str, str], str] = field(default_factory=dict)
# Opaque session id -> server-side session (user + expiry).
sessions: dict[str, Session] = field(default_factory=dict)
# Auth rate limiting: client -> (window start, attempts in window).
auth_rate: dict[str, tuple[float, int]] = field(default_factory=dict)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -576,17 +678,40 @@ class Gateway:
# -- credentials -------------------------------------------------------
def session_for(self, sid: str | None) -> Session | None:
"""Look up a live session, dropping it when it has expired."""
if not sid:
return None
session = self.state.sessions.get(sid)
if session is None:
return None
if session.expires_at <= time.time():
self.state.sessions.pop(sid, None)
return None
return session
def revoke_session(self, sid: str | None) -> None:
if sid:
self.state.sessions.pop(sid, None)
def revoke_sessions(self, user: str) -> None:
for sid in [key for key, session in self.state.sessions.items() if session.user == user]:
self.state.sessions.pop(sid, None)
def credentials(self, headers, cookies) -> tuple[str | None, str | None]:
"""Return ``(token, cookie_user)``; bearer wins over the session cookie."""
"""Return ``(token, session_user)``; bearer wins over the session cookie.
A bearer token is never resolved from a cookie and a session id is never
accepted as a bearer token. An unknown or expired session is treated as
anonymous (and the expired entry is dropped).
"""
auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "):
return auth[7:].strip(), None
raw = cookies.get(self.settings.cookie_name)
if raw:
user, sep, token = raw.partition(":")
if sep and user and token:
return token, user
return None, None
session = self.session_for(cookies.get(self.settings.cookie_name))
if session is None:
return None, None
return None, session.user
def remote_user(self, headers) -> str | None:
"""The user asserted by a trusted reverse proxy, when SSO is enabled.
@@ -605,31 +730,37 @@ class Gateway:
self,
claim: dict,
token: str | None,
cookie_user: str | None,
session_user: str | None,
user: str,
remote: str | None = None,
) -> bool:
if remote == user:
return True # identity asserted by the authenticating proxy
if cookie_user is not None and cookie_user != user:
return False # a session for another user never authorizes this one
if session_user is not None:
return session_user == user # server-side session bound to this user
return token_matches(claim, token)
async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None:
"""Provision ``remote`` idempotently and mint/refresh a session token.
"""Provision ``remote`` idempotently and mint/refresh a session.
The authenticating proxy has already verified ``remote``, so it is
trusted. A brand-new user is provisioned (claim created, token minted);
an existing user whose session cookie is missing or no longer matches
gets a rotated token so a fresh cookie can be issued. A still-valid
session is reused untouched. Provisioning is non-blocking: the sandbox
is awaited by the proxy path (``_resolve_sandbox``), so the landing page
can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``.
an existing user whose session cookie is missing, foreign or no longer
matches gets a rotated token so a fresh cookie can be issued. A still-valid
session for the same user is reused untouched. Provisioning is
non-blocking: the sandbox is awaited by the proxy path
(``_resolve_sandbox``), so the landing page can report "Provisioning"
instead of stalling for ``WAKE_TIMEOUT``.
"""
claim = await self.k8s.get_claim(remote)
token, cookie_user = self.credentials(headers, cookies)
if claim is not None and cookie_user == remote and token_matches(claim, token):
return claim
sid = cookies.get(self.settings.cookie_name)
session = self.session_for(sid)
if session is not None:
if session.user != remote:
# A session for another identity never authorizes this user.
self.revoke_session(sid)
elif claim is not None:
return claim
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
is_new = claim is None
@@ -645,7 +776,7 @@ class Gateway:
else:
await self.k8s.patch_token(remote, token_hash)
await self._touch(remote)
self.set_session(response, remote, token)
self.set_session(response, remote)
return await self.k8s.get_claim(remote) if is_new else claim
@staticmethod
@@ -656,16 +787,73 @@ class Gateway:
and not same_origin(headers)
)
def set_session(self, response: Response, user: str, token: str) -> None:
def set_session(self, response: Response, user: str) -> str:
"""Mint an opaque server-side session and set the browser cookie."""
sid = secrets.token_urlsafe(32)
self.state.sessions[sid] = Session(
user=user,
expires_at=time.time() + self.settings.cookie_max_age,
)
response.set_cookie(
self.settings.cookie_name,
f"{user}:{token}",
sid,
max_age=self.settings.cookie_max_age,
path="/",
httponly=True,
samesite="lax",
secure=self.settings.cookie_secure,
)
return sid
# -- abuse controls ----------------------------------------------------
def _rate_limit(self, request: Request) -> Response | None:
"""Return a 429 response when this client is over the auth budget."""
limit = self.settings.auth_rate_limit
if limit <= 0:
return None
window = max(1, self.settings.auth_rate_window)
key = client_identity(request.headers, request.client)
now = time.time()
bucket = self.state.auth_rate.get(key)
if bucket is None or now - bucket[0] >= window:
self.state.auth_rate[key] = (now, 1)
return None
start, count = bucket
if count >= limit:
retry_after = max(1, math.ceil(start + window - now))
return self._error(
429,
"too many authentication attempts, retry shortly",
{"Retry-After": str(retry_after)},
)
self.state.auth_rate[key] = (start, count + 1)
return None
def _invite_allowed(self, request: Request, payload: dict) -> bool:
"""Check the signup invite code, when one is configured."""
expected = self.settings.signup_invite_code
if expected is None:
return True
provided = payload.get("invite")
if not isinstance(provided, str) or not provided:
provided = request.headers.get("x-signup-invite", "")
if not isinstance(provided, str) or not provided:
return False
return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
def _form_cross_origin(self, request: Request) -> bool:
"""Reject a form signup/login whose Origin/Referer contradicts Host.
Only checked when the browser actually sent one: non-browser clients
(automation, tests) commonly omit both headers and must keep working.
"""
if not self._wants_form(request):
return False
headers = request.headers
if not (headers.get("origin") or headers.get("referer")):
return False
return not same_origin(headers)
# -- activity / maintenance -------------------------------------------
@@ -674,12 +862,21 @@ class Gateway:
self.state.last_activity[user] = time.time()
async def _acquire(self, user: str) -> bool:
"""Reserve an in-flight slot, waiting out a concurrent suspend."""
"""Reserve an in-flight slot, waiting out a concurrent suspend.
Returns False when the user already has ``MAX_IN_FLIGHT_PER_USER``
requests in flight (an abuse guard, not a transient error) or when a
suspend does not finish within ``WAKE_TIMEOUT``.
"""
deadline = time.monotonic() + self.settings.wake_timeout
cap = self.settings.max_in_flight_per_user
while True:
async with self.state.lock:
if not self.state.maintenance.get(user):
self.state.in_flight[user] = self.state.in_flight.get(user, 0) + 1
current = self.state.in_flight.get(user, 0)
if cap > 0 and current >= cap:
return False
self.state.in_flight[user] = current + 1
self.state.last_activity[user] = time.time()
return True
if time.monotonic() > deadline:
@@ -759,14 +956,19 @@ class Gateway:
host: str,
force: bool = False,
) -> tuple[str, tuple[str, str, str], bool]:
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss."""
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.
Every failure raises ``HandshakeError`` with a fixed, credential-free
message: the exception reaches the browser in the 503 body, so it must
never carry the launch token, the handshake URL or pod details.
"""
pod_name = self._pod_name(sandbox)
try:
pod = await self.k8s.get_pod(pod_name)
except ApiException as exc:
raise HandshakeError(f"could not read pod {pod_name}: {exc}") from exc
raise HandshakeError("could not read the DSH handshake pod") from exc
if pod is None:
raise HandshakeError(f"pod {pod_name} not found")
raise HandshakeError("the DSH handshake pod is not available")
uid = (pod.get("metadata") or {}).get("uid") or pod_name
key = (user, uid, host)
if not force:
@@ -780,7 +982,7 @@ class Gateway:
self.settings.dsh_log_tail_lines,
)
except ApiException as exc:
raise HandshakeError(f"could not read logs for {pod_name}: {exc}") from exc
raise HandshakeError("could not read the pod logs for the handshake") from exc
launch_token = parse_dsh_launch_token(logs)
if not launch_token:
raise HandshakeError("DSH launch token not found in pod logs")
@@ -798,12 +1000,12 @@ class Gateway:
timeout=self.settings.http_timeout,
)
except httpx.HTTPError as exc:
raise HandshakeError(f"DSH handshake failed: {exc}") from exc
raise HandshakeError("the DSH handshake request failed") from exc
if response.status_code >= 400:
raise HandshakeError(f"DSH handshake returned {response.status_code}")
raise HandshakeError("the DSH handshake returned an error status")
cookie = extract_dsh_cookies(response.headers.get_list("set-cookie"))
if not cookie:
raise HandshakeError("DSH handshake set no dsh-auth cookie")
raise HandshakeError("the DSH handshake set no auth cookie")
self.state.handshake[key] = cookie
return cookie, key, False
@@ -879,9 +1081,9 @@ class Gateway:
if "set-cookie" in response.headers:
page.headers["set-cookie"] = response.headers["set-cookie"]
return page
token, user = self.credentials(request.headers, request.cookies)
_token, user = self.credentials(request.headers, request.cookies)
session = ""
if user and token:
if user:
safe_user = html.escape(user)
session = (
f'<p class="session">Signed in as <strong>{safe_user}</strong>. '
@@ -889,6 +1091,11 @@ class Gateway:
'<form method="post" action="/logout">'
'<button type="submit">Sign out</button></form>'
)
invite_input = (
'<input name="invite" type="password" placeholder="invite code" required>'
if self.settings.signup_invite_code is not None
else ""
)
body = f"""<!doctype html>
<html lang="en">
<head>
@@ -911,6 +1118,7 @@ class Gateway:
<h2>Create a sandbox</h2>
<form method="post" action="/users">
<input name="user" placeholder="username (dns-1123 label)" required>
{invite_input}
<button type="submit">Create</button>
</form>
</section>
@@ -970,11 +1178,18 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- users -------------------------------------------------------------
async def signup(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request)
try:
payload = await self._read_payload(request)
except BodyTooLarge:
return self._error(413, "request body too large")
if not self._invite_allowed(request, payload):
return self._error(403, "invalid or missing invite code")
user = payload.get("user", "")
if not valid_user(user):
return self._error(400, "body must be {'user': '<dns-1123 label>'}")
@@ -992,16 +1207,21 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
sandbox_name = (sandbox or {}).get("metadata", {}).get("name")
if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303)
self.set_session(response, user, token)
self.set_session(response, user)
return response
response = JSONResponse(
{"user": user, "token": token, "state": state, "sandbox": sandbox_name},
status_code=201 if state == "Ready" else 202,
)
self.set_session(response, user, token)
self.set_session(response, user)
return response
async def login(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request)
try:
payload = await self._read_payload(request)
@@ -1012,21 +1232,29 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if not isinstance(user, str) or not isinstance(token, str):
return self._error(400, "user and token are required")
claim = await self.k8s.get_claim(user)
if claim is None:
return self._error(404, "not found")
if not token_matches(claim, token):
# An unknown user is indistinguishable from a bad token to the client:
# never reveal which usernames exist.
if claim is None or not token_matches(claim, token):
return self._error(401, "unauthorized")
await self._touch(user)
if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303)
else:
response = JSONResponse({"user": user, "state": "ok"})
self.set_session(response, user, token)
self.set_session(response, user)
return response
async def logout(self, request: Request) -> Response:
self.revoke_session(request.cookies.get(self.settings.cookie_name))
response = RedirectResponse("/", status_code=303)
response.delete_cookie(self.settings.cookie_name, path="/")
# Match the attributes the cookie was set with: a __Host- cookie
# deletion that lacks Secure is rejected by browsers, leaving a stale
# (though already revoked) cookie behind.
response.delete_cookie(
self.settings.cookie_name,
path="/",
secure=self.settings.cookie_secure,
)
return response
async def get_user(self, request: Request) -> Response:
@@ -1061,6 +1289,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "DELETE", cookie_user):
return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(user)
self.revoke_sessions(user)
self._clear_handshake(user)
async with self.state.lock:
self.state.last_activity.pop(user, None)
@@ -1084,6 +1313,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "POST", cookie_user):
return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(remote)
self.revoke_sessions(remote)
self._clear_handshake(remote)
async with self.state.lock:
self.state.last_activity.pop(remote, None)
@@ -1111,7 +1341,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
return RedirectResponse("/", status_code=303)
return self._error(404, "unknown user")
token, cookie_user = self.credentials(request.headers, request.cookies)
if remote == user and not (cookie_user == user and token_matches(claim, token)):
if remote == user and cookie_user != user:
# SSO user without a valid session cookie: bootstrap, then continue.
return RedirectResponse("/", status_code=303)
if not self._authorized(claim, token, cookie_user, user, remote):
@@ -1171,11 +1401,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if query:
url = f"{url}?{query}"
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in OWNED_REQUEST_HEADERS and key.lower() != "content-length"
}
headers = {}
allowlist = self.settings.upstream_header_allowlist
for key, value in request.headers.items():
lowered = key.lower()
if lowered in OWNED_REQUEST_HEADERS or lowered == "content-length":
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers are spoofable
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
host = request.headers.get("host", "")
headers["host"] = host
headers["cookie"] = cookie # only the cached DSH auth cookie
@@ -1309,13 +1545,19 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
query = filtered_query(websocket.url.query)
if query:
url = f"{url}?{query}"
headers = {
key: value
for key, value in websocket.headers.items()
if key.lower() not in OWNED_REQUEST_HEADERS
and key.lower() != "origin"
and not key.lower().startswith("sec-websocket-")
}
headers = {}
allowlist = self.settings.upstream_header_allowlist
for key, value in websocket.headers.items():
lowered = key.lower()
if lowered in OWNED_REQUEST_HEADERS or lowered == "origin":
continue
if lowered.startswith("sec-websocket-"):
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
headers["cookie"] = cookie
headers["x-forwarded-host"] = host
headers["x-forwarded-proto"] = websocket.url.scheme
@@ -1331,7 +1573,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
additional_headers=headers,
subprotocols=subprotocols or None,
open_timeout=self.settings.wake_timeout,
max_size=None,
max_size=self.settings.ws_max_message_bytes,
proxy=None,
)
upstream = await upstream_cm.__aenter__()
@@ -1391,8 +1633,41 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- idle sweeper ------------------------------------------------------
def _prune_state(self, claims: list[dict]) -> None:
"""Drop state that can no longer be reached.
Sessions and activity clocks for a user whose claim is gone, expired
sessions and expired rate-limit buckets would otherwise accumulate for
the lifetime of the process. In-flight counters are only pruned at
zero, and a user with work in flight is always preserved.
"""
now = time.time()
present = set()
for claim in claims:
name = (claim.get("metadata") or {}).get("name") or ""
if name.startswith("dsh-"):
present.add(name[len("dsh-") :])
for user in [user for user in self.state.last_activity if user not in present]:
self.state.last_activity.pop(user, None)
for user, count in list(self.state.in_flight.items()):
if user not in present and count == 0:
self.state.in_flight.pop(user, None)
for user, flag in list(self.state.maintenance.items()):
if user not in present and not flag:
self.state.maintenance.pop(user, None)
for key in [key for key in self.state.handshake if key[0] not in present]:
self.state.handshake.pop(key, None)
for sid, session in list(self.state.sessions.items()):
if session.user not in present or session.expires_at <= now:
self.state.sessions.pop(sid, None)
window = max(1, self.settings.auth_rate_window)
for key, (start, _count) in list(self.state.auth_rate.items()):
if now - start >= window:
self.state.auth_rate.pop(key, None)
async def sweep_once(self) -> None:
claims = await self.k8s.list_claims()
self._prune_state(claims)
for claim in claims:
# Only suspend claims this gateway created. Everything else in
# the namespace (resource-layer claims from the README walkthrough,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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"},