feat(web): add outbound WebSocket message size limit extension (#889)

Add support for chunking outbound WebSocket messages when they exceed a
configurable size limit. This helps avoid browser- or proxy-specific
WebSocket message size restrictions while maintaining wire
compatibility.

Changes:
- Add outbound_message_size_limit field to SessionBuilderInner
- Implement extension handler with safe f64->u32 casting and validation
- Update writer_task to chunk large messages when limit is set
- Add outboundMessageSizeLimit() helper function to JavaScript API

---------

Co-authored-by: Benoît Cortier <3809077+CBenoit@users.noreply.github.com>
This commit is contained in:
Gabriel Bauman 2025-07-31 04:18:36 -07:00 committed by GitHub
parent 32b0e40eca
commit 100765f98f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 9 deletions

View file

@ -69,6 +69,7 @@ struct SessionBuilderInner {
use_display_control: bool,
enable_credssp: bool,
outbound_message_size_limit: Option<u32>,
}
impl Default for SessionBuilderInner {
@ -97,6 +98,7 @@ impl Default for SessionBuilderInner {
use_display_control: false,
enable_credssp: true,
outbound_message_size_limit: None,
}
}
}
@ -219,6 +221,16 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder {
|kdc_proxy_url: String| { self.0.borrow_mut().kdc_proxy_url = Some(kdc_proxy_url) };
|display_control: bool| { self.0.borrow_mut().use_display_control = display_control };
|enable_credssp: bool| { self.0.borrow_mut().enable_credssp = enable_credssp };
|outbound_message_size_limit: f64| {
let limit = if outbound_message_size_limit >= 0.0 && outbound_message_size_limit <= f64::from(u32::MAX) {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{ outbound_message_size_limit as u32 }
} else {
warn!(outbound_message_size_limit, "Invalid outbound message size limit; fallback to unlimited");
0 // Fallback to no limit for invalid values.
};
self.0.borrow_mut().outbound_message_size_limit = if limit > 0 { Some(limit) } else { None };
};
}
self.clone()
@ -242,6 +254,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder {
remote_clipboard_changed_callback,
remote_received_format_list_callback,
force_clipboard_update_callback,
outbound_message_size_limit,
);
{
@ -271,6 +284,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder {
remote_clipboard_changed_callback = inner.remote_clipboard_changed_callback.clone();
remote_received_format_list_callback = inner.remote_received_format_list_callback.clone();
force_clipboard_update_callback = inner.force_clipboard_update_callback.clone();
outbound_message_size_limit = inner.outbound_message_size_limit;
}
info!("Connect to RDP host");
@ -293,9 +307,9 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder {
)
});
let ws = WebSocket::open(&proxy_address).context("Couldnt open WebSocket")?;
let ws = WebSocket::open(&proxy_address).context("couldn't open WebSocket")?;
// NOTE: ideally, when the WebSocket cant be opened, the above call should fail with details on why is that
// NOTE: ideally, when the WebSocket can't be opened, the above call should fail with details on why is that
// (e.g., the proxy hostname could not be resolved, proxy service is not running), but errors are neved
// bubbled up in practice, so instead we poll the WebSocket state until we know its connected (i.e., the
// WebSocket handshake is a success and user data can be exchanged).
@ -339,7 +353,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder {
let (writer_tx, writer_rx) = mpsc::unbounded();
spawn_local(writer_task(writer_rx, rdp_writer));
spawn_local(writer_task(writer_rx, rdp_writer, outbound_message_size_limit));
Ok(Session {
desktop_size: connection_result.desktop_size,
@ -885,22 +899,39 @@ fn build_config(
}
}
async fn writer_task(rx: mpsc::UnboundedReceiver<Vec<u8>>, rdp_writer: WriteHalf<WebSocket>) {
async fn writer_task(
rx: mpsc::UnboundedReceiver<Vec<u8>>,
rdp_writer: WriteHalf<WebSocket>,
outbound_limit: Option<u32>,
) {
debug!("writer task started");
async fn inner(
mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut rdp_writer: WriteHalf<WebSocket>,
outbound_limit: Option<u32>,
) -> anyhow::Result<()> {
while let Some(frame) = rx.next().await {
rdp_writer.write_all(&frame).await.context("Couldnt write frame")?;
rdp_writer.flush().await.context("Couldnt flush")?;
match outbound_limit {
Some(max_size) if frame.len() > max_size as usize => {
// Send in chunks.
for chunk in frame.chunks(max_size as usize) {
rdp_writer.write_all(chunk).await.context("couldn't write chunk")?;
rdp_writer.flush().await.context("couldn't flush chunk")?;
}
}
_ => {
// Send complete frame (default case).
rdp_writer.write_all(&frame).await.context("couldn't write frame")?;
rdp_writer.flush().await.context("couldn't flush frame")?;
}
}
}
Ok(())
}
match inner(rx, rdp_writer).await {
match inner(rx, rdp_writer, outbound_limit).await {
Ok(()) => debug!("writer task ended gracefully"),
Err(e) => error!("writer task ended unexpectedly: {e:#}"),
}
@ -960,7 +991,7 @@ async fn connect(
.ok()
.map(|url| KerberosConfig {
kdc_proxy_url: Some(url),
// HACK: Its supposed to be the computer name of the client, but since its not easy to retrieve this information in the browser,
// HACK: It's supposed to be the computer name of the client, but since it's not easy to retrieve this information in the browser,
// we set the destination hostname instead because it happens to work.
hostname: Some(destination),
}),
@ -1030,7 +1061,7 @@ where
framed
.write_all(&rdcleanpath_req)
.await
.context("couldnt write RDCleanPath request")?;
.context("couldn't write RDCleanPath request")?;
}
{

View file

@ -36,6 +36,10 @@ export function kdcProxyUrl(url: string): Extension {
return new Extension('kdc_proxy_url', url);
}
export function outboundMessageSizeLimit(limit: number): Extension {
return new Extension('outbound_message_size_limit', limit);
}
export function enableCredssp(enable: boolean): Extension {
return new Extension('enable_credssp', enable);
}