anvilsign in

collin/browser-terminal-extension

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
13use std::net::{IpAddr, SocketAddr};
14use std::time::Duration;
15
16use futures_util::{SinkExt, StreamExt};
17use tokio_tungstenite::tungstenite::client::IntoClientRequest;
18use tokio_tungstenite::tungstenite::http::StatusCode;
19use tokio_tungstenite::tungstenite::{Error as WsError, Message};
20use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
21
22use termbridge::auth::{self, Denied};
23use termbridge::{Config, Server, paths};
24
25const GOOD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
26const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
27const UNPAIRED: &str = "https://evil.example";
28
29type Client = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
30
31fn 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
39async 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.
45async 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
62fn 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.
67async 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]
91async 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]
103async 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]
129fn 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]
147async 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]
157async 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]
166async 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]
175async 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]
186async 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]
195fn 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]
222async 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]
240async 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 && t.contains("auth timeout")
248 {
249 return true;
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]
261async 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]
278async 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]
307fn 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]
321fn 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]
349fn 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]
367fn 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]
379async 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]
390fn 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]
401fn 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]
420fn 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]
440fn 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]
460async 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]
470async 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]
484async 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]
507async 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]
544async 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]
572fn 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.
580fn 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}