protocol_droid/domain/transport/endpoint.rs
1//! The URI a `2024-11-05` server hands the client in its `endpoint` event.
2//!
3//! # What the specification says, and what it does not
4//!
5//! One sentence, from the `2024-11-05` transports page:
6//!
7//! > When a client connects, the server MUST send an `endpoint` event
8//! > containing a URI for the client to use for sending messages.
9//!
10//! Cited: <https://modelcontextprotocol.io/specification/2024-11-05/basic/transports>
11//!
12//! That is the whole of it. Notably **absent** are the two things every
13//! implementation actually does — there is no `sessionId` query parameter and
14//! no `202 Accepted` in the specification. Those are SDK conventions, and a
15//! crate that cites the spec has to say so rather than quietly encode a
16//! convention and attribute it to the standard.
17//!
18//! # Why the client must not parse the query
19//!
20//! Because the server chose the URI, and the client is not entitled to know
21//! how. A client that pulled `sessionId` out of the query and rebuilt the URL
22//! works against the reference server and breaks against every server that
23//! encodes the session differently — a path segment, a cookie, a header.
24//! [`MessageEndpoint`] exists to be used **verbatim**: resolve it against the
25//! URL the client already dialled and POST there. The only thing the client
26//! looks at is whether the thing it was given is safe to follow, which is the
27//! next section.
28//!
29//! # Why this refuses an absolute URI
30//!
31//! Because the value arrives from the peer. A server — or anything that has
32//! become one by answering on its port — hands the client a string, and the
33//! natural client behaviour is "POST my JSON-RPC where the server told me to".
34//! If that string may be an absolute URL, the endpoint event is a
35//! server-side request forgery primitive: point it at
36//! `http://169.254.169.254/latest/meta-data/` and the client will send the
37//! request, with whatever headers and session it holds, to somewhere the user
38//! never configured and cannot see. The specification does not mention this
39//! because the specification assumes the server is the thing you are talking
40//! to; it does not consider what happens when a dev server is reached over
41//! plain HTTP by something else first.
42//!
43//! A *relative* URI cannot do this. `/messages?sessionId=abc` resolves against
44//! the origin the client already dialled, and there is nothing in it to
45//! redirect. So the parse refuses the two shapes that escape the origin —
46//! [`Absolute`](MessageEndpointError::Absolute) and
47//! [`ProtocolRelative`](MessageEndpointError::ProtocolRelative) — and the
48//! server is left to encode whatever it likes in a form that stays inside the
49//! origin it is already serving.
50//!
51//! The other two refusals are ordinary framing hygiene, and the parser is
52//! where they belong: a C0 control or a space in a request target is a
53//! request-line injection, and a target that does not start with `/` is not a
54//! path this server would have served.
55
56use core::fmt;
57use core::str::FromStr;
58
59use thiserror::Error;
60
61/// The `endpoint` event's data, parsed once and used verbatim.
62///
63/// A newtype over the whole target rather than a decomposed
64/// `(path, query, session)`. Decomposing is the mistake: it makes the client's
65/// next move "rebuild the URL", and rebuilding is where a server's choice of
66/// encoding becomes this client's bug.
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct MessageEndpoint(String);
69
70impl MessageEndpoint {
71 /// The target, exactly as the server wrote it.
72 #[must_use]
73 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76
77 /// The target's path, without any query.
78 ///
79 /// For a server logging what it offered, and for a test asserting the
80 /// shape. A *client* has no use for this: it POSTs to
81 /// [`as_str`](Self::as_str).
82 #[must_use]
83 pub fn path(&self) -> &str {
84 match self.0.split_once('?') {
85 Some((path, _query)) => path,
86 None => &self.0,
87 }
88 }
89
90 /// The query string, without the `?`.
91 #[must_use]
92 pub fn query(&self) -> Option<&str> {
93 self.0.split_once('?').map(|(_path, query)| query)
94 }
95
96 /// A path with no query and no fragment, for a server that will append one.
97 ///
98 /// Distinct from [`FromStr`] because a *configured* path and an *advertised*
99 /// endpoint are different shapes, and only one of them may carry a query.
100 /// A path of `/messages?tenant=a` parses fine as a target — a client could
101 /// be told to post there — but a server that then appends `sessionId` to it
102 /// produces `/messages?tenant=a?sessionId=1`, whose second `?` is data
103 /// rather than a delimiter, and a client that splits on it gets a query of
104 /// `a?sessionId=1` and posts with no session at all. A `#` is worse: it
105 /// makes the whole query a fragment, which the client drops silently.
106 ///
107 /// Refusing both here is what lets [`with_query_parameter`](Self::with_query_parameter)
108 /// be infallible.
109 ///
110 /// # Errors
111 ///
112 /// [`MessageEndpointError::NotAbsolutePath`] for an empty value, a
113 /// non-`/`-initial one, one that is a query string, or one that names a
114 /// scheme; [`MessageEndpointError::IllegalCharacter`] for a C0 control,
115 /// DEL, or a space; and [`MessageEndpointError::QueryOrFragment`] for a
116 /// value that already carries a `?` or a `#`.
117 pub fn bare_path(value: &str) -> Result<Self, MessageEndpointError> {
118 // The general parse first, the `?`/`#` rule second, so that each value
119 // is refused for the most specific thing wrong with it. A value of
120 // `?sessionId=1` has no path at all, and `Empty` says that where
121 // `QueryOrFragment` would say something true and less useful.
122 let endpoint = value.parse::<Self>()?;
123 if value.contains(['?', '#']) {
124 return Err(MessageEndpointError::QueryOrFragment);
125 }
126 Ok(endpoint)
127 }
128
129 /// This target with one more query parameter appended.
130 ///
131 /// Infallible, and that is the whole point: the result is a string this
132 /// module assembled from a [`bare_path`](Self::bare_path) it already
133 /// validated and a key and value it is choosing, so there is nothing left
134 /// to parse and no way for the result to be one [`FromStr`] would refuse.
135 ///
136 /// An existing query is preserved rather than replaced — `?tenant=a` plus
137 /// `sessionId=1` is `?tenant=a&sessionId=1` — because the alternative is a
138 /// server that silently discards its own configuration the first time a
139 /// client is added.
140 ///
141 /// `key` and `value` are not validated here, and cannot be: the only
142 /// callers in this crate pass a `&'static str` constant and a hexadecimal
143 /// session id. A caller with an arbitrary string should parse it into a
144 /// [`MessageEndpoint`] instead of reaching for this.
145 #[must_use]
146 pub fn with_query_parameter(&self, key: &str, value: &str) -> Self {
147 let separator = if self.query().is_some() { '&' } else { '?' };
148 Self(format!("{}{separator}{key}={value}", self.0))
149 }
150}
151
152/// Why a string is not a usable message endpoint.
153#[derive(Debug, Clone, PartialEq, Eq, Error)]
154pub enum MessageEndpointError {
155 /// The value was empty, or was only a query string.
156 #[error("a message endpoint may not be empty")]
157 Empty,
158
159 /// The value named a scheme, so it could point anywhere.
160 ///
161 /// See the module docs: this is the refusal that makes the `endpoint`
162 /// event unusable as an SSRF primitive. Reported separately from
163 /// [`ProtocolRelative`](Self::ProtocolRelative) because the two have
164 /// different shapes in a log and a reader wants to know which one a peer
165 /// tried.
166 #[error(
167 "a message endpoint may not be an absolute URI; it must stay on the origin the client dialled"
168 )]
169 Absolute,
170
171 /// The value began `//`, which is a protocol-relative URL.
172 ///
173 /// Its own case and not an [`Absolute`](Self::Absolute) one because it has
174 /// no scheme to give it away: `//evil.example/steal` resolves to
175 /// `https://evil.example/steal` under the client's own scheme, so a parser
176 /// that only looked for `://` would walk straight past it.
177 #[error(
178 "a message endpoint may not be protocol-relative; it must stay on the origin the client dialled"
179 )]
180 ProtocolRelative,
181
182 /// The value contained a control character or a space.
183 ///
184 /// A request target with a CR in it is a request-line injection: a
185 /// conforming client cannot send it, and an lenient one can be made to
186 /// send a second request the user never asked for.
187 #[error("a message endpoint may not contain a control character or a space")]
188 IllegalCharacter,
189
190 /// The value already carried a `?` or a `#`, so a query appended to it
191 /// would be part of the existing query or of a fragment.
192 #[error("a configured message path must not carry a query or a fragment")]
193 QueryOrFragment,
194
195 /// The value did not begin with `/`.
196 #[error("a message endpoint must be an absolute path starting with `/`")]
197 NotAbsolutePath,
198}
199
200impl FromStr for MessageEndpoint {
201 type Err = MessageEndpointError;
202
203 /// Parses a target the client can safely resolve against the origin it
204 /// already dialled.
205 ///
206 /// # Errors
207 ///
208 /// - [`MessageEndpointError::Empty`] for an empty target or a bare `?`.
209 /// - [`MessageEndpointError::ProtocolRelative`] for a `//` prefix, which
210 /// carries no scheme and so escapes a check that only looks for one.
211 /// - [`MessageEndpointError::Absolute`] for anything with a scheme. The
212 /// check is on the scheme *syntax*, not on the literal `://`, so
213 /// `mailto:x` and `data:text/plain,x` are caught as well as
214 /// `https://host/`.
215 /// - [`MessageEndpointError::IllegalCharacter`] for a C0 control, DEL, or
216 /// a space.
217 /// - [`MessageEndpointError::NotAbsolutePath`] for a target that is
218 /// neither empty nor `/`-initial: a relative `messages?sessionId=x` is
219 /// ambiguous against whatever the client's current path happens to be,
220 /// and resolving it the way a browser would is not a behaviour a
221 /// protocol should depend on.
222 fn from_str(value: &str) -> Result<Self, Self::Err> {
223 if value.is_empty() || value == "?" || value.starts_with('?') {
224 return Err(MessageEndpointError::Empty);
225 }
226 // Before the scheme check, because `//host/x` has no scheme and is the
227 // shape a scheme-only check misses.
228 if value.starts_with("//") {
229 return Err(MessageEndpointError::ProtocolRelative);
230 }
231 if has_scheme(value) {
232 return Err(MessageEndpointError::Absolute);
233 }
234 if value
235 .chars()
236 .any(|c| c.is_control() || c == ' ' || c == '\u{7f}')
237 {
238 return Err(MessageEndpointError::IllegalCharacter);
239 }
240 if !value.starts_with('/') {
241 return Err(MessageEndpointError::NotAbsolutePath);
242 }
243 Ok(Self(value.to_string()))
244 }
245}
246
247/// Whether `value` begins with a URI scheme.
248///
249/// `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`, per RFC 3986
250/// §3.1 — hand-written rather than pulled in, because the alternative is
251/// taking a URI parser to answer a question that is one regex-shaped
252/// predicate, and because the interesting failure is the *false negative*: a
253/// check that looked for `://` would pass `data:text/plain,hello` and this
254/// parser has to refuse it.
255fn has_scheme(value: &str) -> bool {
256 let Some(colon) = value.find(':') else {
257 return false;
258 };
259 let scheme = value.split_at(colon).0;
260 let mut chars = scheme.chars();
261 // ALPHA first: a scheme cannot begin with a digit or a sign, and
262 // `/messages?sessionId=a:b` has a colon whose prefix is not a scheme.
263 match chars.next() {
264 Some(first) if first.is_ascii_alphabetic() => {}
265 _ => return false,
266 }
267 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
268}
269
270impl fmt::Display for MessageEndpoint {
271 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272 formatter.write_str(&self.0)
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 /// A configured path is not a target: it has to be able to *take* a query.
279 ///
280 /// A table rather than a loop over cases with an index, because the cases
281 /// are the documentation here — a reader deciding whether the `?` and `#`
282 /// refusals are over-strict should be able to read the shapes rather than
283 /// the loop that rejects them.
284 ///
285 /// Each case names the *variant*, not merely that something went wrong. A
286 /// test that checks only "it failed" passes when a validator refuses for
287 /// the wrong reason, which is how a good message slowly becomes a
288 /// misleading one.
289 #[test]
290 fn a_configured_path_may_not_already_carry_a_query_or_a_fragment() {
291 use MessageEndpointError as E;
292 let cases = [
293 ("/messages", None),
294 ("/messages?sessionId=1", Some(E::QueryOrFragment)),
295 ("/messages#a", Some(E::QueryOrFragment)),
296 ("/messages#a?b=c", Some(E::QueryOrFragment)),
297 ("?sessionId=1", Some(E::Empty)),
298 ("https://elsewhere.example/x", Some(E::Absolute)),
299 ("//elsewhere.example/x", Some(E::ProtocolRelative)),
300 ("messages", Some(E::NotAbsolutePath)),
301 ("/mess ages", Some(E::IllegalCharacter)),
302 ];
303 for (value, wanted) in cases {
304 assert_eq!(
305 MessageEndpoint::bare_path(value).err(),
306 wanted,
307 "bare_path({value:?})"
308 );
309 }
310 }
311
312 /// Appending must not cost the server its own configuration.
313 ///
314 /// A path of `/messages?tenant=a` is refused as a *configured* path — see
315 /// the test above — but a `MessageEndpoint` built some other way can carry
316 /// a query, and then this is the method that has to preserve it. Replacing
317 /// rather than extending would silently drop `tenant`, and the symptom
318 /// would be a client talking to the wrong tenant with no error anywhere.
319 #[test]
320 fn appending_extends_an_existing_query_rather_than_replacing_it() -> Result<(), TestError> {
321 let endpoint = MessageEndpoint::from_str("/messages?tenant=a")?;
322 let with_session = endpoint.with_query_parameter("sessionId", "0000000000000001");
323 assert_eq!(
324 with_session.as_str(),
325 "/messages?tenant=a&sessionId=0000000000000001"
326 );
327 assert_eq!(with_session.path(), "/messages");
328 assert_eq!(
329 with_session.query(),
330 Some("tenant=a&sessionId=0000000000000001")
331 );
332 Ok(())
333 }
334
335 /// And with no query, the first parameter is what introduces one.
336 #[test]
337 fn appending_to_a_bare_path_introduces_the_query() -> Result<(), TestError> {
338 let endpoint = MessageEndpoint::from_str("/messages")?;
339 let with_session = endpoint.with_query_parameter("sessionId", "0000000000000001");
340 assert_eq!(
341 with_session.as_str(),
342 "/messages?sessionId=0000000000000001"
343 );
344 assert_eq!(with_session.path(), "/messages");
345 assert_eq!(with_session.query(), Some("sessionId=0000000000000001"));
346 Ok(())
347 }
348
349 use super::{MessageEndpoint, MessageEndpointError};
350 use core::str::FromStr;
351
352 type TestError = Box<dyn std::error::Error>;
353
354 /// Asserts `value` parses, and reads back as `expected`.
355 fn accepts(value: &str, expected: &str) -> Result<(), TestError> {
356 let endpoint = MessageEndpoint::from_str(value)?;
357 if endpoint.as_str() != expected {
358 return Err(format!("{value:?} read back as {:?}", endpoint.as_str()).into());
359 }
360 Ok(())
361 }
362
363 /// Asserts `value` is refused, and with `expected`.
364 fn refuses(value: &str, expected: &MessageEndpointError) -> Result<(), TestError> {
365 match MessageEndpoint::from_str(value) {
366 Err(error) if &error == expected => Ok(()),
367 Err(error) => {
368 Err(format!("{value:?} refused as {error:?}, wanted {expected:?}").into())
369 }
370 Ok(endpoint) => Err(format!(
371 "{value:?} was accepted as {:?}, which can leave the origin",
372 endpoint.as_str()
373 )
374 .into()),
375 }
376 }
377
378 /// The shape the reference server sends.
379 #[test]
380 fn the_reference_shape_parses() -> Result<(), TestError> {
381 accepts(
382 "/messages?sessionId=0a1b2c3d",
383 "/messages?sessionId=0a1b2c3d",
384 )
385 }
386
387 #[test]
388 fn a_plain_path_parses() -> Result<(), TestError> {
389 accepts("/messages", "/messages")
390 }
391
392 /// The reason the client uses the target verbatim.
393 #[test]
394 fn a_path_and_query_are_kept_together() -> Result<(), TestError> {
395 let endpoint = MessageEndpoint::from_str("/messages?sessionId=x&y=z")?;
396 if endpoint.path() != "/messages" {
397 return Err(format!("path read as {:?}", endpoint.path()).into());
398 }
399 if endpoint.query() != Some("sessionId=x&y=z") {
400 return Err(format!("query read as {:?}", endpoint.query()).into());
401 }
402 // And the whole thing is what a client would POST to, unchanged.
403 if endpoint.as_str() != "/messages?sessionId=x&y=z" {
404 return Err("the target was rebuilt rather than kept".into());
405 }
406 Ok(())
407 }
408
409 /// The `SSRF` case. A client that sent its `JSON-RPC` here would be sending
410 /// the session to a host the user never configured.
411 #[test]
412 fn an_absolute_url_is_refused() -> Result<(), TestError> {
413 refuses(
414 "http://169.254.169.254/latest/meta-data/",
415 &MessageEndpointError::Absolute,
416 )
417 }
418
419 /// `data:` has no `//` and is still an absolute URI, so a check that only
420 /// looked for `://` would let it through.
421 #[test]
422 fn a_schemed_url_without_slashes_is_refused() -> Result<(), TestError> {
423 refuses("data:text/plain,hello", &MessageEndpointError::Absolute)
424 }
425
426 /// The shape that carries no scheme at all, and so escapes a scheme check.
427 #[test]
428 fn a_protocol_relative_url_is_refused() -> Result<(), TestError> {
429 refuses(
430 "//evil.example/steal",
431 &MessageEndpointError::ProtocolRelative,
432 )
433 }
434
435 /// A CR in a request target is a request-line injection.
436 #[test]
437 fn a_control_character_is_refused() -> Result<(), TestError> {
438 refuses(
439 "/messages\r\nX-Injected: 1",
440 &MessageEndpointError::IllegalCharacter,
441 )
442 }
443
444 #[test]
445 fn a_space_is_refused() -> Result<(), TestError> {
446 refuses(
447 "/messages and more",
448 &MessageEndpointError::IllegalCharacter,
449 )
450 }
451
452 /// A colon whose prefix is not a scheme is not a scheme. A server may
453 /// perfectly well use a session id that looks like one, and refusing it
454 /// would be refusing a legal frame.
455 #[test]
456 fn a_colon_that_is_not_a_scheme_is_kept() -> Result<(), TestError> {
457 accepts(
458 "/messages?sessionId=2024-11-05T00:00:00Z",
459 "/messages?sessionId=2024-11-05T00:00:00Z",
460 )
461 }
462
463 /// RFC 3986 §3.1: a scheme starts with `ALPHA`. `9:foo` has the *shape* of
464 /// a scheme and is not one, so it is a relative path — and has to be
465 /// reported as one.
466 ///
467 /// This test exists because the ALPHA check was **unpinned**: deleting it
468 /// left all sixteen tests green, since a digit-led colon is refused either
469 /// way and only the reason changes. The reason is what a client reading a
470 /// refusal log actually looks at, and a parser that blames a peer for
471 /// pointing off-origin when the peer sent a relative path produces a log
472 /// nobody trusts.
473 #[test]
474 fn a_scheme_may_not_start_with_a_digit() -> Result<(), TestError> {
475 refuses("9:foo", &MessageEndpointError::NotAbsolutePath)
476 }
477
478 /// The same for the characters RFC 3986 excludes from a leading position.
479 #[test]
480 fn a_scheme_may_not_start_with_a_sign_or_a_dot() -> Result<(), TestError> {
481 for target in ["-x:foo", "+x:foo", ".x:foo"] {
482 refuses(target, &MessageEndpointError::NotAbsolutePath)?;
483 }
484 Ok(())
485 }
486
487 /// Relative targets are ambiguous against whatever path the client has
488 /// dialled, and a protocol should not depend on browser resolution rules.
489 #[test]
490 fn a_relative_path_is_refused() -> Result<(), TestError> {
491 refuses(
492 "messages?sessionId=x",
493 &MessageEndpointError::NotAbsolutePath,
494 )
495 }
496
497 #[test]
498 fn an_empty_target_is_refused() -> Result<(), TestError> {
499 refuses("", &MessageEndpointError::Empty)
500 }
501
502 /// A bare query names no path at all.
503 #[test]
504 fn a_bare_query_is_refused() -> Result<(), TestError> {
505 refuses("?sessionId=x", &MessageEndpointError::Empty)
506 }
507}