Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Websocket authentication for preview #1093

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
14 changes: 5 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ strum = { version = "0.26.2", features = ["derive"] }
quote = "1"
syn = "2"
triomphe = { version = "0.1.10", default-features = false, features = ["std"] }
rand = "0.8.5"
sha2 = "0.10.8"

# Asynchoronous and Multi-threading
async-trait = "0.1.77"
Expand Down Expand Up @@ -146,7 +148,9 @@ insta = { version = "1.39", features = ["glob"] }

# Our Own Crates
typst-preview = { path = "./crates/typst-preview" }
tinymist-assets = { version = "0.12.16-rc1" }
# TODO: temporarily for dev
# tinymist-assets = { version = "0.12.16-rc1" }
tinymist-assets = { path = "./crates/tinymist-assets" }
tinymist = { path = "./crates/tinymist/" }
tinymist-derive = { path = "./crates/tinymist-derive/" }
tinymist-analysis = { path = "./crates/tinymist-analysis/" }
Expand Down
2 changes: 2 additions & 0 deletions crates/tinymist/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ open = { workspace = true, optional = true }
dirs.workspace = true
base64.workspace = true
rayon.workspace = true
rand.workspace = true
sha2.workspace = true

[features]
default = ["cli", "embed-fonts", "no-content-hint", "preview"]
Expand Down
179 changes: 130 additions & 49 deletions crates/tinymist/src/tool/preview.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
//! Document preview tool for Typst

mod auth;

use std::num::NonZeroUsize;
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};

use futures::{SinkExt, StreamExt, TryStreamExt};
use hyper::service::service_fn;
use hyper_tungstenite::{tungstenite::Message, HyperWebsocket, HyperWebsocketStream};
use hyper_tungstenite::{tungstenite::Message, HyperWebsocketStream};
use hyper_util::rt::TokioIo;
use hyper_util::server::graceful::GracefulShutdown;
use lsp_types::notification::Notification;
Expand All @@ -22,9 +24,9 @@ use typst::syntax::{LinkedNode, Source, Span, SyntaxKind, VirtualPath};
use typst::World;
pub use typst_preview::CompileStatus;
use typst_preview::{
frontend_html, CompileHost, ControlPlaneMessage, ControlPlaneResponse, ControlPlaneRx,
ControlPlaneTx, DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort,
PreviewArgs, PreviewBuilder, PreviewMode, Previewer, SourceFileServer, WsMessage,
CompileHost, ControlPlaneMessage, ControlPlaneResponse, ControlPlaneRx, ControlPlaneTx,
DocToSrcJumpInfo, EditorServer, Location, MemoryFiles, MemoryFilesShort, PreviewArgs,
PreviewBuilder, PreviewMode, Previewer, SourceFileServer, WsMessage,
};
use typst_shim::syntax::LinkedNodeExt;

Expand Down Expand Up @@ -228,6 +230,12 @@ pub struct PreviewCliArgs {
/// Don't open the preview in the browser after compilation.
#[clap(long = "no-open")]
pub dont_open_in_browser: bool,

/// Use this to disable websocket authentication for the control plane server. Careful: Among other things, this allows any website you visit to use the control plane server.
///
/// This option is only meant to ease the transition to authentication for downstream packages. It will be removed in a future version of tinymist.
#[clap(long, default_value = "false")]
pub disable_control_plane_auth: bool,
}

/// The global state of the preview tool.
Expand Down Expand Up @@ -263,6 +271,7 @@ pub struct StartPreviewResponse {
static_server_port: Option<u16>,
static_server_addr: Option<String>,
data_plane_port: Option<u16>,
secret: String,
is_primary: bool,
}

Expand Down Expand Up @@ -340,17 +349,23 @@ impl PreviewState {
// The fence must be put after the previewer is initialized.
compile_handler.flush_compile();

// Replace the data plane port in the html to self
let frontend_html = frontend_html(TYPST_PREVIEW_HTML, args.preview_mode, "/");
let secret = auth::generate_token();

let srv = make_http_server(frontend_html, args.data_plane_host, websocket_tx).await;
let srv = make_http_server(
true,
args.data_plane_host,
Some(secret.clone()),
websocket_tx,
)
.await;
let addr = srv.addr;
log::info!("PreviewTask({task_id}): preview server listening on: {addr}");

let resp = StartPreviewResponse {
static_server_port: Some(addr.port()),
static_server_addr: Some(addr.to_string()),
data_plane_port: Some(addr.port()),
secret,
is_primary,
};

Expand Down Expand Up @@ -399,21 +414,21 @@ pub struct HttpServer {

/// Create a http server for the previewer.
pub async fn make_http_server(
frontend_html: String,
serve_frontend_html: bool,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may revert the change. It doesn't harm security to allow customizing frontend HTML.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason to do it like this is that it makes it easier to reason about the security properties of the code. If the HTML can be customized and I want to convince myself that there is no security issue, I will have to check every single call site. By not allowing to customize the HTML, I just have to check this one function.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what is bad about customization. We can take some responsibility to secure them instead of not providing the functionality. This is our work.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I'm not against customization. My point is that, since we currently don't need customization, it's easier to keep everything contained in a single function because it's easier to reason about. If it becomes necessary to add customization, we can of course change that.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh, so we'll finally change it back. I'm not sure why you feel unconfident about the existing code. Less changes means less controversy.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing code is fine. My only reason for this change is to make it easier to review for security (now and with future changes). I think that's worth it, but I'll change it back if you disagree.

static_file_addr: String,
websocket_tx: mpsc::UnboundedSender<HyperWebsocket>,
secret: Option<String>,
websocket_tx: mpsc::UnboundedSender<HyperWebsocketStream>,
) -> HttpServer {
use http_body_util::Full;
use hyper::body::{Bytes, Incoming};
type Server = hyper_util::server::conn::auto::Builder<hyper_util::rt::TokioExecutor>;

let frontend_html = hyper::body::Bytes::from(frontend_html);
let make_service = move || {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let secret = secret.clone();
service_fn(move |mut req: hyper::Request<Incoming>| {
let frontend_html = frontend_html.clone();
let websocket_tx = websocket_tx.clone();
let secret = secret.clone();
async move {
// Check if the request is a websocket upgrade request.
if hyper_tungstenite::is_upgrade_request(&req) {
Expand All @@ -424,17 +439,52 @@ pub async fn make_http_server(
})
.unwrap();

let _ = websocket_tx.send(websocket);
tokio::spawn(async move {
let websocket = websocket.await.unwrap();

// Authenticate the client before we talk to it.
// Important even if we run on localhost because
// 1) browsers allow any website to connect to http servers/websockets on localhost
// 2) on multi-user systems another (potentially untrusted) user can connect to localhost.
Comment on lines +447 to +448
Copy link
Collaborator

@Enter-tainer Enter-tainer Jan 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to mitigate (1) by setting relevant cors header or checking referer/origin in request header? Currently I think current auth workflow is somehow way to complex and "homemade". I wonder if there is any better solution for this.

I also seek for other people's solution. But it seems that they don't do authN for websocket. For example, vite relies on websocket to hot reload dev server. And it's a plain websocket connection and there is not bi direction auth.

From what I understand, if we didn't set Access-Control-Allow-Origin: * in our http server, the browser SHOULD forbid access to 127.0.0.1 if the source is not the same. But I remebered that I saw a poc somewhere so i'm not sure what's happening. It might be: (a) I get it wrong, browser did allow that?? (b) the poc http server also runs on 127.0.0.1, so they do run on the same origin? (c) we do set Access-Control-Allow-Origin: * in our code.

Copy link
Author

@tmistele tmistele Jan 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think CORS etc. do not work for WebSockets, so I think Access-Control-Allow-Origin won't help.

But here's an idea to make the authentication much less complex (so it's less of a problem that it's "homegrown"):

  1. We generate two secrets, let's call them client_secret and server_secret.
  2. The WebSockets servers knows both. And we also tell the client (the frontend html) about both (e.g. through the URL http://127.0.0.1:port/#client_secret=...&server_secret=...)
  3. On connect, the client sends the client_secret to the server. The server verifies that it's correct.
  4. The server sends the server_secret to the client. The client verifies that it's correct.

That's super simple and does "bi-direction auth".

It has worse security properties than a challenge response authentication with nonces, but for our case that's probably okay.

Edit: Actually, I don't like that. It doesn't work well in the case of multi-user systems. Consider this scenario: We start a websocket server on 127.0.0.1:port and open an html frontend in the browser. Now another user on the same system can try to 1) be faster than us to open a server on 127.0.0.1:port 2) wait until the client sends the client_secret 3) quickly shut down their "evil" server on 127.0.0.1:port so that the legitimate server doesn't error when starting (because the port is already in use). 4) Connect to the legitimate server using the legitimate client_secret. That's bad. Of course, the above needs the local attacker to win a race condition. But I don't want to rely on the absence of such race conditions for security.
The more complex authentication that is implemented at the moment doesn't have such problems because the secret itself is never sent over the connection. Only the hash(secret:challenge:nonce) is transmitted. And challenge and nonce are never controlled by the same party (one is chosen by the server, the other by the client).

Such problems also don't arise if we decide that we just don't support multi-user systems. In that case we can just give the client a token that we check on the sever. No bi-directional authentication needed. Makes things much simpler.

I would prefer not to do this, but it's probably mostly okay in practice since multi-user systems with adversarial users are likely rare.

What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think CORS etc. do not work for WebSockets, so I think Access-Control-Allow-Origin won't help.

but websocket connection is upgraded from a http request? if the http request is blocked, i'd assume it would be impossible to establish ws connection

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert but google says that CORS only applies to http responses, not requests. The Websocket connection only involves an upgrade http request, not a response.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just did a quick test and cors indeed doesn't apply to websocket. Would it make sense if we check Origin in http header and reject un-trusted hosts?

//
// Note: We use authentication only for the websocket. The static HTML file server (see below)
// only serves a not secret static template, so we don't bother with authentication there.
match secret {
Some(secret) => {
if let Ok(websocket) =
auth::try_auth_websocket_client(websocket, &secret).await
{
let _ = websocket_tx.send(websocket);
} else {
log::error!("Websocket client authentication failed");
}
}
None => {
// We optionally allow to skip authentication upon explicit request to ease the transition to
// authentication for downstream packages.
// FIXME: Remove this is in a future version.
let _ = websocket_tx.send(websocket);
}
}
});

// Return the response so the spawned future can continue.
Ok(response)
} else if req.uri().path() == "/" {
// log::debug!("Serve frontend: {mode:?}");
let res = hyper::Response::builder()
.header(hyper::header::CONTENT_TYPE, "text/html")
.body(Full::<Bytes>::from(frontend_html))
// It's important that we serve a static template that only contains information that is public anyway.
// Otherwise, we need authentication here (see comment for websocket case above).
// In particular, the websocket port, the secret etc. must not be in the HTML we serve. These information
// are in the # part of the URL.
.body(Full::<Bytes>::from(if serve_frontend_html {
TYPST_PREVIEW_HTML
} else {
""
}))
.unwrap();
Ok::<_, std::convert::Infallible>(res)
Ok::<_, anyhow::Error>(res)
} else {
// jump to /
let res = hyper::Response::builder()
Expand Down Expand Up @@ -570,17 +620,33 @@ pub async fn preview_main(args: PreviewCliArgs) -> anyhow::Result<()> {
(service, handle)
};

let secret = auth::generate_token();
log::info!("Secret for websocket authentication: {secret}");

let (lsp_tx, mut lsp_rx) = ControlPlaneTx::new(true);

let secret_for_control_plane = if args.disable_control_plane_auth {
log::warn!(
"Disabling authentication for the control plane server. This is not recommended."
);
None
} else {
Some(secret.clone())
};
let control_plane_server_handle = tokio::spawn(async move {
let (control_sock_tx, mut control_sock_rx) = mpsc::unbounded_channel();

let srv =
make_http_server(String::default(), args.control_plane_host, control_sock_tx).await;
let srv = make_http_server(
false,
args.control_plane_host,
secret_for_control_plane,
control_sock_tx,
)
.await;
log::info!("Control panel server listening on: {}", srv.addr);

let control_websocket = control_sock_rx.recv().await.unwrap();
let ws = control_websocket.await.unwrap();
let ws = control_websocket;

tokio::pin!(ws);

Expand Down Expand Up @@ -641,24 +707,42 @@ pub async fn preview_main(args: PreviewCliArgs) -> anyhow::Result<()> {

bind_streams(&mut previewer, websocket_rx);

let frontend_html = frontend_html(TYPST_PREVIEW_HTML, args.preview_mode, "/");

let static_server = if let Some(static_file_host) = static_file_host {
log::warn!("--static-file-host is deprecated, which will be removed in the future. Use --data-plane-host instead.");
let html = frontend_html.clone();
Some(make_http_server(html, static_file_host, websocket_tx.clone()).await)
Some(
make_http_server(
true,
static_file_host,
Some(secret.clone()),
websocket_tx.clone(),
)
.await,
)
} else {
None
};

let srv = make_http_server(frontend_html, args.data_plane_host, websocket_tx).await;
let srv = make_http_server(
true,
args.data_plane_host,
Some(secret.clone()),
websocket_tx,
)
.await;
log::info!("Data plane server listening on: {}", srv.addr);

let static_server_addr = static_server.as_ref().map(|s| s.addr).unwrap_or(srv.addr);
log::info!("Static file server listening on: {static_server_addr}");
let preview_url = format!(
"http://{static_server_addr}/#secret={secret}&previewMode={}",
match args.preview_mode {
PreviewMode::Document => "Doc",
PreviewMode::Slide => "Slide",
}
);
log::info!("Static file server listening on: {preview_url}");

if !args.dont_open_in_browser {
if let Err(e) = open::that_detached(format!("http://{static_server_addr}")) {
if let Err(e) = open::that_detached(preview_url) {
log::error!("failed to open browser: {e}");
};
}
Expand Down Expand Up @@ -741,29 +825,26 @@ fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, p: &mut Point) ->
None
}

fn bind_streams(previewer: &mut Previewer, websocket_rx: mpsc::UnboundedReceiver<HyperWebsocket>) {
previewer.start_data_plane(
websocket_rx,
|conn: Result<HyperWebsocketStream, hyper_tungstenite::tungstenite::Error>| {
let conn = conn.map_err(error_once_map_string!("cannot receive websocket"))?;

Ok(conn
.sink_map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string()))
.map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string()))
.with(|msg| {
Box::pin(async move {
let msg = match msg {
WsMessage::Text(msg) => Message::Text(msg),
WsMessage::Binary(msg) => Message::Binary(msg),
};
Ok(msg)
})
fn bind_streams(
previewer: &mut Previewer,
websocket_rx: mpsc::UnboundedReceiver<HyperWebsocketStream>,
) {
previewer.start_data_plane(websocket_rx, |conn: HyperWebsocketStream| {
conn.sink_map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string()))
.map_err(|e| error_once!("cannot serve_with websocket", err: e.to_string()))
.with(|msg| {
Box::pin(async move {
let msg = match msg {
WsMessage::Text(msg) => Message::Text(msg),
WsMessage::Binary(msg) => Message::Binary(msg),
};
Ok(msg)
})
.map_ok(|msg| match msg {
Message::Text(msg) => WsMessage::Text(msg),
Message::Binary(msg) => WsMessage::Binary(msg),
_ => WsMessage::Text("unsupported message".to_owned()),
}))
},
);
})
.map_ok(|msg| match msg {
Message::Text(msg) => WsMessage::Text(msg),
Message::Binary(msg) => WsMessage::Binary(msg),
_ => WsMessage::Text("unsupported message".to_owned()),
})
});
}
Loading