| 1 | //! Regression tests for the four properties we committed to: |
| 2 | //! |
| 3 | //! 1. bind to loopback only |
| 4 | //! 2. Origin allowlist (blocks hostile web pages) |
| 5 | //! 3. 0600 token file (blocks other local uids) |
| 6 | //! 4. explicit pairing (never trust-on-first-use silently) |
| 7 | //! |
| 8 | //! Each is something that is easy to get right once and then quietly regress, |
| 9 | //! which is exactly what tests are for. The reference implementation we looked |
| 10 | //! at got #1 and #3 right and #2 and #4 wrong, and the result was that any |
| 11 | //! website could open a shell. |
| 12 | |
| 13 | use std::net::{IpAddr, SocketAddr}; |
| 14 | use std::time::Duration; |
| 15 | |
| 16 | use futures_util::{SinkExt, StreamExt}; |
| 17 | use tokio_tungstenite::tungstenite::client::IntoClientRequest; |
| 18 | use tokio_tungstenite::tungstenite::http::StatusCode; |
| 19 | use tokio_tungstenite::tungstenite::{Error as WsError, Message}; |
| 20 | use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; |
| 21 | |
| 22 | use termbridge::auth::{self, Denied}; |
| 23 | use termbridge::{Config, Server, paths}; |
| 24 | |
| 25 | const GOOD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; |
| 26 | const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; |
| 27 | const UNPAIRED: &str = "https://evil.example"; |
| 28 | |
| 29 | type Client = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>; |
| 30 | |
| 31 | fn test_config() -> Config { |
| 32 | let mut c = Config::new(GOOD_TOKEN, vec![PAIRED.to_string()]); |
| 33 | c.auth_timeout = Duration::from_millis(300); |
| 34 | // Exercise auth without spawning real shells. |
| 35 | c.echo_only = true; |
| 36 | c |
| 37 | } |
| 38 | |
| 39 | async fn start(config: Config) -> Server { |
| 40 | Server::start(config, 0).await.expect("bind ephemeral port") |
| 41 | } |
| 42 | |
| 43 | /// Connect with full control over Origin and Host, the two headers an attacker |
| 44 | /// would want to lie about. |
| 45 | async fn connect( |
| 46 | server: &Server, |
| 47 | origin: Option<&str>, |
| 48 | host_override: Option<&str>, |
| 49 | query: &str, |
| 50 | ) -> Result<Client, WsError> { |
| 51 | let url = format!("{}{}", server.url(), query); |
| 52 | let mut req = url.into_client_request().unwrap(); |
| 53 | if let Some(o) = origin { |
| 54 | req.headers_mut().insert("origin", o.parse().unwrap()); |
| 55 | } |
| 56 | if let Some(h) = host_override { |
| 57 | req.headers_mut().insert("host", h.parse().unwrap()); |
| 58 | } |
| 59 | connect_async(req).await.map(|(ws, _)| ws) |
| 60 | } |
| 61 | |
| 62 | fn rejected_with(err: &WsError, expect: StatusCode) -> bool { |
| 63 | matches!(err, WsError::Http(resp) if resp.status() == expect) |
| 64 | } |
| 65 | |
| 66 | /// A client that got through the handshake and sent a valid auth frame. |
| 67 | async fn authed(server: &Server) -> Client { |
| 68 | let mut ws = connect(server, Some(PAIRED), None, "") |
| 69 | .await |
| 70 | .expect("handshake"); |
| 71 | ws.send(Message::Text( |
| 72 | serde_json::json!({"type": "auth", "token": GOOD_TOKEN}) |
| 73 | .to_string() |
| 74 | .into(), |
| 75 | )) |
| 76 | .await |
| 77 | .unwrap(); |
| 78 | let reply = ws.next().await.unwrap().unwrap(); |
| 79 | assert!( |
| 80 | reply.to_text().unwrap().contains("\"ok\""), |
| 81 | "expected ok, got {reply:?}" |
| 82 | ); |
| 83 | ws |
| 84 | } |
| 85 | |
| 86 | // --------------------------------------------------------------------------- |
| 87 | // 1. Bind to loopback only |
| 88 | // --------------------------------------------------------------------------- |
| 89 | |
| 90 | #[tokio::test] |
| 91 | async fn listener_address_is_loopback() { |
| 92 | let server = start(test_config()).await; |
| 93 | assert!( |
| 94 | server.addr().ip().is_loopback(), |
| 95 | "listening on {} — must be loopback", |
| 96 | server.addr() |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /// The listener must not be reachable on this machine's LAN address. Without |
| 101 | /// this, a laptop on café wifi is serving shells to the network. |
| 102 | #[tokio::test] |
| 103 | async fn not_reachable_on_lan_interface() { |
| 104 | let server = start(test_config()).await; |
| 105 | let port = server.addr().port(); |
| 106 | |
| 107 | let Some(lan_ip) = outbound_interface_ip() else { |
| 108 | eprintln!("skipping: no non-loopback interface available"); |
| 109 | return; |
| 110 | }; |
| 111 | |
| 112 | let target = SocketAddr::new(lan_ip, port); |
| 113 | let res = tokio::time::timeout( |
| 114 | Duration::from_millis(500), |
| 115 | tokio::net::TcpStream::connect(target), |
| 116 | ) |
| 117 | .await; |
| 118 | |
| 119 | match res { |
| 120 | Ok(Ok(_)) => panic!("connected to {target} — daemon is exposed on the network"), |
| 121 | _ => { /* refused or timed out, as required */ } |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | /// Guards against someone adding a `--bind` flag or flipping the constant. |
| 126 | /// A config option to listen on 0.0.0.0 has no legitimate use here and only |
| 127 | /// creates a remotely exploitable setup. |
| 128 | #[test] |
| 129 | fn source_never_binds_wildcard() { |
| 130 | let src = std::fs::read_to_string("src/server.rs").unwrap(); |
| 131 | for needle in ["0.0.0.0", "UNSPECIFIED", "[::]"] { |
| 132 | assert!( |
| 133 | !src.contains(needle), |
| 134 | "src/server.rs mentions `{needle}` — the bind address must stay loopback-only" |
| 135 | ); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // --------------------------------------------------------------------------- |
| 140 | // 2. Origin check — the defense against hostile web pages |
| 141 | // --------------------------------------------------------------------------- |
| 142 | |
| 143 | /// WebSocket has no CORS preflight, so any page you visit can open a socket to |
| 144 | /// 127.0.0.1. `Origin` is browser-set and unforgeable from page JS, so this is |
| 145 | /// the check that actually stops that. |
| 146 | #[tokio::test] |
| 147 | async fn rejects_unpaired_origin() { |
| 148 | let server = start(test_config()).await; |
| 149 | let err = connect(&server, Some(UNPAIRED), None, "") |
| 150 | .await |
| 151 | .expect_err("hostile origin must be refused at handshake"); |
| 152 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 153 | } |
| 154 | |
| 155 | /// Even *with* a stolen token. Origin and token are independent gates. |
| 156 | #[tokio::test] |
| 157 | async fn unpaired_origin_rejected_even_with_valid_token() { |
| 158 | let server = start(test_config()).await; |
| 159 | let err = connect(&server, Some(UNPAIRED), None, "") |
| 160 | .await |
| 161 | .expect_err("must fail before the token is ever considered"); |
| 162 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 163 | } |
| 164 | |
| 165 | #[tokio::test] |
| 166 | async fn rejects_missing_origin() { |
| 167 | let server = start(test_config()).await; |
| 168 | let err = connect(&server, None, None, "") |
| 169 | .await |
| 170 | .expect_err("no Origin must be refused"); |
| 171 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 172 | } |
| 173 | |
| 174 | #[tokio::test] |
| 175 | async fn rejects_null_origin() { |
| 176 | let server = start(test_config()).await; |
| 177 | let err = connect(&server, Some("null"), None, "") |
| 178 | .await |
| 179 | .expect_err("sandboxed-iframe `null` origin must be refused"); |
| 180 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 181 | } |
| 182 | |
| 183 | /// DNS rebinding: attacker.com resolves to 127.0.0.1, so the page reaches us |
| 184 | /// while carrying its own origin. The Host header is what gives it away. |
| 185 | #[tokio::test] |
| 186 | async fn rejects_rebound_host_header() { |
| 187 | let server = start(test_config()).await; |
| 188 | let err = connect(&server, Some(PAIRED), Some("attacker.example"), "") |
| 189 | .await |
| 190 | .expect_err("non-loopback Host must be refused"); |
| 191 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn host_header_parsing() { |
| 196 | for good in [ |
| 197 | "127.0.0.1", |
| 198 | "127.0.0.1:7681", |
| 199 | "localhost", |
| 200 | "localhost:7681", |
| 201 | "[::1]:7681", |
| 202 | ] { |
| 203 | assert!(auth::host_is_loopback(good), "{good} should be loopback"); |
| 204 | } |
| 205 | for bad in [ |
| 206 | "attacker.example", |
| 207 | "attacker.example:7681", |
| 208 | "127.0.0.1.attacker.example", |
| 209 | "localhost.attacker.example", |
| 210 | "192.168.1.5:7681", |
| 211 | "", |
| 212 | ] { |
| 213 | assert!(!auth::host_is_loopback(bad), "{bad} must not pass"); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // --------------------------------------------------------------------------- |
| 218 | // 3. Token |
| 219 | // --------------------------------------------------------------------------- |
| 220 | |
| 221 | #[tokio::test] |
| 222 | async fn rejects_wrong_token() { |
| 223 | let server = start(test_config()).await; |
| 224 | let mut ws = connect(&server, Some(PAIRED), None, "").await.unwrap(); |
| 225 | ws.send(Message::Text( |
| 226 | serde_json::json!({"type": "auth", "token": "wrong"}) |
| 227 | .to_string() |
| 228 | .into(), |
| 229 | )) |
| 230 | .await |
| 231 | .unwrap(); |
| 232 | let reply = ws.next().await.unwrap().unwrap(); |
| 233 | assert!( |
| 234 | reply.to_text().unwrap().contains("invalid token"), |
| 235 | "{reply:?}" |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | #[tokio::test] |
| 240 | async fn rejects_missing_auth_frame() { |
| 241 | let server = start(test_config()).await; |
| 242 | let mut ws = connect(&server, Some(PAIRED), None, "").await.unwrap(); |
| 243 | // Say nothing. The deadline must close us. |
| 244 | let outcome = tokio::time::timeout(Duration::from_secs(2), async { |
| 245 | while let Some(Ok(m)) = ws.next().await { |
| 246 | if let Message::Text(t) = &m { |
| 247 | if t.contains("auth timeout") { |
| 248 | return true; |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | false |
| 253 | }) |
| 254 | .await |
| 255 | .expect("server must not leave an unauthenticated socket open"); |
| 256 | assert!(outcome, "expected an auth timeout rejection"); |
| 257 | } |
| 258 | |
| 259 | /// Sending data before authenticating must not reach the terminal. |
| 260 | #[tokio::test] |
| 261 | async fn rejects_data_frame_before_auth() { |
| 262 | let server = start(test_config()).await; |
| 263 | let mut ws = connect(&server, Some(PAIRED), None, "").await.unwrap(); |
| 264 | ws.send(Message::Binary(b"rm -rf ~\n".to_vec().into())) |
| 265 | .await |
| 266 | .unwrap(); |
| 267 | let reply = ws.next().await.unwrap().unwrap(); |
| 268 | assert!( |
| 269 | reply.to_text().unwrap().contains("malformed auth"), |
| 270 | "pre-auth data must be refused, got {reply:?}" |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | /// The token must not be accepted from the query string. Query strings leak |
| 275 | /// into logs, crash dumps and devtools history, and the browser WebSocket API |
| 276 | /// makes putting it there the path of least resistance. |
| 277 | #[tokio::test] |
| 278 | async fn query_string_token_does_not_authenticate() { |
| 279 | let server = start(test_config()).await; |
| 280 | let mut ws = connect( |
| 281 | &server, |
| 282 | Some(PAIRED), |
| 283 | None, |
| 284 | &format!("/?token={GOOD_TOKEN}"), |
| 285 | ) |
| 286 | .await |
| 287 | .unwrap(); |
| 288 | let outcome = tokio::time::timeout(Duration::from_secs(2), async { |
| 289 | while let Some(Ok(m)) = ws.next().await { |
| 290 | if let Message::Text(t) = &m { |
| 291 | if t.contains("\"ok\"") { |
| 292 | return false; // authenticated via URL — bad |
| 293 | } |
| 294 | if t.contains("auth timeout") { |
| 295 | return true; |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | false |
| 300 | }) |
| 301 | .await |
| 302 | .expect("must not hang"); |
| 303 | assert!(outcome, "query-string token must not authenticate"); |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn token_compare_rejects_wrong_length_and_content() { |
| 308 | assert!(auth::token_matches(GOOD_TOKEN, GOOD_TOKEN)); |
| 309 | assert!(!auth::token_matches("", GOOD_TOKEN)); |
| 310 | assert!(!auth::token_matches(&GOOD_TOKEN[..63], GOOD_TOKEN)); |
| 311 | assert!(!auth::token_matches(&format!("{GOOD_TOKEN}x"), GOOD_TOKEN)); |
| 312 | // Differs only in the final byte — a short-circuiting compare would leak |
| 313 | // this via timing. |
| 314 | let mut near = GOOD_TOKEN.to_string(); |
| 315 | near.pop(); |
| 316 | near.push('0'); |
| 317 | assert!(!auth::token_matches(&near, GOOD_TOKEN)); |
| 318 | } |
| 319 | |
| 320 | #[test] |
| 321 | fn generated_token_file_is_0600() { |
| 322 | use std::os::unix::fs::PermissionsExt; |
| 323 | let dir = tempfile::tempdir().unwrap(); |
| 324 | let token = paths::generate_token(dir.path()).unwrap(); |
| 325 | |
| 326 | assert_eq!( |
| 327 | token.len(), |
| 328 | paths::TOKEN_BYTES * 2, |
| 329 | "expected 32 random bytes as hex" |
| 330 | ); |
| 331 | |
| 332 | let mode = std::fs::metadata(dir.path().join("token")) |
| 333 | .unwrap() |
| 334 | .permissions() |
| 335 | .mode() |
| 336 | & 0o777; |
| 337 | assert_eq!(mode, 0o600, "token file mode is {mode:04o}, must be 0600"); |
| 338 | |
| 339 | let dir_mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777; |
| 340 | assert_eq!( |
| 341 | dir_mode, 0o700, |
| 342 | "config dir mode is {dir_mode:04o}, must be 0700" |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | /// If the mode ever loosens, refuse rather than authenticate against a secret |
| 347 | /// every user on the box can read. |
| 348 | #[test] |
| 349 | fn load_token_refuses_group_or_world_readable_file() { |
| 350 | use std::os::unix::fs::PermissionsExt; |
| 351 | for bad_mode in [0o644, 0o640, 0o604, 0o666] { |
| 352 | let dir = tempfile::tempdir().unwrap(); |
| 353 | paths::generate_token(dir.path()).unwrap(); |
| 354 | std::fs::set_permissions( |
| 355 | dir.path().join("token"), |
| 356 | std::fs::Permissions::from_mode(bad_mode), |
| 357 | ) |
| 358 | .unwrap(); |
| 359 | assert!( |
| 360 | paths::load_token(dir.path()).is_err(), |
| 361 | "mode {bad_mode:04o} must be refused" |
| 362 | ); |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | #[test] |
| 367 | fn tokens_are_unique_across_generations() { |
| 368 | let a = paths::generate_token(tempfile::tempdir().unwrap().path()).unwrap(); |
| 369 | let b = paths::generate_token(tempfile::tempdir().unwrap().path()).unwrap(); |
| 370 | assert_ne!(a, b); |
| 371 | } |
| 372 | |
| 373 | // --------------------------------------------------------------------------- |
| 374 | // 4. Explicit pairing |
| 375 | // --------------------------------------------------------------------------- |
| 376 | |
| 377 | /// A fresh install must grant nothing. No implicit trust-on-first-use. |
| 378 | #[tokio::test] |
| 379 | async fn fresh_install_pairs_nothing() { |
| 380 | let mut cfg = Config::new(GOOD_TOKEN, vec![]); |
| 381 | cfg.echo_only = true; |
| 382 | let server = start(cfg).await; |
| 383 | let err = connect(&server, Some(PAIRED), None, "") |
| 384 | .await |
| 385 | .expect_err("with no paired origins, everything is refused"); |
| 386 | assert!(rejected_with(&err, StatusCode::FORBIDDEN), "got {err:?}"); |
| 387 | } |
| 388 | |
| 389 | #[test] |
| 390 | fn connecting_does_not_pair_itself() { |
| 391 | let dir = tempfile::tempdir().unwrap(); |
| 392 | assert!(paths::load_paired_origins(dir.path()).is_empty()); |
| 393 | // Pairing only ever happens through the explicit CLI path. |
| 394 | assert!(!auth::origin_is_paired( |
| 395 | PAIRED, |
| 396 | &paths::load_paired_origins(dir.path()) |
| 397 | )); |
| 398 | } |
| 399 | |
| 400 | #[test] |
| 401 | fn pair_and_unpair_round_trip() { |
| 402 | let dir = tempfile::tempdir().unwrap(); |
| 403 | assert!(paths::pair_origin(dir.path(), PAIRED).unwrap()); |
| 404 | assert!( |
| 405 | !paths::pair_origin(dir.path(), PAIRED).unwrap(), |
| 406 | "idempotent" |
| 407 | ); |
| 408 | assert_eq!( |
| 409 | paths::load_paired_origins(dir.path()), |
| 410 | vec![PAIRED.to_string()] |
| 411 | ); |
| 412 | |
| 413 | assert!(paths::unpair_origin(dir.path(), PAIRED).unwrap()); |
| 414 | assert!(paths::load_paired_origins(dir.path()).is_empty()); |
| 415 | } |
| 416 | |
| 417 | /// Firefox's moz-extension origin is a random per-install UUID, so pairing must |
| 418 | /// work with an origin that cannot be known ahead of time. |
| 419 | #[test] |
| 420 | fn pairs_firefox_random_uuid_origin() { |
| 421 | let dir = tempfile::tempdir().unwrap(); |
| 422 | let ff = "moz-extension://11111111-2222-3333-4444-555555555555"; |
| 423 | paths::pair_origin(dir.path(), ff).unwrap(); |
| 424 | let paired = paths::load_paired_origins(dir.path()); |
| 425 | assert!(auth::origin_is_paired(ff, &paired)); |
| 426 | assert!( |
| 427 | auth::origin_is_paired(&ff.to_uppercase(), &paired), |
| 428 | "case-insensitive" |
| 429 | ); |
| 430 | assert!(!auth::origin_is_paired( |
| 431 | "moz-extension://99999999-2222-3333-4444-555555555555", |
| 432 | &paired |
| 433 | )); |
| 434 | } |
| 435 | |
| 436 | /// Pairing one origin must not imply its neighbours — no prefix or substring |
| 437 | /// matching. `chrome-extension://` as a blanket allow would let any other |
| 438 | /// installed extension through. |
| 439 | #[test] |
| 440 | fn pairing_is_exact_match_not_prefix() { |
| 441 | let paired = vec![PAIRED.to_string()]; |
| 442 | for near_miss in [ |
| 443 | "chrome-extension://", |
| 444 | "chrome-extension://abcdefghijklmnopabcdefghijklmnoq", |
| 445 | "chrome-extension://abcdefghijklmnopabcdefghijklmnop.evil.example", |
| 446 | "https://abcdefghijklmnopabcdefghijklmnop", |
| 447 | ] { |
| 448 | assert!( |
| 449 | !auth::origin_is_paired(near_miss, &paired), |
| 450 | "{near_miss} must not match" |
| 451 | ); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | // --------------------------------------------------------------------------- |
| 456 | // Happy path + rate limiting |
| 457 | // --------------------------------------------------------------------------- |
| 458 | |
| 459 | #[tokio::test] |
| 460 | async fn paired_origin_with_valid_token_is_accepted() { |
| 461 | let server = start(test_config()).await; |
| 462 | let _ws = authed(&server).await; |
| 463 | } |
| 464 | |
| 465 | /// Binary frames survive round-trip untouched — this is what makes the |
| 466 | /// WebSocket transport worth it over native messaging's JSON-only channel. |
| 467 | /// Includes a lone continuation byte, which is invalid UTF-8 and would have to |
| 468 | /// be base64'd or mangled on a JSON transport. |
| 469 | #[tokio::test] |
| 470 | async fn binary_frames_round_trip_invalid_utf8() { |
| 471 | let server = start(test_config()).await; |
| 472 | let mut ws = authed(&server).await; |
| 473 | |
| 474 | let raw: Vec<u8> = vec![0x1b, b'[', b'3', b'1', b'm', 0xff, 0xfe, 0x80, b'o', b'k']; |
| 475 | ws.send(Message::Binary(raw.clone().into())).await.unwrap(); |
| 476 | let echoed = ws.next().await.unwrap().unwrap(); |
| 477 | match echoed { |
| 478 | Message::Binary(b) => assert_eq!(b.as_ref(), raw.as_slice()), |
| 479 | other => panic!("expected binary echo, got {other:?}"), |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | #[tokio::test] |
| 484 | async fn repeated_failures_trigger_lockout() { |
| 485 | let mut config = test_config(); |
| 486 | config.max_failures = 3; |
| 487 | config.lockout = Duration::from_secs(60); |
| 488 | let server = start(config).await; |
| 489 | |
| 490 | for _ in 0..3 { |
| 491 | let _ = connect(&server, Some(UNPAIRED), None, "").await; |
| 492 | } |
| 493 | |
| 494 | // Even a legitimate client is now refused, with 429 rather than 403. |
| 495 | let err = connect(&server, Some(PAIRED), None, "") |
| 496 | .await |
| 497 | .expect_err("must be locked out"); |
| 498 | assert!( |
| 499 | rejected_with(&err, StatusCode::TOO_MANY_REQUESTS), |
| 500 | "expected 429, got {err:?}" |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | /// A plain HTTP request is not an auth failure, and must not be reported as |
| 505 | /// one — that sends you debugging tokens when the problem is the connection. |
| 506 | #[tokio::test] |
| 507 | async fn plain_http_request_is_reported_as_not_a_websocket() { |
| 508 | use tokio::io::AsyncWriteExt; |
| 509 | let mut server = start(test_config()).await; |
| 510 | |
| 511 | let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap(); |
| 512 | sock.write_all( |
| 513 | format!( |
| 514 | "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nOrigin: {PAIRED}\r\n\r\n", |
| 515 | server.addr().port() |
| 516 | ) |
| 517 | .as_bytes(), |
| 518 | ) |
| 519 | .await |
| 520 | .unwrap(); |
| 521 | |
| 522 | let ev = tokio::time::timeout(Duration::from_secs(3), server.next_event()) |
| 523 | .await |
| 524 | .expect("expected a rejection event") |
| 525 | .unwrap(); |
| 526 | |
| 527 | match ev { |
| 528 | termbridge::Event::Rejected { why, detail, .. } => { |
| 529 | assert_eq!( |
| 530 | why, |
| 531 | Denied::NotAWebSocketUpgrade, |
| 532 | "a non-upgrade request must not be blamed on auth" |
| 533 | ); |
| 534 | assert!(detail.is_some(), "the real cause must be reported"); |
| 535 | } |
| 536 | other => panic!("expected Rejected, got {other:?}"), |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | /// A wss:// attempt against a daemon started *without* a TLS identity must be |
| 541 | /// named, not reported as a parse failure. (With TLS configured it is served |
| 542 | /// normally — see tests/tls.rs.) |
| 543 | #[tokio::test] |
| 544 | async fn tls_client_hello_is_named() { |
| 545 | use tokio::io::AsyncWriteExt; |
| 546 | let mut server = start(test_config()).await; |
| 547 | |
| 548 | let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap(); |
| 549 | // Opening bytes of a TLS 1.x ClientHello record. |
| 550 | sock.write_all(&[0x16, 0x03, 0x01, 0x00, 0x2f, 0x01]) |
| 551 | .await |
| 552 | .unwrap(); |
| 553 | |
| 554 | let ev = tokio::time::timeout(Duration::from_secs(3), server.next_event()) |
| 555 | .await |
| 556 | .expect("expected a rejection event") |
| 557 | .unwrap(); |
| 558 | |
| 559 | match ev { |
| 560 | termbridge::Event::Rejected { why, detail, .. } => { |
| 561 | assert_eq!(why, Denied::TlsAttempted); |
| 562 | assert!( |
| 563 | detail.unwrap().contains("without a TLS identity"), |
| 564 | "the message should say what to do about it" |
| 565 | ); |
| 566 | } |
| 567 | other => panic!("expected Rejected, got {other:?}"), |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn denied_reasons_are_stable() { |
| 573 | assert_eq!(Denied::UnpairedOrigin.status(), 403); |
| 574 | assert_eq!(Denied::RateLimited.status(), 429); |
| 575 | } |
| 576 | |
| 577 | // --------------------------------------------------------------------------- |
| 578 | |
| 579 | /// Discover this host's outbound interface address without sending packets. |
| 580 | fn outbound_interface_ip() -> Option<IpAddr> { |
| 581 | let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; |
| 582 | sock.connect("192.0.2.1:9").ok()?; // TEST-NET-1, never routed |
| 583 | let ip = sock.local_addr().ok()?.ip(); |
| 584 | (!ip.is_loopback() && !ip.is_unspecified()).then_some(ip) |
| 585 | } |