chiark / gitweb /
Develop an explicit HTTP redirection policy.
authorSimon Tatham <anakin@pobox.com>
Tue, 2 Jan 2024 11:27:00 +0000 (11:27 +0000)
committerSimon Tatham <anakin@pobox.com>
Tue, 2 Jan 2024 17:30:18 +0000 (17:30 +0000)
The Mastodon dev system runs its streaming API on a different port of
its VM. So you need to be able to follow an HTTP redirect from
http://mastodon.local to http://mastodon.local:4000 and still pass the
same auth token to that server.

But in general, one should not keep authentication configuration when
traversing a cross-site redirection! reqwest's automated redirect
following won't do it at all, and rightly so.

So I've written a custom redirection policy which allows _same_-host
redirection (just in case something turns out to need it), and doesn't
follow cross-site redirection (which would in general be useless
anyway because of this auth issue). And when setting up the streaming
API in particular, we catch the unfollowed redirection, vet it very
carefully (against the official streaming API location which the
instance happily advertises in its configuration record), and if we're
satisfied, make a fresh request to the target URL which _does_ pass
the same authentication.

As a result, now we can do streaming to the Mastodon dev instance,
which the Python version of this client _never_ managed!

src/client.rs
src/types.rs

index 7c0df5b7b84081a5d2e52b4695b1434004024f2c..0dd1e1687706fcccd3be3158b8caf434f8046817 100644 (file)
@@ -162,7 +162,7 @@ impl Client {
     pub fn new(cfgloc: &ConfigLocation) -> Result<Self, ClientError> {
         Ok(Client {
             auth: AuthConfig::load(cfgloc)?,
-            client: reqwest::blocking::Client::new(),
+            client: Self::build_client()?,
             accounts: HashMap::new(),
             statuses: HashMap::new(),
             notifications: HashMap::new(),
@@ -172,6 +172,52 @@ impl Client {
         })
     }
 
+    fn build_client() -> Result<reqwest::blocking::Client, ClientError>
+    {
+        // We turn off cross-site HTTP redirections in our client
+        // objects. That's because in general it doesn't do any good
+        // for a Mastodon API endpoint to redirect to another host:
+        // most requests have to be authenticated, and if you're
+        // redirected to another host, that host (by normal reqwest
+        // policy for cross-site redirections) won't receive your auth
+        // header. So we might as well reject the redirection in the
+        // first place.
+        //
+        // An exception is when setting up streaming connections,
+        // because those _can_ redirect to another host - but in that
+        // case, the target host can be known in advance (via the
+        // streaming URL in the instance configuration), and also, we
+        // still can't let _reqwest_ quietly follow the redirection,
+        // because it will strip off the auth. So we follow it
+        // ourselves manually, vet the target URL, and put the auth
+        // back on.
+        let no_xsite = reqwest::redirect::Policy::custom(|attempt| {
+            if attempt.previous().len() > 10 {
+                attempt.error("too many redirects")
+            } else if let Some(prev_url) = attempt.previous().last() {
+                let next_url = attempt.url();
+                if (prev_url.host(), prev_url.port()) !=
+                    (next_url.host(), next_url.port())
+                {
+                    // Stop and pass the 3xx response back to the
+                    // caller, rather than throwing a fatal error.
+                    // That way, the streaming setup can implement its
+                    // special case.
+                    attempt.stop()
+                } else {
+                    attempt.follow()
+                }
+            } else {
+                panic!("confusing redirect with no previous URLs!");
+            }
+        });
+
+        let client = reqwest::blocking::Client::builder()
+            .redirect(no_xsite)
+            .build()?;
+        Ok(client)
+    }
+
     pub fn set_writable(&mut self, permit: bool) {
         self.permit_write = permit;
     }
@@ -555,15 +601,78 @@ impl Client {
     }
 
     pub fn start_streaming_thread<Recv: Fn(StreamUpdate) + Send + 'static>(
-        &self, id: &StreamId, receiver: Box<Recv>) -> Result<(), ClientError>
+        &mut self, id: &StreamId, receiver: Box<Recv>) ->
+        Result<(), ClientError>
     {
         let req = match id {
             StreamId::User => Req::get("v1/streaming/user"),
         };
-        
-        let client = reqwest::blocking::Client::new();
-        let (url, req) = self.api_request_cl(&client, req)?;
+        let method = req.method.clone(); // to reuse for redirects below
+
+        let client = Self::build_client()?;
+        let (url, mut req) = self.api_request_cl(&client, req)?;
         let mut rsp = req.send()?;
+        if rsp.status().is_redirection() {
+            // We follow one redirection here, and we permit it to be
+            // to precisely the (host, port) pair that was specified
+            // in the instance's configuration as the streaming API
+            // endpoint. And when we follow that redirection, we do it
+            // manually, and send the same bearer auth token that we'd
+            // send for the normal API.
+            //
+            // An example server that needs this is the Mastodon
+            // development test instance (the one you can set up in a
+            // VM by running 'vagrant up' in the source checkout).
+            // That runs on http://mastodon.local (via Vagrant
+            // inserting that hostname in your /etc/hosts
+            // temporarily), but its streaming APIs run on a separate
+            // HTTP server, so you get redirected to
+            // http://mastodon.local:4000.
+
+            match rsp.headers().get(reqwest::header::LOCATION) {
+                None => {
+                    return Err(ClientError::UrlError(
+                        url.clone(),
+                        "received redirection without a Location header"
+                            .to_owned()));
+                }
+                Some(hval) => {
+                    let bval = hval.as_bytes();
+                    let sval = match std::str::from_utf8(bval) {
+                        Ok(s) => s,
+                        Err(_) => return Err(ClientError::UrlError(
+                            url, "HTTP redirect URL was invalid UTF-8"
+                                .to_owned())),
+                    };
+                    let newurl = match rsp.url().join(sval) {
+                        Ok(u) => u, 
+                        Err(e) => return Err(ClientError::UrlError(
+                            url, format!("processing redirection: {}", e))),
+                    };
+
+                    let instance = self.instance()?;
+                    let ok = match &instance.configuration.urls.streaming {
+                        None => false,
+                        Some(s) => if let Ok(goodurl) = Url::parse(s) {
+                            (goodurl.host(), goodurl.port()) ==
+                                (newurl.host(), newurl.port())
+                        } else {
+                            false
+                        }
+                    };
+                    if !ok {
+                        return Err(ClientError::UrlError(
+                            url.clone(),
+                            format!("redirection to suspicious URL {}",
+                                    sval)));
+                    }
+                    req = client.request(method, newurl)
+                        .bearer_auth(&self.auth.user_token);
+                    rsp = req.send()?;
+                }
+            }
+        };
+
         let rspstatus = rsp.status();
         if !rspstatus.is_success() {
             return Err(ClientError::UrlError(
index ff84ad55dcbce075f93f3cd55d4e6034b1a242a1..612dc3d7996571bc41af91bd632a4fca7caefc94 100644 (file)
@@ -238,9 +238,15 @@ pub struct InstanceStatusConfig {
     pub characters_reserved_per_url: usize,
 }
 
+#[derive(Deserialize, Debug, Clone)]
+pub struct InstanceUrlConfig {
+    pub streaming: Option<String>,
+}
+
 #[derive(Deserialize, Debug, Clone)]
 pub struct InstanceConfig {
     pub statuses: InstanceStatusConfig,
+    pub urls: InstanceUrlConfig,
 }
 
 #[derive(Deserialize, Debug, Clone)]