Skip to main content

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}