| 1 | use std::{ |
| 2 | net::TcpListener, |
| 3 | path::PathBuf, |
| 4 | process::{Child, Command, Stdio}, |
| 5 | sync::atomic::{AtomicBool, Ordering}, |
| 6 | time::Duration, |
| 7 | }; |
| 8 | |
| 9 | use matrix_sdk::test_utils::mocks::MatrixMockServer; |
| 10 | use reqwest::{Client, StatusCode, header}; |
| 11 | use serde_json::{Value, json}; |
| 12 | use tempfile::TempDir; |
| 13 | use wiremock::{ |
| 14 | Mock, ResponseTemplate, |
| 15 | matchers::{method, path_regex}, |
| 16 | }; |
| 17 | |
| 18 | struct Server(Child); |
| 19 | |
| 20 | impl Drop for Server { |
| 21 | fn drop(&mut self) { |
| 22 | let _ = self.0.kill(); |
| 23 | let _ = self.0.wait(); |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | fn server_binary() -> PathBuf { |
| 28 | option_env!("CARGO_BIN_EXE_malice") |
| 29 | .map(PathBuf::from) |
| 30 | .unwrap_or_else(|| { |
| 31 | std::env::current_exe() |
| 32 | .expect("integration test executable") |
| 33 | .parent() |
| 34 | .expect("cargo deps directory") |
| 35 | .parent() |
| 36 | .expect("cargo target directory") |
| 37 | .join("malice") |
| 38 | }) |
| 39 | } |
| 40 | |
| 41 | async fn start(port: u16, data_dir: &TempDir, client: &Client) -> Server { |
| 42 | let child = Command::new(server_binary()) |
| 43 | .env("MALICE_BIND", format!("127.0.0.1:{port}")) |
| 44 | .env("MALICE_DATA_DIR", data_dir.path()) |
| 45 | .env("MALICE_SESSION_COOKIE_SECURE", "false") |
| 46 | .env("MALICE_ALLOW_HTTP_HOMESERVER", "true") |
| 47 | .stdout(Stdio::null()) |
| 48 | .stderr(Stdio::inherit()) |
| 49 | .spawn() |
| 50 | .expect("start Malice backend"); |
| 51 | let mut server = Server(child); |
| 52 | tokio::time::timeout(Duration::from_secs(30), async { |
| 53 | loop { |
| 54 | if let Some(status) = server.0.try_wait().expect("poll backend") { |
| 55 | panic!("Malice exited before health check: {status}"); |
| 56 | } |
| 57 | if client |
| 58 | .get(format!("http://127.0.0.1:{port}/api/health")) |
| 59 | .send() |
| 60 | .await |
| 61 | .is_ok_and(|response| response.status().is_success()) |
| 62 | { |
| 63 | break; |
| 64 | } |
| 65 | tokio::time::sleep(Duration::from_millis(100)).await; |
| 66 | } |
| 67 | }) |
| 68 | .await |
| 69 | .expect("Malice did not start"); |
| 70 | server |
| 71 | } |
| 72 | |
| 73 | fn unused_port() -> u16 { |
| 74 | TcpListener::bind("127.0.0.1:0") |
| 75 | .expect("bind ephemeral port") |
| 76 | .local_addr() |
| 77 | .expect("read ephemeral port") |
| 78 | .port() |
| 79 | } |
| 80 | |
| 81 | #[tokio::test] |
| 82 | async fn login_isolated_cookie_survives_restart_and_logout_revokes_it() { |
| 83 | let matrix = MatrixMockServer::new().await; |
| 84 | matrix.mock_versions().ok().mount().await; |
| 85 | matrix.mock_login().ok().expect(1).mount().await; |
| 86 | let first_room = AtomicBool::new(false); |
| 87 | Mock::given(method("POST")) |
| 88 | .and(path_regex("/sync$")) |
| 89 | .respond_with(move |request: &wiremock::Request| { |
| 90 | let body: Value = serde_json::from_slice(&request.body).unwrap(); |
| 91 | let rooms = |
| 92 | if body["conn_id"] == "room-list" && !first_room.swap(true, Ordering::Relaxed) { |
| 93 | json!({"!malice:localhost": { |
| 94 | "initial": true, |
| 95 | "name": "Malice test room", |
| 96 | "joined_count": 1, |
| 97 | "required_state": [{ |
| 98 | "type": "m.room.member", |
| 99 | "state_key": "@cheeky_monkey:matrix.org", |
| 100 | "sender": "@cheeky_monkey:matrix.org", |
| 101 | "event_id": "$join:localhost", |
| 102 | "origin_server_ts": 1, |
| 103 | "content": {"membership": "join"} |
| 104 | }], |
| 105 | "timeline": [{ |
| 106 | "type": "m.room.message", |
| 107 | "sender": "@cheeky_monkey:matrix.org", |
| 108 | "event_id": "$recent:localhost", |
| 109 | "origin_server_ts": 2, |
| 110 | "content": {"msgtype":"m.text", "body":"recent channel message"} |
| 111 | },{ |
| 112 | "type": "m.room.message", |
| 113 | "sender": "@cheeky_monkey:matrix.org", |
| 114 | "event_id": "$image:localhost", |
| 115 | "origin_server_ts": 3, |
| 116 | "content": { |
| 117 | "msgtype": "m.image", |
| 118 | "body": "image.png", |
| 119 | "url": "mxc://localhost/avatar", |
| 120 | "info": {"mimetype": "image/png", "size": 13} |
| 121 | } |
| 122 | }], |
| 123 | "prev_batch": "older-page" |
| 124 | }}) |
| 125 | } else { |
| 126 | json!({}) |
| 127 | }; |
| 128 | ResponseTemplate::new(200) |
| 129 | .set_body_json(json!({"pos":"1","lists":{},"rooms":rooms,"extensions":{}})) |
| 130 | .set_delay(Duration::from_millis(100)) |
| 131 | }) |
| 132 | .mount(matrix.server()) |
| 133 | .await; |
| 134 | Mock::given(method("GET")) |
| 135 | .and(path_regex("/rooms/.*/messages$")) |
| 136 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ |
| 137 | "start": "older-page", |
| 138 | "chunk": [{ |
| 139 | "type": "m.room.message", |
| 140 | "room_id": "!malice:localhost", |
| 141 | "sender": "@cheeky_monkey:matrix.org", |
| 142 | "event_id": "$older:localhost", |
| 143 | "origin_server_ts": 1, |
| 144 | "content": {"msgtype":"m.text", "body":"older channel message"} |
| 145 | }] |
| 146 | }))) |
| 147 | .mount(matrix.server()) |
| 148 | .await; |
| 149 | matrix |
| 150 | .mock_room_state_encryption() |
| 151 | .expect_access_token("abc123") |
| 152 | .plain() |
| 153 | .mount() |
| 154 | .await; |
| 155 | matrix |
| 156 | .mock_room_send() |
| 157 | .expect_access_token("abc123") |
| 158 | .body_matches_partial_json(json!({"body":"backend room smoke"})) |
| 159 | .ok(matrix_sdk::ruma::event_id!("$sent:localhost")) |
| 160 | .expect(1) |
| 161 | .mount() |
| 162 | .await; |
| 163 | |
| 164 | Mock::given(method("GET")) |
| 165 | .and(path_regex("/download/[^/]+/avatar$")) |
| 166 | .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\nimage".to_vec())) |
| 167 | .mount(matrix.server()) |
| 168 | .await; |
| 169 | Mock::given(method("GET")) |
| 170 | .and(path_regex("/download/[^/]+/svg$")) |
| 171 | .respond_with( |
| 172 | ResponseTemplate::new(200).set_body_bytes(b"<svg onload='alert(1)'/>".to_vec()), |
| 173 | ) |
| 174 | .mount(matrix.server()) |
| 175 | .await; |
| 176 | |
| 177 | let data_dir = tempfile::tempdir().expect("temporary user stores"); |
| 178 | let client = Client::new(); |
| 179 | let port = unused_port(); |
| 180 | let base = format!("http://127.0.0.1:{port}"); |
| 181 | let server = start(port, &data_dir, &client).await; |
| 182 | |
| 183 | let guest: Value = client |
| 184 | .get(format!("{base}/api/session")) |
| 185 | .send() |
| 186 | .await |
| 187 | .unwrap() |
| 188 | .json() |
| 189 | .await |
| 190 | .unwrap(); |
| 191 | assert_eq!(guest["user_id"], Value::Null); |
| 192 | assert_eq!( |
| 193 | client |
| 194 | .get(format!("{base}/api/rooms")) |
| 195 | .send() |
| 196 | .await |
| 197 | .unwrap() |
| 198 | .status(), |
| 199 | StatusCode::UNAUTHORIZED |
| 200 | ); |
| 201 | |
| 202 | let cross_origin = client |
| 203 | .post(format!("{base}/api/login")) |
| 204 | .header(header::ORIGIN, "https://attacker.example") |
| 205 | .json(&json!({ |
| 206 | "homeserver": matrix.uri(), |
| 207 | "username": "alice", |
| 208 | "password": "test-password" |
| 209 | })) |
| 210 | .send() |
| 211 | .await |
| 212 | .unwrap(); |
| 213 | assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN); |
| 214 | |
| 215 | let cross_port = client |
| 216 | .post(format!("{base}/api/login")) |
| 217 | .header(header::ORIGIN, format!("http://127.0.0.1:{}", port + 1)) |
| 218 | .json(&json!({ |
| 219 | "homeserver": matrix.uri(), |
| 220 | "username": "alice", |
| 221 | "password": "test-password" |
| 222 | })) |
| 223 | .send() |
| 224 | .await |
| 225 | .unwrap(); |
| 226 | assert_eq!(cross_port.status(), StatusCode::FORBIDDEN); |
| 227 | |
| 228 | let login = client |
| 229 | .post(format!("{base}/api/login")) |
| 230 | .header(header::ORIGIN, &base) |
| 231 | .json(&json!({ |
| 232 | "homeserver": matrix.uri(), |
| 233 | "username": "alice", |
| 234 | "password": "test-password" |
| 235 | })) |
| 236 | .send() |
| 237 | .await |
| 238 | .unwrap(); |
| 239 | assert!( |
| 240 | login.status().is_success(), |
| 241 | "login returned {}", |
| 242 | login.status() |
| 243 | ); |
| 244 | let cookie = login |
| 245 | .headers() |
| 246 | .get(header::SET_COOKIE) |
| 247 | .expect("opaque session cookie") |
| 248 | .to_str() |
| 249 | .unwrap() |
| 250 | .split(';') |
| 251 | .next() |
| 252 | .unwrap() |
| 253 | .to_owned(); |
| 254 | let logged_in: Value = login.json().await.unwrap(); |
| 255 | assert!(logged_in["user_id"].as_str().is_some()); |
| 256 | #[cfg(unix)] |
| 257 | { |
| 258 | use std::os::unix::fs::PermissionsExt; |
| 259 | let sid = cookie.split_once('=').unwrap().1; |
| 260 | let mode = |path: std::path::PathBuf| { |
| 261 | std::fs::metadata(path).unwrap().permissions().mode() & 0o777 |
| 262 | }; |
| 263 | assert_eq!(mode(data_dir.path().join("master.key")), 0o600); |
| 264 | assert_eq!(mode(data_dir.path().join("sessions")), 0o700); |
| 265 | assert_eq!( |
| 266 | mode(data_dir.path().join("sessions").join(format!("{sid}.bin"))), |
| 267 | 0o600 |
| 268 | ); |
| 269 | } |
| 270 | assert_eq!( |
| 271 | client |
| 272 | .get(format!("{base}/api/rooms")) |
| 273 | .send() |
| 274 | .await |
| 275 | .unwrap() |
| 276 | .status(), |
| 277 | StatusCode::UNAUTHORIZED, |
| 278 | "one browser must not inherit another browser's Matrix session" |
| 279 | ); |
| 280 | assert_eq!( |
| 281 | client |
| 282 | .get(format!("{base}/api/media")) |
| 283 | .query(&[("mxc", "mxc://localhost/avatar")]) |
| 284 | .send() |
| 285 | .await |
| 286 | .unwrap() |
| 287 | .status(), |
| 288 | StatusCode::UNAUTHORIZED, |
| 289 | ); |
| 290 | assert_eq!( |
| 291 | client |
| 292 | .get(format!("{base}/api/media")) |
| 293 | .header(header::COOKIE, &cookie) |
| 294 | .query(&[("mxc", "not-a-mxc")]) |
| 295 | .send() |
| 296 | .await |
| 297 | .unwrap() |
| 298 | .status(), |
| 299 | StatusCode::BAD_REQUEST, |
| 300 | ); |
| 301 | let avatar = client |
| 302 | .get(format!("{base}/api/media")) |
| 303 | .header(header::COOKIE, &cookie) |
| 304 | .query(&[("mxc", "mxc://localhost/avatar")]) |
| 305 | .send() |
| 306 | .await |
| 307 | .unwrap(); |
| 308 | assert_eq!(avatar.status(), StatusCode::OK); |
| 309 | assert_eq!( |
| 310 | avatar.headers().get(header::CONTENT_TYPE).unwrap(), |
| 311 | "image/png" |
| 312 | ); |
| 313 | assert_eq!( |
| 314 | avatar.bytes().await.unwrap().as_ref(), |
| 315 | b"\x89PNG\r\n\x1a\nimage" |
| 316 | ); |
| 317 | assert_eq!( |
| 318 | client |
| 319 | .get(format!("{base}/api/media")) |
| 320 | .header(header::COOKIE, &cookie) |
| 321 | .query(&[("mxc", "mxc://localhost/svg")]) |
| 322 | .send() |
| 323 | .await |
| 324 | .unwrap() |
| 325 | .status(), |
| 326 | StatusCode::UNSUPPORTED_MEDIA_TYPE, |
| 327 | ); |
| 328 | let rooms = client |
| 329 | .get(format!("{base}/api/rooms")) |
| 330 | .header(header::COOKIE, &cookie) |
| 331 | .send() |
| 332 | .await |
| 333 | .unwrap(); |
| 334 | assert!(rooms.status().is_success()); |
| 335 | let rooms: Value = rooms.json().await.unwrap(); |
| 336 | assert!(rooms["rooms"].is_array()); |
| 337 | tokio::time::timeout(Duration::from_secs(5), async { |
| 338 | loop { |
| 339 | let response: Value = client |
| 340 | .get(format!("{base}/api/rooms")) |
| 341 | .header(header::COOKIE, &cookie) |
| 342 | .send() |
| 343 | .await |
| 344 | .unwrap() |
| 345 | .json() |
| 346 | .await |
| 347 | .unwrap(); |
| 348 | if response["rooms"] |
| 349 | .as_array() |
| 350 | .unwrap() |
| 351 | .iter() |
| 352 | .any(|room| room["room_id"] == "!malice:localhost") |
| 353 | { |
| 354 | break; |
| 355 | } |
| 356 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 357 | } |
| 358 | }) |
| 359 | .await |
| 360 | .expect("joined room appeared in live room list"); |
| 361 | let open = client |
| 362 | .post(format!("{base}/api/rooms/open")) |
| 363 | .header(header::ORIGIN, &base) |
| 364 | .header(header::COOKIE, &cookie) |
| 365 | .json(&json!({"room_id":"!malice:localhost"})) |
| 366 | .send() |
| 367 | .await |
| 368 | .unwrap(); |
| 369 | assert_eq!(open.status(), StatusCode::NO_CONTENT); |
| 370 | let media_id = tokio::time::timeout(Duration::from_secs(10), async { |
| 371 | loop { |
| 372 | let mut stream = client |
| 373 | .get(format!("{base}/api/events")) |
| 374 | .header(header::COOKIE, &cookie) |
| 375 | .send() |
| 376 | .await |
| 377 | .unwrap(); |
| 378 | let frame = stream.chunk().await.unwrap().expect("SSE snapshot"); |
| 379 | let text = std::str::from_utf8(&frame).unwrap(); |
| 380 | let payload = text |
| 381 | .lines() |
| 382 | .find_map(|line| line.strip_prefix("data: ")) |
| 383 | .unwrap(); |
| 384 | let snapshot: Value = serde_json::from_str(payload).unwrap(); |
| 385 | if let Some(id) = snapshot["timelines"]["!malice:localhost"] |
| 386 | .as_array() |
| 387 | .and_then(|items| { |
| 388 | items.iter().find_map(|item| { |
| 389 | item["Event"]["content"]["Message"]["media"]["id"].as_str() |
| 390 | }) |
| 391 | }) |
| 392 | { |
| 393 | break id.to_owned(); |
| 394 | } |
| 395 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 396 | } |
| 397 | }) |
| 398 | .await |
| 399 | .expect("opened room registered image attachment"); |
| 400 | assert_eq!( |
| 401 | client |
| 402 | .get(format!("{base}/api/message-media")) |
| 403 | .query(&[("media_id", media_id.as_str()), ("size", "Full")]) |
| 404 | .send() |
| 405 | .await |
| 406 | .unwrap() |
| 407 | .status(), |
| 408 | StatusCode::UNAUTHORIZED, |
| 409 | ); |
| 410 | let attachment = client |
| 411 | .get(format!("{base}/api/message-media")) |
| 412 | .header(header::COOKIE, &cookie) |
| 413 | .query(&[("media_id", media_id.as_str()), ("size", "Full")]) |
| 414 | .send() |
| 415 | .await |
| 416 | .unwrap(); |
| 417 | assert_eq!(attachment.status(), StatusCode::OK); |
| 418 | assert_eq!( |
| 419 | attachment.headers().get(header::CONTENT_TYPE).unwrap(), |
| 420 | "image/png" |
| 421 | ); |
| 422 | assert_eq!( |
| 423 | attachment.bytes().await.unwrap().as_ref(), |
| 424 | b"\x89PNG\r\n\x1a\nimage" |
| 425 | ); |
| 426 | let sent = client |
| 427 | .post(format!("{base}/api/rooms/send")) |
| 428 | .header(header::ORIGIN, &base) |
| 429 | .header(header::COOKIE, &cookie) |
| 430 | .json(&json!({"room_id":"!malice:localhost", "body":"backend room smoke"})) |
| 431 | .send() |
| 432 | .await |
| 433 | .unwrap(); |
| 434 | assert_eq!(sent.status(), StatusCode::NO_CONTENT); |
| 435 | tokio::time::timeout(Duration::from_secs(5), async { |
| 436 | loop { |
| 437 | if matrix |
| 438 | .received_requests() |
| 439 | .await |
| 440 | .unwrap() |
| 441 | .iter() |
| 442 | .any(|request| request.url.path().contains("/send/m.room.message/")) |
| 443 | { |
| 444 | break; |
| 445 | } |
| 446 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 447 | } |
| 448 | }) |
| 449 | .await |
| 450 | .expect("message reached Matrix send endpoint"); |
| 451 | tokio::time::timeout(Duration::from_secs(15), async { |
| 452 | loop { |
| 453 | let mut stream = client |
| 454 | .get(format!("{base}/api/events")) |
| 455 | .header(header::COOKIE, &cookie) |
| 456 | .send() |
| 457 | .await |
| 458 | .unwrap(); |
| 459 | let frame = stream.chunk().await.unwrap().expect("SSE snapshot"); |
| 460 | let text = std::str::from_utf8(&frame).unwrap(); |
| 461 | let payload = text |
| 462 | .lines() |
| 463 | .find_map(|line| line.strip_prefix("data: ")) |
| 464 | .unwrap(); |
| 465 | let snapshot: Value = serde_json::from_str(payload).unwrap(); |
| 466 | if snapshot["connection"] == "online" |
| 467 | && snapshot["timelines"]["!malice:localhost"] |
| 468 | .as_array() |
| 469 | .is_some_and(|items| { |
| 470 | items.iter().any(|item| { |
| 471 | item["Event"]["content"]["Message"]["body"] == "older channel message" |
| 472 | && item["Event"]["event_id"] == "$older:localhost" |
| 473 | }) && items.iter().any(|item| { |
| 474 | item["Event"]["content"]["Message"]["body"] == "backend room smoke" |
| 475 | && item["Event"]["event_id"] == "$sent:localhost" |
| 476 | && item["Event"]["send_state"] == "Sent" |
| 477 | }) |
| 478 | }) |
| 479 | { |
| 480 | break; |
| 481 | } |
| 482 | tokio::time::sleep(Duration::from_millis(50)).await; |
| 483 | } |
| 484 | }) |
| 485 | .await |
| 486 | .expect("server-confirmed message appeared in online timeline SSE snapshot"); |
| 487 | |
| 488 | drop(server); |
| 489 | let _restored = start(port, &data_dir, &client).await; |
| 490 | let session: Value = client |
| 491 | .get(format!("{base}/api/session")) |
| 492 | .header(header::COOKIE, &cookie) |
| 493 | .send() |
| 494 | .await |
| 495 | .unwrap() |
| 496 | .json() |
| 497 | .await |
| 498 | .unwrap(); |
| 499 | assert_eq!(session["user_id"], logged_in["user_id"]); |
| 500 | let mut events = client |
| 501 | .get(format!("{base}/api/events")) |
| 502 | .header(header::COOKIE, &cookie) |
| 503 | .send() |
| 504 | .await |
| 505 | .unwrap(); |
| 506 | assert_eq!(events.status(), StatusCode::OK); |
| 507 | let initial = events.chunk().await.unwrap().expect("initial SSE frame"); |
| 508 | assert!(String::from_utf8_lossy(&initial).contains("\"type\":\"snapshot\"")); |
| 509 | |
| 510 | matrix |
| 511 | .mock_logout() |
| 512 | .expect_access_token("abc123") |
| 513 | .ok() |
| 514 | .expect(1) |
| 515 | .mount() |
| 516 | .await; |
| 517 | let logout = client |
| 518 | .post(format!("{base}/api/logout")) |
| 519 | .header(header::ORIGIN, &base) |
| 520 | .header(header::COOKIE, &cookie) |
| 521 | .send() |
| 522 | .await |
| 523 | .unwrap(); |
| 524 | assert_eq!(logout.status(), StatusCode::NO_CONTENT); |
| 525 | tokio::time::timeout(Duration::from_secs(2), async { |
| 526 | while events.chunk().await.unwrap().is_some() {} |
| 527 | }) |
| 528 | .await |
| 529 | .expect("logout must close the authenticated event stream"); |
| 530 | let after_logout: Value = client |
| 531 | .get(format!("{base}/api/session")) |
| 532 | .header(header::COOKIE, &cookie) |
| 533 | .send() |
| 534 | .await |
| 535 | .unwrap() |
| 536 | .json() |
| 537 | .await |
| 538 | .unwrap(); |
| 539 | assert_eq!(after_logout["user_id"], Value::Null); |
| 540 | } |