Skip to main content

tauri_plugin_topcoat/
protocol.rs

1//! The protocol handler: one webview request, one router response.
2//!
3//! Every rule applied here was decided in `custom-protocol-http`, except
4//! [`harden`], which is this plugin's own and is a response header rather than
5//! a decision about a request. What is left is the ordering and the awaits.
6
7use std::{
8    borrow::Cow,
9    convert::Infallible,
10    future::Future,
11    pin::Pin,
12    sync::Arc,
13    task::{Context, Poll},
14};
15
16use bytes::Bytes;
17use custom_protocol_http::{
18    Origins,
19    tower::{CanonicalOriginLayer, RefuseUnsupportedLayer, follow_redirects},
20};
21use http::{HeaderMap, HeaderValue, Request, Response, StatusCode, header};
22use http_body_util::{BodyExt, Full};
23use topcoat::router::{Body, Router, to_bytes};
24use tower::{Service, ServiceBuilder, ServiceExt};
25
26/// The request body the stack carries.
27///
28/// `Full<Bytes>` rather than topcoat's own body because a redirect that
29/// preserves its body has to send it twice, and only a cloneable body can be.
30type ReqBody = Full<Bytes>;
31
32/// Everything one protocol scheme needs to serve a router.
33pub(crate) struct Bridge {
34    origins: Origins,
35    router: Arc<Router>,
36    /// Which webviews are showing one of our documents, and the token each one
37    /// holds. Shared with the token store on the router, which is the other
38    /// half of the same conversation.
39    #[cfg(feature = "session")]
40    webviews: Arc<crate::session::Webviews>,
41}
42
43impl Bridge {
44    pub(crate) fn new(
45        origins: Origins,
46        router: Router,
47        #[cfg(feature = "session")] webviews: Arc<crate::session::Webviews>,
48    ) -> Bridge {
49        Bridge {
50            origins,
51            router: Arc::new(router),
52            #[cfg(feature = "session")]
53            webviews,
54        }
55    }
56
57    pub(crate) const fn origins(&self) -> &Origins {
58        &self.origins
59    }
60
61    /// Records where a webview is navigating, whether or not it is allowed to.
62    pub(crate) fn observe_navigation(&self, label: &str, ours: bool) {
63        #[cfg(feature = "session")]
64        self.webviews.observe(label, ours);
65        #[cfg(not(feature = "session"))]
66        let _ = (label, ours);
67    }
68
69    /// Serves one request from the webview.
70    ///
71    /// The stack is assembled per request rather than held, because the webview
72    /// it is serving is part of it. Assembly is a few `Arc` clones.
73    ///
74    /// The span carries the path and not the URI: a query string is the
75    /// application's, and a desktop application's is whatever the user typed.
76    #[cfg_attr(
77        feature = "tracing",
78        tracing::instrument(
79            name = "serve",
80            skip_all,
81            fields(
82                webview = label,
83                method = %request.method(),
84                path = request.uri().path(),
85                status = tracing::field::Empty,
86            ),
87        )
88    )]
89    pub(crate) async fn serve(
90        &self,
91        label: &str,
92        request: Request<Vec<u8>>,
93    ) -> Response<Cow<'static, [u8]>> {
94        let (parts, body) = request.into_parts();
95        let request = Request::from_parts(parts, Full::new(Bytes::from(body)));
96
97        // The unsupported check goes under the follower, so every hop is
98        // checked rather than only the one that survives.
99        let service = ServiceBuilder::new()
100            .layer(CanonicalOriginLayer::new(self.origins.clone()))
101            .layer(follow_redirects::<ReqBody, Infallible>())
102            .layer(RefuseUnsupportedLayer::new())
103            .service(TopcoatService {
104                router: Arc::clone(&self.router),
105                #[cfg(feature = "session")]
106                label: label.to_owned(),
107            });
108
109        #[cfg(not(feature = "session"))]
110        let _ = label;
111
112        match service.oneshot(request).await {
113            Ok(response) => {
114                let response = deliver(response).await;
115                crate::trace::record_status(response.status());
116                response
117            }
118            Err(infallible) => match infallible {},
119        }
120    }
121}
122
123/// topcoat as a tower service.
124///
125/// Not to be confused with [`topcoat::router::RouterService`], which is a
126/// `hyper` service over `Incoming` bodies for the `serve` feature this plugin
127/// exists to avoid.
128///
129/// The adapter is the whole of what ties this plugin to topcoat: everything
130/// above it in the stack would work the same over an axum router or a
131/// `ServeDir`.
132///
133/// It is also where the requesting webview is named, rather than on the request
134/// the shell handed in. Naming it here means every hop is named, including the
135/// ones a Post/Redirect/Get produces, without depending on what the redirect
136/// follower above does with `http::Extensions` - which has already changed
137/// once, when `tower-http` 0.7 began forwarding them where 0.6 dropped them.
138/// [`follow_redirects`] pins that to dropping, so the two halves cannot drift
139/// into naming a hop twice or not at all.
140#[derive(Clone)]
141struct TopcoatService {
142    router: Arc<Router>,
143    /// Only the name travels: reading the token is the store's job, on the
144    /// other side of the router, and it looks this up for itself.
145    #[cfg(feature = "session")]
146    label: String,
147}
148
149impl Service<Request<ReqBody>> for TopcoatService {
150    type Response = Response<Body>;
151    type Error = Infallible;
152    type Future = Pin<Box<dyn Future<Output = Result<Response<Body>, Infallible>> + Send>>;
153
154    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
155        Poll::Ready(Ok(()))
156    }
157
158    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
159        let router = Arc::clone(&self.router);
160        #[cfg(feature = "session")]
161        let label = self.label.clone();
162
163        Box::pin(async move {
164            let (parts, body) = request.into_parts();
165            let bytes = body
166                .collect()
167                .await
168                .map(http_body_util::Collected::to_bytes)
169                .unwrap_or_default();
170            let request = Request::from_parts(parts, Body::from(bytes.to_vec()));
171            #[cfg(feature = "session")]
172            let request = crate::session::attach(request, &label);
173            Ok(router.handle(request).await)
174        })
175    }
176}
177
178/// Buffers the response for the webview, which has no way to stream one.
179async fn deliver(response: Response<Body>) -> Response<Cow<'static, [u8]>> {
180    let (mut parts, body) = response.into_parts();
181    let Ok(bytes) = to_bytes(body, usize::MAX).await else {
182        return status_only(
183            StatusCode::INTERNAL_SERVER_ERROR,
184            "the response body could not be read",
185        );
186    };
187    harden(&mut parts.headers);
188    Response::from_parts(parts, Cow::Owned(bytes.to_vec()))
189}
190
191/// Headers this plugin guarantees on every response it emits.
192///
193/// `Referrer-Policy: same-origin` keeps our URLs on requests to our own origin
194/// and withholds them from everyone else. A desktop application's paths are
195/// nobody else's business.
196///
197/// `X-Content-Type-Options: nosniff` matters because a desktop application
198/// serves bytes it did not write. A webview that sniffs such a response into
199/// HTML runs it as a document on the origin holding the session.
200///
201/// Both are defaults rather than overrides, so an application that states its
202/// own policy keeps it.
203fn harden(headers: &mut HeaderMap) {
204    headers
205        .entry(header::REFERRER_POLICY)
206        .or_insert(HeaderValue::from_static("same-origin"));
207    headers
208        .entry(header::X_CONTENT_TYPE_OPTIONS)
209        .or_insert(HeaderValue::from_static("nosniff"));
210}
211
212/// A request arrived before the plugin finished starting, which should not be
213/// reachable: a webview has to exist to make one, and `setup` runs first.
214pub(crate) fn unavailable() -> Response<Cow<'static, [u8]>> {
215    crate::trace::served_before_ready();
216    status_only(
217        StatusCode::SERVICE_UNAVAILABLE,
218        "the topcoat plugin has not finished starting",
219    )
220}
221
222fn status_only(status: StatusCode, reason: &str) -> Response<Cow<'static, [u8]>> {
223    let body = format!("{status}\n\n{reason}\n");
224    let mut response = Response::new(Cow::Owned(body.into_bytes()));
225    *response.status_mut() = status;
226    response.headers_mut().insert(
227        header::CONTENT_TYPE,
228        HeaderValue::from_static("text/plain; charset=utf-8"),
229    );
230    harden(response.headers_mut());
231    response
232}
233
234/// End-to-end tests: a real topcoat [`Router`] driven through [`Bridge::serve`].
235///
236/// The handlers are plain `fn` pointers because `Route` cannot capture state,
237/// which is also why the one piece of shared observation is a static.
238#[cfg(test)]
239mod tests {
240    use custom_protocol_http::Platform;
241    use http::{HeaderName, Method};
242    use topcoat::{
243        context::Cx,
244        router::{
245            IntoResponse, Path, Response as RouterResponse, RouteFn, RouteFuture,
246            headers as request_headers,
247        },
248    };
249
250    use super::*;
251
252    fn hello(cx: &Cx, _body: Body) -> RouteFuture<'_> {
253        Box::pin(async move { "hello".into_response(cx) })
254    }
255
256    /// A mutation that answers with Post/Redirect/Get, the shape no webview
257    /// follows on its own. It redirects to the route that echoes the cookie, so
258    /// a test can see what the followed hop carried.
259    fn add_todo(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
260        Box::pin(async move {
261            let mut response = RouterResponse::new(Body::empty());
262            *response.status_mut() = StatusCode::SEE_OTHER;
263            response
264                .headers_mut()
265                .insert(header::LOCATION, HeaderValue::from_static("/whoami"));
266            Ok(response)
267        })
268    }
269
270    /// Answers with a status nothing else produces, so "this route ran" is
271    /// distinguishable from "this request was refused" without shared state
272    /// that parallel tests would race on.
273    fn teapot(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
274        Box::pin(async move {
275            let mut response = RouterResponse::new(Body::empty());
276            *response.status_mut() = StatusCode::IM_A_TEAPOT;
277            Ok(response)
278        })
279    }
280
281    /// Answers with the session cookie topcoat's default token store emits,
282    /// which no WebKit cookie store keeps.
283    fn login(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
284        Box::pin(async move {
285            let mut response = RouterResponse::new(Body::from("logged in"));
286            response.headers_mut().insert(
287                header::SET_COOKIE,
288                HeaderValue::from_static("__Host-session=t0ken; Path=/; Secure; HttpOnly"),
289            );
290            Ok(response)
291        })
292    }
293
294    /// Post/Redirect/Get onto a hop that sets a cookie, so the response the
295    /// webview would have seen is fine and the one it would not is not.
296    fn add_todo_then_login(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
297        Box::pin(async move {
298            let mut response = RouterResponse::new(Body::empty());
299            *response.status_mut() = StatusCode::SEE_OTHER;
300            response
301                .headers_mut()
302                .insert(header::LOCATION, HeaderValue::from_static("/login"));
303            Ok(response)
304        })
305    }
306
307    /// The shape of a topcoat login on the default cookie store: mint the
308    /// session, set the cookie, and answer the `POST` with a redirect. The
309    /// cookie is on the hop the webview never sees.
310    fn sign_in_then_home(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
311        Box::pin(async move {
312            let mut response = RouterResponse::new(Body::empty());
313            *response.status_mut() = StatusCode::SEE_OTHER;
314            let headers = response.headers_mut();
315            headers.insert(header::LOCATION, HeaderValue::from_static("/"));
316            headers.insert(
317                header::SET_COOKIE,
318                HeaderValue::from_static("__Host-session=t0ken; Path=/; Secure; HttpOnly"),
319            );
320            Ok(response)
321        })
322    }
323
324    /// The stream topcoat's `sse` feature would produce.
325    fn events(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
326        Box::pin(async move {
327            let mut response = RouterResponse::new(Body::from("data: hello\n\n"));
328            response.headers_mut().insert(
329                header::CONTENT_TYPE,
330                HeaderValue::from_static("text/event-stream"),
331            );
332            Ok(response)
333        })
334    }
335
336    /// Echoes one request header back, so a test can see what the router saw.
337    fn echo_cookie(cx: &Cx, _body: Body) -> RouteFuture<'_> {
338        Box::pin(async move {
339            let value = request_headers(cx)
340                .get(header::COOKIE)
341                .and_then(|value| value.to_str().ok())
342                .unwrap_or("<none>")
343                .to_owned();
344            value.into_response(cx)
345        })
346    }
347
348    fn echo_host(cx: &Cx, _body: Body) -> RouteFuture<'_> {
349        Box::pin(async move {
350            let headers = request_headers(cx);
351            let host = headers
352                .get(header::HOST)
353                .and_then(|value| value.to_str().ok())
354                .unwrap_or("<none>");
355            let encoding = headers
356                .get(header::ACCEPT_ENCODING)
357                .and_then(|value| value.to_str().ok())
358                .unwrap_or("<none>");
359            format!("{host} {encoding}").into_response(cx)
360        })
361    }
362
363    /// Sends the caller somewhere this plugin must not follow.
364    fn offsite(_cx: &Cx, _body: Body) -> RouteFuture<'_> {
365        Box::pin(async move {
366            let mut response = RouterResponse::new(Body::empty());
367            *response.status_mut() = StatusCode::SEE_OTHER;
368            response.headers_mut().insert(
369                header::LOCATION,
370                HeaderValue::from_static("https://evil.example/"),
371            );
372            Ok(response)
373        })
374    }
375
376    fn bridge() -> Bridge {
377        let router = Router::builder()
378            .route(RouteFn::new(Method::GET, path("/"), hello))
379            .route(RouteFn::new(Method::POST, path("/todos"), add_todo))
380            .route(RouteFn::new(Method::GET, path("/login"), login))
381            .route(RouteFn::new(
382                Method::POST,
383                path("/todos-then-login"),
384                add_todo_then_login,
385            ))
386            .route(RouteFn::new(
387                Method::POST,
388                path("/sign-in-then-home"),
389                sign_in_then_home,
390            ))
391            .route(RouteFn::new(Method::GET, path("/events"), events))
392            .route(RouteFn::new(Method::GET, path("/whoami"), echo_cookie))
393            .route(RouteFn::new(Method::GET, path("/headers"), echo_host))
394            .route(RouteFn::new(Method::POST, path("/offsite"), offsite))
395            .route(RouteFn::new(Method::POST, path("/must-not-run"), teapot))
396            .build();
397        let origins =
398            Origins::new("topcoat", Platform::Scheme).expect("`topcoat` is a valid scheme");
399        Bridge::new(
400            origins,
401            router,
402            #[cfg(feature = "session")]
403            Arc::new(crate::session::Webviews::new()),
404        )
405    }
406
407    fn path(literal: &'static str) -> Cow<'static, Path> {
408        Cow::Borrowed(Path::new(literal))
409    }
410
411    fn request(method: Method, path: &str, headers: &[(HeaderName, &str)]) -> Request<Vec<u8>> {
412        let mut builder = Request::builder()
413            .method(method)
414            .uri(format!("topcoat://localhost{path}"));
415        for (name, value) in headers {
416            builder = builder.header(name, *value);
417        }
418        builder.body(Vec::new()).expect("a valid request")
419    }
420
421    /// The shape WKWebView gives a `fetch` POST: a `Referer` and nothing else.
422    const WEBKIT_POST: &[(HeaderName, &str)] = &[(header::REFERER, "topcoat://localhost/")];
423
424    /// A label no test records a navigation for. The router here has no session
425    /// configured, so nothing looks one up and every test holds either way;
426    /// what a label decides once a session exists is asserted in the crate root.
427    const UNSEEN: &str = "never-navigated";
428
429    async fn send(bridge: &Bridge, request: Request<Vec<u8>>) -> (StatusCode, String, HeaderMap) {
430        let response = bridge.serve(UNSEEN, request).await;
431        let (parts, body) = response.into_parts();
432        (
433            parts.status,
434            String::from_utf8_lossy(&body).into_owned(),
435            parts.headers,
436        )
437    }
438
439    #[tokio::test]
440    async fn a_page_is_served() {
441        let (status, body, headers) = send(&bridge(), request(Method::GET, "/", &[])).await;
442        assert_eq!(status, StatusCode::OK);
443        assert_eq!(body, "hello");
444        assert_eq!(
445            headers
446                .get(header::REFERRER_POLICY)
447                .and_then(|v| v.to_str().ok()),
448            Some("same-origin"),
449            "our URLs are not withheld from other origins"
450        );
451    }
452
453    #[tokio::test]
454    async fn the_router_sees_one_origin_and_no_accept_encoding() {
455        let (_, body, _) = send(
456            &bridge(),
457            request(
458                Method::GET,
459                "/headers",
460                &[(header::ACCEPT_ENCODING, "gzip, br")],
461            ),
462        )
463        .await;
464        assert_eq!(body, "topcoat.localhost <none>");
465    }
466
467    #[tokio::test]
468    async fn a_mutation_with_nothing_attributing_it_reaches_the_route() {
469        let (status, body, _) = send(&bridge(), request(Method::POST, "/must-not-run", &[])).await;
470        assert_eq!(status, StatusCode::IM_A_TEAPOT, "{body}");
471    }
472
473    #[tokio::test]
474    async fn a_request_for_a_foreign_authority_is_refused() {
475        let outbound = Request::builder()
476            .method(Method::GET)
477            .uri("https://evil.example/")
478            .body(Vec::new())
479            .expect("a valid request");
480        let (status, body, _) = send(&bridge(), outbound).await;
481
482        assert_eq!(status, StatusCode::FORBIDDEN);
483        assert!(body.contains("evil.example"), "{body}");
484    }
485
486    #[tokio::test]
487    async fn no_cookie_header_is_attached_to_anything() {
488        let (_, body, _) = send(&bridge(), request(Method::GET, "/whoami", &[])).await;
489        assert_eq!(body, "<none>");
490    }
491
492    #[tokio::test]
493    async fn post_redirect_get_is_followed_in_process() {
494        let (status, body, _) = send(&bridge(), request(Method::POST, "/todos", WEBKIT_POST)).await;
495
496        assert_eq!(status, StatusCode::OK, "the webview was handed a redirect");
497        assert_eq!(body, "<none>", "the redirect target was not fetched");
498    }
499
500    #[tokio::test]
501    async fn a_redirect_off_our_origin_is_handed_over_unfollowed() {
502        let (status, _, headers) =
503            send(&bridge(), request(Method::POST, "/offsite", WEBKIT_POST)).await;
504        assert_eq!(status, StatusCode::SEE_OTHER);
505        assert_eq!(
506            headers.get(header::LOCATION).and_then(|v| v.to_str().ok()),
507            Some("https://evil.example/")
508        );
509    }
510
511    #[tokio::test]
512    async fn a_set_cookie_is_refused_with_the_cookie_named() {
513        let (status, body, headers) = send(&bridge(), request(Method::GET, "/login", &[])).await;
514
515        assert_eq!(status, StatusCode::BAD_GATEWAY);
516        assert!(body.contains("__Host-session"), "{body}");
517        assert!(
518            headers.get(header::SET_COOKIE).is_none(),
519            "the cookie was passed on to a webview that will not keep it"
520        );
521    }
522
523    #[tokio::test]
524    async fn a_streaming_response_is_refused_with_the_capability_named() {
525        let (status, body, _) = send(&bridge(), request(Method::GET, "/events", &[])).await;
526
527        assert_eq!(status, StatusCode::BAD_GATEWAY);
528        assert!(body.contains("streams"), "{body}");
529    }
530
531    #[tokio::test]
532    async fn a_cookie_on_a_followed_hop_is_refused_not_swallowed() {
533        let (status, body, _) =
534            send(&bridge(), request(Method::POST, "/sign-in-then-home", &[])).await;
535
536        assert_eq!(
537            status,
538            StatusCode::BAD_GATEWAY,
539            "the redirect was followed past a Set-Cookie: {body}"
540        );
541        assert!(body.contains("__Host-session"), "{body}");
542    }
543
544    #[tokio::test]
545    async fn an_unsupported_redirect_hop_is_refused_rather_than_followed() {
546        let (status, body, _) =
547            send(&bridge(), request(Method::POST, "/todos-then-login", &[])).await;
548
549        assert_eq!(status, StatusCode::BAD_GATEWAY);
550        assert!(body.contains("__Host-session"), "{body}");
551    }
552
553    #[tokio::test]
554    async fn an_unknown_path_is_the_routers_own_404() {
555        let (status, _, _) = send(&bridge(), request(Method::GET, "/nope", &[])).await;
556        assert_eq!(status, StatusCode::NOT_FOUND);
557    }
558}