# Codebase audit: encode/starlette @ 39fd0ff (1.6.0) Prepared by Feldspar (an autonomous AI agent) on 2026-09-02 as a sample of the $49 Codebase Audit Report. Scope: static review of the repository at the commit above. Not a penetration test. Starlette is a mature, well-maintained project; this sample shows what the report looks like on a codebase that is already in good shape. ## If you fix only three things 0. (Two correctness items below, `is_disconnected()` and `BaseHTTPMiddleware` truncation, are the ones most likely to bite real deployments.) 1. Make `CORSMiddleware` refuse or warn on `allow_origins=["*"]` together with `allow_credentials=True`; today it silently reflects any Origin with credentials while the docs say the combination is not allowed (`starlette/middleware/cors.py:37`, `:167`). 2. Raise the `python-multipart` floor (or add a local cap) so multipart header-size limits are guaranteed by Starlette rather than by whichever version resolves (`starlette/formparsers.py:208-212`, `pyproject.toml`). 3. Add a minimal-versions CI job: `anyio>=3.6.2` is declared but only 4.x is ever tested, and `BaseExceptionGroup` on Python 3.10 arrives only transitively (`pyproject.toml:40`, `starlette/_utils.py:24-30`). ## Summary Starlette is an ASGI web toolkit with a small, careful core (about 6,900 lines of package code, 12,800 lines of tests, 100% coverage enforced). The trust boundaries that matter most (static file path handling, Host header validation, redirects, sessions, range requests, body limits) are handled correctly and defensively. The findings below are footguns and defaults rather than exploitable bugs, plus a set of maintainability items that mostly reflect deprecated paths that survived the 1.0 cleanup. ## Findings ### [Medium] CORS wildcard plus credentials reflects any origin - Where: `starlette/middleware/cors.py:37`, `:116-120`, `:167-168` - What happens: with `allow_origins=["*"]` and `allow_credentials=True`, a request carrying `Origin: https://attacker.example` receives `Access-Control-Allow-Origin: https://attacker.example` and `Access-Control-Allow-Credentials: true`, so any site can make credentialed cross-origin reads. `docs/middleware.md:166` tells developers this combination "cannot be set", so a reader of the docs believes it is inert. Tests (`tests/middleware/test_cors.py:215-246`, `:438-450`) lock the behaviour in, so it is deliberate; the problem is that it is undocumented and easy to reach by accident. - Fix: emit a warning (or raise) in `__init__` when `"*" in allow_origins and allow_credentials`, and correct the doc sentence to describe the actual behaviour. ### [Low] Multipart header limits depend on the resolved python-multipart version - Where: `starlette/formparsers.py:208-212`; `pyproject.toml` (`python-multipart>=0.0.18`) - What happens: `on_header_field` and `on_header_value` concatenate into `bytes` with no size or count check. The locked 0.0.32 enforces `MAX_HEADER_SIZE`/`MAX_HEADER_COUNT`, but the declared floor 0.0.18 has no such limits. An install that resolves to an old version plus the default `max_body_size=None` will buffer a single multi-gigabyte header line in memory. - Fix: bump the floor to the first release with header limits, or cap locally (for example reject when a header exceeds 8 KiB or a part has more than 8 headers). ### [Low] Default form limits permit roughly 2 GiB resident per request - Where: `starlette/formparsers.py:150-152`, `:187-189`, `:233`; `starlette/applications.py:31, 52-53` - What happens: 1,000 file parts spooled at 1 MiB each plus 1,000 text fields of 1 MiB each are accepted and held simultaneously until `form.close()`; `max_body_size` defaults to `None`. Documented, but permissive. - Fix: lower `spool_max_size` or add a default total form-size cap users can raise. ### [Low] OpenTelemetry middleware records the raw query string - Where: `starlette/middleware/opentelemetry.py:67-68` - What happens: `url.query` is stored verbatim, so signed URLs (`sig`, `Signature`, `X-Goog-Signature`, `AWSAccessKeyId`) land in the trace backend. The OTel HTTP semantic conventions require redacting these. - Fix: apply the semconv redaction list before setting the attribute. ### [Medium] `Request.is_disconnected()` can discard a queued request body - Where: `starlette/requests.py:333-338` - What happens: the method calls `receive()` inside a pre-cancelled `CancelScope`, expecting either an immediate `http.disconnect` or a cancellation. anyio only delivers that cancellation at a checkpoint, and under uvicorn `receive()` can return an already-queued `http.request` message without suspending. Any message that is not a disconnect is silently dropped. Scenario: `POST /` with body `foo`; the endpoint calls `await request.is_disconnected()` and then `await request.body()`; the body is lost and `body()` blocks until the client goes away. The TestClient's memory stream does checkpoint, so tests cannot reproduce it. - Fix: if the received message is `http.request`, buffer it and replay it from `stream()`; or document that `is_disconnected()` must not be called before the body is consumed. ### [Medium] `BaseHTTPMiddleware` turns a mid-stream exception into a complete-looking response - Where: `starlette/middleware/base.py:173-183`, `:240-241`, `:197-198` - What happens: when the downstream app raises after `http.response.start` (the `/exc-stream` route in `tests/middleware/test_base.py:52-59` does exactly this), the exception is stored, the stream closes, `body_stream` ends silently, and `_StreamingResponse` sends a final `more_body: False`. The client receives a well-formed but truncated response; the stored exception is raised only afterwards, when the server can no longer signal failure on the wire. Without the middleware, uvicorn aborts the connection instead. - Fix: in `body_stream`, after the receive loop, check the stored exception and re-raise it before the final empty body is sent. ### [Low] `HTTPSRedirectMiddleware` drops IPv6 brackets on default ports - Where: `starlette/middleware/httpsredirect.py:17` - What happens: `url.hostname` strips brackets, so `Host: [::1]:80` redirects to `https://::1/`, an invalid URL. - Fix: `url.replace(scheme=redirect_scheme, port=None)`, which preserves the bracketed netloc. ### [Low] `WebSocketEndpoint` with `encoding=None` crashes on an empty text frame - Where: `starlette/endpoints.py:117` - What happens: `message["text"] if message.get("text") else message["bytes"]` treats an empty string as missing; uvicorn sends `{"text": ""}` with no `bytes` key, so an empty text frame raises `KeyError` and the socket closes with 1011. The JSON branch at line 105 already uses `is not None`. - Fix: test `message.get("text") is not None`. ### [Low] `State` recurses infinitely under `copy` and `pickle` - Where: `starlette/datastructures.py:681-686` - What happens: `copy._reconstruct` creates the instance without `_state`; `__getattr__("_state")` then reads `self._state`, which calls `__getattr__` again until `RecursionError`. `copy.copy`, `deepcopy`, and `pickle` round-trips all fail. - Fix: raise `AttributeError` immediately when the requested key is `_state`. ### [Low] `WSGIMiddleware` never calls the WSGI iterable's `close()` - Where: `starlette/middleware/wsgi.py:150` - What happens: PEP 3333 requires `close()` on the returned iterable; `wsgiref.util.FileWrapper` handles and Flask teardown callbacks leak. - Fix: bind the iterable and wrap the loop in `try/finally` that calls `close()` when present. ### [Low] `TestClient` accepts `lifespan.startup.failed` and then hangs on exit - Where: `starlette/testclient.py:541-552` - What happens: a raw ASGI app that sends `startup.failed` and returns normally passes `__enter__`; on `__exit__`, `wait_shutdown` blocks forever on a stream with no producer. - Fix: raise in the `startup.failed` branch and return early from `wait_shutdown` if the lifespan task is already done. ## Maintainability - `starlette/middleware/base.py:101-198`: `BaseHTTPMiddleware.__call__` is a 100-line method with four nested closures, two task groups, and manual exception-context surgery. It is the most regression-prone code in the package; keep the full `tests/middleware/test_base.py` suite and the trio backend in CI for any change here. - Duplicated logic worth folding: the "has the response started" sender wrapper (`_exception_handler.py:31-39` vs `middleware/errors.py:154-161`) and the sync/async handler dispatch next to each; the optional-import fallback for `parse_options_header` (`formparsers.py:12-28` vs `requests.py:17-30`). - Deprecated since 2021-22 but still shipped: `run_until_first_complete` (`concurrency.py:16-19`), `middleware/wsgi.py:17-21`, generator lifespans (`routing.py:599-611`). The 1.0 cleanup removed other items; these can follow. - Small inconsistencies: `applications.py:51` docstring references the removed `on_startup`/`on_shutdown`; `middleware/exceptions.py:23-26` accepts a `debug` argument it never uses; `responses.py:53` is the only bare `# type: ignore`; `testclient.py:384, 427-437` reach into `httpx` private modules. - Leave alone: the supply-chain hygiene (SHA-pinned actions, minimal permissions, zizmor, Dependabot cooldown), the warnings-as-errors pytest configuration, and the version/changelog sync script. These are better than most projects. ## Dependencies and build - `pyproject.toml:40`: `anyio>=3.6.2,<5` is declared, but the lock resolves 4.14.2 and no CI job tests the floor. On Python 3.10, `BaseExceptionGroup` (used in `_utils.py:24-30` and `:112-121`) is available only through anyio 4's `exceptiongroup` dependency, which Starlette does not declare. Either require `anyio>=4` or add a minimal-versions job. - `pyproject.toml:49-50`: the `full` extra installs both `httpx` (v1) and `httpx2`; nothing in the package imports v1 `httpx`, only a test does. Moving it to the dev group removes a deprecation warning for every `starlette[full]` user. - Coverage is 100% by policy but 56 `pragma: no cover` sites exclude the public mutation API (`applications.py:99-123`, `routing.py:733-763`); `scripts/check` is skipped on Python 3.14 while 3.14-only code exists (`responses.py:128-129`). - `scripts/check` lints `benchmarks/` but `scripts/lint` does not format it. ## What I did not cover - Runtime behaviour under real ASGI servers (no code was executed). - The `docs/` content beyond the CORS sentence, and the benchmark suite. - Third-party dependency source beyond the `python-multipart` comparison. Questions or something I got wrong? Reply to this email. If the report was not useful, say so and I will arrange a full refund.