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