Skip to content

Commit 1ac93d9

Browse files
committed
Security hardening pass for 1.0.0
- Validate the host argument in build_request_head against CR/LF/NUL, closing the one field that skipped the CRLF-injection screen (the self-built Host header), with a regression test - docs/security-assessment.md: record the 1.0.0 security assessment — trust model, attacker scenarios, mitigations, accepted risks, and re-assessment triggers (OSPS-SA-03) - Louder warning on verify=False in the transports guide
1 parent 5914d37 commit 1ac93d9

5 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎docs/security-assessment.md‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Security assessment
2+
3+
*Last performed: 2026-09-01, against the 1.0.0 release candidate.
4+
Re-run this assessment for any release that adds a new transport,
5+
provider, credential flow, or parser.*
6+
7+
This page records what could realistically go wrong when you use lm15,
8+
what the library does about it, and what it deliberately leaves to you.
9+
The vulnerability reporting process lives in
10+
[SECURITY.md](https://github.com/lm15-dev/lm15-python/blob/main/SECURITY.md).
11+
12+
## Trust model
13+
14+
- **You choose the endpoints.** lm15 sends requests to the base URLs you
15+
(or the built-in provider defaults) configure. It never follows
16+
redirects and never rewrites a host behind your back.
17+
- **Providers are semi-trusted.** We trust them with the request content
18+
you send, but we parse their responses defensively: a malicious or
19+
compromised server should produce a clean `ProtocolError` or
20+
`TransportError`, not memory exhaustion or code execution.
21+
- **Your machine is trusted.** Credentials read from environment
22+
variables or local credential files are assumed to be yours. lm15
23+
protects them from *accidental* disclosure (logs, reprs, world-readable
24+
files), not from an attacker who already controls your machine.
25+
26+
## What an attacker might try, and what stands in the way
27+
28+
### Steal API keys or OAuth tokens
29+
30+
- All provider `api_key` fields are declared `repr=False`; the OAuth
31+
adapters (Claude Code, Codex) override `__repr__` explicitly. Printing
32+
or logging a provider object does not disclose secrets.
33+
- `lm15.doctor` explains credential resolution without ever returning
34+
secret material, by construction.
35+
- Refreshed OAuth credentials are written atomically with `0o600`
36+
permissions (owner-only) under a lock file (`lm15/_authlock.py`).
37+
- OAuth login flows use PKCE (S256, 64 random bytes from `secrets`), a
38+
callback listener bound to `127.0.0.1` only, and `state` checking.
39+
Token refresh goes to hardcoded HTTPS endpoints.
40+
- Known trade-off: the Gemini Live websocket authenticates with
41+
`?key=` in the URL, as the API requires. The connection is `wss://`
42+
(encrypted), so the exposure is client-side URL logging only. If
43+
Google's live API gains handshake-header auth, we should switch.
44+
45+
### Intercept traffic (adversary in the middle)
46+
47+
- TLS certificate verification is **on by default**, using the system
48+
trust store via `ssl.create_default_context()`.
49+
- `verify=False` exists for local development against self-signed
50+
servers. It is never a default and must be typed explicitly. Prefer
51+
`ca_bundle=` for private test CAs.
52+
- Proxies: only plain-HTTP proxies are supported; HTTPS targets are
53+
tunneled with CONNECT and the TLS handshake runs end-to-end to the
54+
origin — the proxy never sees inside the tunnel.
55+
56+
### Smuggle or inject through the HTTP layer
57+
58+
The stdlib-only HTTP/1.1 codec (`lm15/transports/_http11.py`) is the
59+
most exposed code and is written accordingly:
60+
61+
- CR/LF/NUL bytes are rejected in the method, target, host, and every
62+
header name and value (CRLF-injection screen).
63+
- Response heads are capped at 64 KB; chunk-size lines at 16 KB.
64+
- `Transfer-Encoding` stacking (`gzip, chunked`) is rejected;
65+
malformed or negative `Content-Length` is rejected; data past the
66+
declared length is a protocol error. These are the classic
67+
request-smuggling and desync levers.
68+
- SSE streams enforce a 64 KB per-line and 1 MB per-event limit.
69+
- Connect, read, and write timeouts all have non-infinite defaults.
70+
71+
### Trick the library into running code
72+
73+
- The codebase contains no `eval`, `exec`, `pickle`, subprocess, or
74+
shell invocation. All parsing of provider data is `json` +
75+
hand-written byte parsers.
76+
- There is **no automatic tool-execution loop**: when a model requests a
77+
tool call, lm15 hands the parsed request to your code and does
78+
nothing on its own. Executing model-chosen actions is always an
79+
explicit decision of the application.
80+
81+
### Poison the supply chain
82+
83+
- Runtime dependencies: **none** (Python stdlib only). The single
84+
optional extra is `websockets`, needed only for live sessions.
85+
- Behavior is pinned by the cross-language conformance corpus in
86+
`lm15-contract` at an exact SHA (`CONTRACT_PIN`); CI fails on drift.
87+
88+
## Accepted risks (known and deliberate)
89+
90+
1. **Unbounded non-streaming response bodies.** A hostile server could
91+
send an enormous body to a non-streaming call and exhaust memory.
92+
You choose your endpoints, so we accept this; a `max_body` cap is a
93+
candidate hardening if untrusted-endpoint use cases appear.
94+
2. **`verify=False` exists.** It is an explicit, documented,
95+
local-development-only escape hatch.
96+
3. **Gemini Live key-in-URL** (see above) — required by the API today.
97+
98+
## Re-assessment triggers
99+
100+
Run this assessment again (and update this page) when any of these
101+
change: a new transport or protocol, a new credential flow, a new
102+
parser over untrusted bytes, redirect support, or a new dependency.

‎docs/using-the-transports.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ For local development only, verification can be disabled:
215215
transport = StdlibTransport(verify=False)
216216
```
217217

218+
!!! danger "Never ship `verify=False`"
219+
Disabling verification removes the only proof that you are talking
220+
to the real server. Anyone on the network path can then read and
221+
rewrite your traffic, including your API keys. For private test
222+
CAs, use `ca_bundle=` instead — it keeps verification on.
223+
218224
## Headers and defaults
219225

220226
The HTTP/1.1 codec adds defaults only when the caller did not provide them:

‎lm15/transports/_http11.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def build_request_head(
5353
"""
5454
_validate_field("method", method)
5555
_validate_field("target", target)
56+
_validate_field("host", host)
5657
for k, v in headers:
5758
_validate_field(f"header name ({k})", k)
5859
_validate_field(f"header value ({k})", v)

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ nav:
6363
- Model profiles & compat: using-model-profiles.md
6464
- Transports: using-the-transports.md
6565
- Design rationale: design-rationale.md
66+
- Security assessment: security-assessment.md
6667
- Cookbooks:
6768
- Overview: cookbooks/index.md
6869
- Essentials:

‎tests/transports/test_http11.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ def test_forbids_bare_newline_in_target(self) -> None:
116116
port=443, is_tls=True, headers=[], body_length=None,
117117
)
118118

119+
def test_forbids_bare_newline_in_host(self) -> None:
120+
# The Host header is built from the host argument; it must get the
121+
# same CRLF-injection screening as caller-supplied headers.
122+
with pytest.raises(ProtocolError):
123+
build_request_head(
124+
method="GET", target="/", host="example.com\r\nInjected: yes",
125+
port=443, is_tls=True, headers=[], body_length=None,
126+
)
127+
119128

120129
class TestResponseHeadParser:
121130
def _feed(self, parser: ResponseHeadParser, data: bytes) -> bool:

0 commit comments

Comments
 (0)