-
Notifications
You must be signed in to change notification settings - Fork 0
feat(network): serialize bounded BiDi WebSocket opening request #193
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
Merged
seonghobae
merged 9 commits into
feat/webdriver-bidi-loopback-tcp-connect
from
feat/webdriver-bidi-websocket-handshake-request
Aug 26, 2026
+365
−2
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
11a533d
test(network): require bounded BiDi WebSocket opening request
seonghobae 9803303
feat(network): bind BiDi WebSocket opening request
seonghobae 8b8f664
test(network): cover BiDi handshake diagnostics
seonghobae 73f2fbd
test(network): close BiDi handshake branch coverage
seonghobae d39d0d3
test(network): retain BiDi WebSocket client key
seonghobae ccfc641
fix(network): retain BiDi WebSocket client key
seonghobae b036e47
merge: carry ChromeDriver session compatibility into BiDi handshake r…
seonghobae 30da73a
test(network): redact WebSocket client nonce debug
seonghobae 6922dd9
fix(network): redact WebSocket client nonce debug
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
155 changes: 155 additions & 0 deletions
155
crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| use std::fmt; | ||
|
|
||
| use originweave_core::VerifiedWebDriverBiDiSocketPeer; | ||
|
|
||
| use crate::WebDriverBiDiTcpConnection; | ||
|
|
||
| const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; | ||
| const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = "<redacted WebSocket client nonce>"; | ||
|
|
||
| fn is_base64_data_byte(byte: u8) -> bool { | ||
| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') | ||
| } | ||
|
|
||
| fn is_canonical_16_byte_base64(value: &str) -> bool { | ||
| let bytes = value.as_bytes(); | ||
| bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH | ||
| && bytes[..22].iter().copied().all(is_base64_data_byte) | ||
| && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') | ||
| && bytes[22] == b'=' | ||
| && bytes[23] == b'=' | ||
| } | ||
|
|
||
| /// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. | ||
| #[derive(Debug, Eq, PartialEq)] | ||
| pub enum WebDriverBiDiWebSocketHandshakeError { | ||
| /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. | ||
| InvalidClientKey, | ||
| /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. | ||
| TlsRequired, | ||
| } | ||
|
|
||
| impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| match self { | ||
| Self::InvalidClientKey => formatter.write_str( | ||
| "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", | ||
| ), | ||
| Self::TlsRequired => formatter.write_str( | ||
| "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", | ||
| ), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} | ||
|
|
||
| /// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. | ||
| /// | ||
| /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type | ||
| /// validates only the canonical wire representation, including zero padding bits. It does not | ||
| /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce | ||
| /// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so | ||
| /// diagnostic output cannot disclose handshake material. | ||
| #[derive(Eq, PartialEq)] | ||
| pub struct WebDriverBiDiWebSocketClientKey(String); | ||
|
|
||
| impl fmt::Debug for WebDriverBiDiWebSocketClientKey { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter | ||
| .debug_tuple("WebDriverBiDiWebSocketClientKey") | ||
| .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) | ||
| .finish() | ||
| } | ||
| } | ||
|
|
||
| impl WebDriverBiDiWebSocketClientKey { | ||
| /// Admit one canonical base64 client key representing exactly 16 bytes. | ||
| pub fn new(value: &str) -> Result<Self, WebDriverBiDiWebSocketHandshakeError> { | ||
| if !is_canonical_16_byte_base64(value) { | ||
| return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); | ||
| } | ||
| Ok(Self(value.to_owned())) | ||
| } | ||
|
|
||
| /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. | ||
| #[must_use] | ||
| pub fn as_str(&self) -> &str { | ||
| &self.0 | ||
| } | ||
| } | ||
|
|
||
| /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. | ||
| /// | ||
| /// The plan consumes the verified TCP connection so the opening request cannot be detached from the | ||
| /// socket peer/session evidence that authorized its exact loopback destination. It serializes only | ||
| /// the fixed WebSocket version-13 request required for the admitted `/session/<session-id>` resource | ||
| /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. | ||
| /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary | ||
| /// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized | ||
| /// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. | ||
| /// | ||
| /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` | ||
| /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or | ||
| /// Agent-authority grant. | ||
| pub struct WebDriverBiDiWebSocketHandshakePlan { | ||
| connection: WebDriverBiDiTcpConnection, | ||
| client_key: WebDriverBiDiWebSocketClientKey, | ||
| request: Vec<u8>, | ||
| } | ||
|
|
||
| impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| formatter | ||
| .debug_struct("WebDriverBiDiWebSocketHandshakePlan") | ||
| .field("verified_peer", self.connection.verified_peer()) | ||
| .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) | ||
| .field("request_byte_count", &self.request.len()) | ||
| .finish() | ||
| } | ||
| } | ||
|
|
||
| impl WebDriverBiDiWebSocketHandshakePlan { | ||
| /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. | ||
| pub fn new( | ||
| connection: WebDriverBiDiTcpConnection, | ||
| client_key: WebDriverBiDiWebSocketClientKey, | ||
| ) -> Result<Self, WebDriverBiDiWebSocketHandshakeError> { | ||
| if connection.verified_peer().requires_tls() { | ||
| return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); | ||
| } | ||
|
|
||
| let peer = connection.verified_peer(); | ||
| let request = format!( | ||
| "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", | ||
| peer.session_id(), | ||
| peer.socket_addr(), | ||
| client_key.as_str(), | ||
| ) | ||
| .into_bytes(); | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| Ok(Self { | ||
| connection, | ||
| client_key, | ||
| request, | ||
| }) | ||
| } | ||
|
|
||
| /// Borrow the exact serialized RFC 6455 opening-request bytes. | ||
| #[must_use] | ||
| pub fn request_bytes(&self) -> &[u8] { | ||
| &self.request | ||
| } | ||
|
|
||
| /// Borrow the exact client key that a later server-handshake validator must correlate. | ||
| #[must_use] | ||
| pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { | ||
| &self.client_key | ||
| } | ||
|
|
||
| /// Borrow the exact peer/session evidence already verified before request construction. | ||
| #[must_use] | ||
| pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { | ||
| self.connection.verified_peer() | ||
| } | ||
| } | ||
202 changes: 202 additions & 0 deletions
202
crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| use std::{net::TcpListener, thread, time::Duration}; | ||
|
|
||
| use originweave_core::WebDriverBiDiWebSocketEndpoint; | ||
| use originweave_network::{ | ||
| WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, | ||
| WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, | ||
| }; | ||
|
|
||
| const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; | ||
| const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; | ||
| const REDACTED_CLIENT_KEY: &str = "<redacted WebSocket client nonce>"; | ||
|
|
||
| fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { | ||
| let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); | ||
| assert!(admitted.is_ok(), "{admitted:?}"); | ||
| let Ok(admitted) = admitted else { | ||
| unreachable!("asserted valid endpoint") | ||
| }; | ||
| let correlated = admitted.correlate_session_id(SESSION_ID); | ||
| assert!(correlated.is_ok(), "{correlated:?}"); | ||
| let Ok(correlated) = correlated else { | ||
| unreachable!("asserted correlated endpoint") | ||
| }; | ||
| let target = correlated.into_explicit_connect_target(); | ||
| assert!(target.is_ok(), "{target:?}"); | ||
| let Ok(target) = target else { | ||
| unreachable!("asserted explicit target") | ||
| }; | ||
| let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); | ||
| assert!(plan.is_ok(), "{plan:?}"); | ||
| let Ok(plan) = plan else { | ||
| unreachable!("asserted connection plan") | ||
| }; | ||
| let connection = plan.connect(); | ||
| assert!(connection.is_ok(), "{connection:?}"); | ||
| let Ok(connection) = connection else { | ||
| unreachable!("asserted loopback connection") | ||
| }; | ||
| connection | ||
| } | ||
|
|
||
| #[test] | ||
| fn client_key_debug_redacts_websocket_nonce() { | ||
| let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); | ||
| assert!(key.is_ok(), "{key:?}"); | ||
| let Ok(key) = key else { | ||
| return; | ||
| }; | ||
|
|
||
| let debug = format!("{key:?}"); | ||
| assert!(debug.contains(REDACTED_CLIENT_KEY)); | ||
| assert!(!debug.contains(RFC6455_SAMPLE_KEY)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn handshake_plan_debug_redacts_websocket_nonce() { | ||
| let listener = TcpListener::bind(("127.0.0.1", 0)); | ||
| assert!(listener.is_ok(), "{listener:?}"); | ||
| let Ok(listener) = listener else { | ||
| return; | ||
| }; | ||
| let local_addr = listener.local_addr(); | ||
| assert!(local_addr.is_ok(), "{local_addr:?}"); | ||
| let Ok(local_addr) = local_addr else { | ||
| return; | ||
| }; | ||
| let server = thread::spawn(move || listener.accept().map(|_| ())); | ||
|
|
||
| let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); | ||
| let connection = connect(&endpoint); | ||
| let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); | ||
| assert!(key.is_ok(), "{key:?}"); | ||
| let Ok(key) = key else { | ||
| return; | ||
| }; | ||
| let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); | ||
| assert!(plan.is_ok(), "{plan:?}"); | ||
| let Ok(plan) = plan else { | ||
| return; | ||
| }; | ||
|
|
||
| let debug = format!("{plan:?}"); | ||
| assert!(debug.contains(REDACTED_CLIENT_KEY)); | ||
| assert!(!debug.contains(RFC6455_SAMPLE_KEY)); | ||
|
|
||
| let server_result = server.join(); | ||
| assert!(server_result.is_ok(), "{server_result:?}"); | ||
| if let Ok(accept_result) = server_result { | ||
| assert!(accept_result.is_ok(), "{accept_result:?}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { | ||
| let listener = TcpListener::bind(("127.0.0.1", 0)); | ||
| assert!(listener.is_ok(), "{listener:?}"); | ||
| let Ok(listener) = listener else { | ||
| return; | ||
| }; | ||
| let local_addr = listener.local_addr(); | ||
| assert!(local_addr.is_ok(), "{local_addr:?}"); | ||
| let Ok(local_addr) = local_addr else { | ||
| return; | ||
| }; | ||
| let server = thread::spawn(move || listener.accept().map(|_| ())); | ||
|
|
||
| let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); | ||
| let connection = connect(&endpoint); | ||
| let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); | ||
| assert!(key.is_ok(), "{key:?}"); | ||
| let Ok(key) = key else { | ||
| return; | ||
| }; | ||
| let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); | ||
| assert!(plan.is_ok(), "{plan:?}"); | ||
| let Ok(plan) = plan else { | ||
| return; | ||
| }; | ||
|
|
||
| let expected = format!( | ||
| "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" | ||
| ); | ||
| assert_eq!(plan.request_bytes(), expected.as_bytes()); | ||
| assert_eq!(plan.client_key().as_str(), RFC6455_SAMPLE_KEY); | ||
| assert_eq!(plan.verified_peer().socket_addr(), local_addr); | ||
| assert_eq!(plan.verified_peer().session_id(), SESSION_ID); | ||
| assert!(!plan.verified_peer().requires_tls()); | ||
|
|
||
| let server_result = server.join(); | ||
| assert!(server_result.is_ok(), "{server_result:?}"); | ||
| if let Ok(accept_result) = server_result { | ||
| assert!(accept_result.is_ok(), "{accept_result:?}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn handshake_errors_render_actionable_fail_closed_messages() { | ||
| assert_eq!( | ||
| WebDriverBiDiWebSocketHandshakeError::InvalidClientKey.to_string(), | ||
| "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes" | ||
| ); | ||
| assert_eq!( | ||
| WebDriverBiDiWebSocketHandshakeError::TlsRequired.to_string(), | ||
| "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { | ||
| let invalid_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); | ||
| assert!(matches!( | ||
| invalid_length, | ||
| Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) | ||
| )); | ||
| let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); | ||
| assert!(matches!( | ||
| invalid_character, | ||
| Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) | ||
| )); | ||
| let invalid_padding_bits = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZR=="); | ||
| assert!(matches!( | ||
| invalid_padding_bits, | ||
| Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) | ||
| )); | ||
| let invalid_padding_character = | ||
| WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQA="); | ||
| assert!(matches!( | ||
| invalid_padding_character, | ||
| Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) | ||
| )); | ||
|
|
||
| let listener = TcpListener::bind(("127.0.0.1", 0)); | ||
| assert!(listener.is_ok(), "{listener:?}"); | ||
| let Ok(listener) = listener else { | ||
| return; | ||
| }; | ||
| let local_addr = listener.local_addr(); | ||
| assert!(local_addr.is_ok(), "{local_addr:?}"); | ||
| let Ok(local_addr) = local_addr else { | ||
| return; | ||
| }; | ||
| let server = thread::spawn(move || listener.accept().map(|_| ())); | ||
|
|
||
| let endpoint = format!("wss://{local_addr}/session/{SESSION_ID}"); | ||
| let connection = connect(&endpoint); | ||
| let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); | ||
| assert!(key.is_ok(), "{key:?}"); | ||
| let Ok(key) = key else { | ||
| return; | ||
| }; | ||
| let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); | ||
| assert!(matches!( | ||
| plan, | ||
| Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) | ||
| )); | ||
|
|
||
| let server_result = server.join(); | ||
| assert!(server_result.is_ok(), "{server_result:?}"); | ||
| if let Ok(accept_result) = server_result { | ||
| assert!(accept_result.is_ok(), "{accept_result:?}"); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.