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 (#40)
- 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. Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
This commit is contained in:
committed by
GitHub
parent
2f959e6afb
commit
1a929e3e24
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user