Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conformance/adapters/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub const TESTS: &[&str] = &[
"connect-invalid-protocols",
"connect-refused",
"connect-rejected",
"connect-redirect",
"connect-timeout",
"subprotocol-negotiated",
"subprotocol-none-offered",
Expand Down
1 change: 1 addition & 0 deletions conformance/adapters/jco/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const TESTS = [
"connect-invalid-protocols",
"connect-refused",
"connect-rejected",
"connect-redirect",
"connect-timeout",
"subprotocol-negotiated",
"subprotocol-none-offered",
Expand Down
17 changes: 17 additions & 0 deletions conformance/guest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const CORPUS: &[(&str, &[&str])] = &[
("connect-invalid-protocols", &["connect", "errors"]),
("connect-refused", &["connect", "errors"]),
("connect-rejected", &["connect", "errors"]),
("connect-redirect", &["connect", "errors"]),
("connect-timeout", &["connect", "errors", "timeouts"]),
("subprotocol-negotiated", &["connect", "subprotocol"]),
("subprotocol-none-offered", &["connect", "subprotocol"]),
Expand Down Expand Up @@ -260,9 +261,13 @@ async fn run(test_id: &str, config: &TestConfig) -> Result<(), String> {
Ok(())
}
"connect-invalid-url" => {
// server_url is `ws://host:port`; splice userinfo in after the
// scheme for the credentials case.
let with_userinfo = format!("ws://user:secret@{}/echo", &config.server_url[5..]);
let cases: &[String] = &[
format!("http{}", &config.server_url[2..]), // http:// scheme
format!("{}/echo#fragment", config.server_url),
with_userinfo,
"not a url".to_string(),
"/echo".to_string(),
];
Expand Down Expand Up @@ -318,6 +323,18 @@ async fn run(test_id: &str, config: &TestConfig) -> Result<(), String> {
Err(other) => Err(format!("expected connect-failed, got {}", describe(&other))),
}
}
"connect-redirect" => {
// A redirect instead of the upgrade must fail the connect;
// clients never follow (the redirect target is a working echo
// endpoint, so a client that followed would connect and fail
// here).
let url = format!("{}/redirect", config.server_url);
match Websocket::connect(url.clone(), Vec::new()).await {
Err(Error::ConnectFailed(_)) => Ok(()),
Ok(_) => Err("connect followed a redirect".to_string()),
Err(other) => Err(format!("expected connect-failed, got {}", describe(&other))),
}
}
"connect-timeout" => {
// The adapter configures a short connect bound; /stall never
// answers the handshake.
Expand Down
1 change: 1 addition & 0 deletions conformance/server/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ignored; unknown paths answer HTTP 404.
| `GET /healthz` | Plain HTTP 200. Readiness probe; not a WebSocket endpoint. |
| `/echo` | Echo every text/binary message verbatim, preserving kind and boundaries. The closing handshake is echoed too: a client close frame is acknowledged with the same code and reason. |
| `/reject` | Answer the upgrade with HTTP 403: the client observes a failed handshake. |
| `/redirect` | Answer the upgrade with HTTP 302 `Location: /echo`: clients must not follow (a client that did would reach a working echo endpoint and expose itself). |
| `/stall` | Never answer the handshake (held up to 120 s): the client's connect bound must fire. **Holds a pending handshake**: browsers serialize in-flight WebSocket handshakes per endpoint, so concurrent connects to the same host:port queue behind it. |
| `/close-after?count=N&code=C&reason=R` | Echo `N` messages (default 0), then the server initiates the close with code `C` and reason `R`; `code` omitted sends a code-less close frame (observed as 1005). Drains until the handshake completes. |
| `/burst-then-close?count=N&size=S&code=C&reason=R` | Immediately send `N` binary messages (default 1) of `S` bytes (default 16), then a close frame as in `/close-after`. |
Expand Down
12 changes: 12 additions & 0 deletions conformance/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ async fn handle_request(mut req: Request<Incoming>) -> anyhow::Result<Response<E
// the client observes a failed handshake.
return Ok(status_only(StatusCode::FORBIDDEN));
}
if path == "/redirect" {
// Answer the upgrade with a redirect toward /echo. WebSocket
// clients must not follow it (RFC 6455 leaves redirects to the
// client, and browsers fail the connection), so the target is
// deliberately valid: a client that connected anyway would pass
// the echo behavior and fail the row.
let mut response = status_only(StatusCode::FOUND);
response
.headers_mut()
.insert(hyper::header::LOCATION, HeaderValue::from_static("/echo"));
return Ok(response);
}
if path == "/stall" {
// Never answer the handshake (bounded so a stuck test cannot leak
// the socket forever); the client's connect bound must fire first.
Expand Down
7 changes: 6 additions & 1 deletion conformance/tests.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ description = "connect to the echo server with no subprotocol offer; no protocol
[[test]]
id = "connect-invalid-url"
tags = ["connect", "errors"]
description = "non-ws schemes, fragments, and non-URLs fail invalid-url eagerly"
description = "non-ws schemes, fragments, userinfo, and non-URLs fail invalid-url eagerly"

[[test]]
id = "connect-invalid-protocols"
Expand All @@ -34,6 +34,11 @@ id = "connect-rejected"
tags = ["connect", "errors"]
description = "an HTTP error instead of the upgrade fails connect-failed"

[[test]]
id = "connect-redirect"
tags = ["connect", "errors"]
description = "a redirect instead of the upgrade fails the connect; clients never follow"

[[test]]
id = "connect-timeout"
tags = ["connect", "errors", "timeouts"]
Expand Down
5 changes: 5 additions & 0 deletions js/jco/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ function validateUrl(url) {
if (!parsed.hostname) {
throw { tag: "invalid-url", val: "URL must have a host" };
}
// The WHATWG WebSocket constructor rejects credentials in the URL; the
// eager taxonomy matches that floor uniformly.
if (parsed.username || parsed.password) {
throw { tag: "invalid-url", val: "URL must not have userinfo" };
}
}

/** Validate a subprotocol offer per the WIT contract; throws `invalid-argument`. */
Expand Down
45 changes: 43 additions & 2 deletions rust/wasmtime/src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ impl InboundBudget {
fn overflowed(&self) -> bool {
self.overflowed.load(Ordering::SeqCst)
}

/// Latch the overflow directly (the transport rejected a message past
/// its cap before the budget could account it).
fn latch_overflow(&self) {
self.overflowed.store(true, Ordering::SeqCst);
}
}

/// A connection's inbound-message queue: the receiving half of the pump's
Expand Down Expand Up @@ -285,7 +291,7 @@ pub(crate) struct ConnectConfig {
}

/// Validate a connect URL per the WIT contract: absolute `ws:`/`wss:`, no
/// fragment.
/// fragment, no userinfo.
fn validate_url(url: &str) -> Result<(), String> {
if url.contains('#') {
return Err("URL must not have a fragment".to_string());
Expand All @@ -301,6 +307,14 @@ fn validate_url(url: &str) -> Result<(), String> {
if uri.host().is_none_or(str::is_empty) {
return Err("URL must have a host".to_string());
}
// The WHATWG WebSocket constructor rejects credentials in the URL; the
// eager taxonomy matches that floor uniformly.
if uri
.authority()
.is_some_and(|authority| authority.as_str().contains('@'))
{
return Err("URL must not have userinfo".to_string());
}
Ok(())
}

Expand Down Expand Up @@ -392,9 +406,24 @@ impl Websocket {
);
}

// The transport's own message/frame caps scale with the configured
// buffer bound instead of tungstenite's fixed defaults, so an
// embedder raising the bound cannot make the transport reject
// messages a browser-backed host would deliver (and the budget
// would overflow-close). Messages in (bound, cap] take the normal
// budget-overflow path; past the cap, the capacity error is mapped
// onto the same overflow taxonomy in the pump.
let transport_cap = config
.max_inbound_buffer_bytes
.saturating_mul(2)
.max(64 * 1024 * 1024);
let ws_config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.max_message_size(Some(transport_cap))
.max_frame_size(Some(transport_cap));

let (ws, response) = match tokio::time::timeout(
config.connect_timeout,
tokio_tungstenite::connect_async(request),
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false),
)
.await
{
Expand Down Expand Up @@ -712,6 +741,18 @@ impl Pump {
let _ = self.bounded_write(ws.flush()).await;
}
Some(Ok(_)) => {}
Some(Err(tungstenite::Error::Capacity(_))) => {
// The transport rejected a message past its cap
// (which scales above the buffer bound), so the
// guest-observable outcome is the overflow
// contract, same as a message the budget rejected.
// The read stream is compromised mid-frame; close
// toward the peer and tear down rather than keep
// reading.
self.budget.latch_overflow();
self.begin_close(&mut ws, None).await;
break;
}
// A read error or EOF is the transport's verdict either
// way; `peer_frame` already records whether a close
// frame arrived first.
Expand Down
15 changes: 8 additions & 7 deletions wit/websocket.wit
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ interface types {
/// contents, and do not expect them to be non-empty — some
/// implementations cannot observe failure details.
variant error {
/// The supplied URL is not an absolute `ws:` or `wss:` URL without a
/// fragment. `connections.websocket.connect` fails with this eagerly,
/// before any network activity.
/// The supplied URL is not an absolute `ws:` or `wss:` URL, or has
/// a fragment or userinfo. `connections.websocket.connect` fails
/// with this eagerly, before any network activity.
invalid-url(string),
/// The connection attempt failed: name resolution, TCP, TLS, or the
/// HTTP upgrade handshake (including a server whose subprotocol
Expand Down Expand Up @@ -170,10 +170,11 @@ interface connections {
resource websocket {
/// Open a WebSocket connection.
///
/// `url` must be an absolute `ws:` or `wss:` URL without a fragment;
/// anything else fails `invalid-url` eagerly. `protocols` is the
/// subprotocol offer, possibly empty; a malformed offer (an invalid
/// token, or a duplicate entry) fails `invalid-argument` eagerly.
/// `url` must be an absolute `ws:` or `wss:` URL without a
/// fragment or userinfo; anything else fails `invalid-url` eagerly.
/// `protocols` is the subprotocol offer, possibly empty; a
/// malformed offer (an invalid token, or a duplicate entry) fails
/// `invalid-argument` eagerly.
///
/// The returned future resolves once the WebSocket handshake has
/// completed and the connection is open, or with `connect-failed`
Expand Down
Loading