1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
//! Origin guard for the now browser-facing, action-capable chat endpoint.
//!
//! Why: the DOC-20 §6 / ONB-3 Web surface (`GET /web`) makes a browser drive the
//! state-mutating `POST /api/v1/sessions/chat` with `actions: true`, which can
//! spawn/stop/mutate managed sessions. The daemon binds loopback by default, but
//! if it is ever reachable from a browser on a non-loopback interface, a
//! malicious cross-origin page could silently POST to that endpoint and trigger
//! session actions (classic CSRF). This module is the lightweight mitigation: a
//! same-origin/loopback Origin check that costs nothing for the existing
//! server-side callers.
//! What: [`origin_allowed`] inspects the request's `Origin` header (if any) and
//! its `Host`. With NO `Origin` header the request is allowed — this is the
//! server-side case (the Telegram adapter, the TUI, and `curl` all call the
//! endpoint via reqwest/localhost and send no `Origin`), so all current behavior
//! is preserved. With an `Origin` header present, the request is allowed only if
//! the origin is loopback (`http://127.0.0.1[:port]`, `http://localhost[:port]`,
//! `http://[::1][:port]`) or matches the request `Host`; any other (cross-origin)
//! browser request is rejected.
//! Test: `origin_guard_tests` covers no-Origin → allowed, loopback/same-origin →
//! allowed, and cross-origin → rejected.
use Router;
use HeaderMap;
use ;
/// Apply the shared router-wide same-origin write guard to the daemon router
/// (#3304).
///
/// Why: before #3304 only the `coordinator_chat` handler was origin-checked (via
/// [`origin_allowed`], above); every OTHER destructive route (`POST /sessions`,
/// `DELETE /sessions/{id}`, `POST /api/v1/control/sessions/{id}/stop`,
/// managed-session mutation, `/claude-config/apply`, `/pair/*`) was exposed to
/// cross-origin CSRF. Layering the shared `trusty_common` guard router-wide (a
/// plain `.layer()` AFTER `with_state`, applied at the serve site in
/// `daemon::mod` so it wraps every route incl. merged sub-routers — the #3268
/// lesson) covers them all. `coordinator_chat` KEEPS its own finer-grained
/// [`origin_allowed`] check and `/rpc` KEEPS its loopback `ConnectInfo` gate —
/// belt and braces. Only the guard is layered (NOT `with_standard_middleware`)
/// so the daemon's existing no-CORS posture is unchanged.
/// What: wraps `router` with `guard_write_origin` carrying `self_origins`. The
/// daemon binds loopback only (ADR-0011 loopback-only doctrine, #3330) — there
/// is no secondary listener, so only `SelfOrigins::default()` is ever passed;
/// non-loopback ingress is exclusively `trusty-console`'s job. Method-gated +
/// fail-open-on-missing-Origin, so GET reads, SSE streams, the `serve --stdio`
/// bridge, `curl`, and the TUI/Telegram adapters are unaffected.
/// Test: `origin_guard_router_tests` (cross-origin `POST /sessions` → 403;
/// loopback/missing-Origin → allowed; GET read unaffected).
/// Decide whether a request to the action-capable chat endpoint is allowed by the
/// CSRF/origin guard.
///
/// Why: this is the single, unit-testable seam for the CSRF mitigation. Keeping
/// the policy in one pure function (header map in, bool out) means the handler
/// stays a thin caller and the allow/reject matrix is provable without spinning
/// up a server. The no-`Origin` allowance is what keeps every existing
/// server-side caller (Telegram/TUI via reqwest, `curl`) working unchanged —
/// only real browsers attach an `Origin` header to a `POST`.
/// What: returns `true` when the request carries NO `Origin` header (server-side
/// callers), or when the `Origin` is loopback (`127.0.0.1`, `localhost`, `::1`,
/// any scheme/port) or its host:port matches the request's `Host` header
/// (same-origin). Returns `false` for any other present `Origin` (cross-origin),
/// and for a malformed `Origin` that is neither loopback nor same-origin.
/// Test: `origin_guard_tests::{no_origin_allowed, loopback_origin_allowed,
/// same_origin_allowed, cross_origin_rejected}`.
/// Read a header as a UTF-8 `&str`, treating absent/non-UTF-8 as missing.
///
/// Why: header values are bytes; the guard only reasons about textual origins, so
/// a non-UTF-8 value should be treated as "no usable header" rather than panic.
/// What: returns `Some(&str)` when the header exists and is valid UTF-8, else
/// `None`.
/// Test: exercised indirectly via [`origin_allowed`] tests.
/// Extract the `host[:port]` authority from an `Origin` value.
///
/// Why: an `Origin` is `scheme://host[:port]`; the guard compares authorities
/// (and detects loopback) on the host part, so the scheme prefix must be
/// stripped. Doing it by hand avoids pulling in a URL-parser dependency for one
/// header.
/// What: strips a leading `scheme://` (everything up to and including the first
/// `://`) AND any trailing `/path` component, returning just the `host[:port]`
/// authority; returns `None` when there is no `://` (e.g. the literal `null`,
/// which browsers send for opaque origins). The trailing-path strip means an
/// `Origin` like `http://localhost:8765/` (a stray slash) is still treated as the
/// `localhost:8765` authority rather than `localhost:8765/`, which would never
/// match a `Host` and would wrongly be rejected.
/// Test: exercised via [`origin_allowed`] tests (`cross_origin_rejected` includes
/// a `null` origin; `same_origin_with_trailing_slash_allowed` covers the slash).
/// Whether an authority (`host[:port]`) names the loopback interface.
///
/// Why: the daemon serves the browser page itself, so a same-machine browser's
/// `Origin` is loopback; those must always pass regardless of which loopback
/// alias or ephemeral port the browser used.
/// What: returns `true` when the host portion (authority with any `:port`
/// stripped, and any IPv6 brackets removed) is `127.0.0.1`, `localhost`, or
/// `::1`. Case-insensitive on `localhost`.
/// Test: `origin_guard_tests::loopback_origin_allowed`.