diff --git a/matrix/src/main.rs b/matrix/src/main.rs index 6eaf7aeb..b9adae5f 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -131,6 +131,19 @@ fn log_state_update(state: &BridgeState) { info!("Updated state: {:?}", state); } +/// Emit a sanitized config log without sensitive tokens. +#[cfg(not(test))] +fn log_config(cfg: &Config) { + info!( + potatomesh_base_url = cfg.potatomesh.base_url.as_str(), + matrix_homeserver = cfg.matrix.homeserver.as_str(), + matrix_server_name = cfg.matrix.server_name.as_str(), + matrix_room_id = cfg.matrix.room_id.as_str(), + state_file = cfg.state.state_file.as_str(), + "Loaded config" + ); +} + async fn poll_once( potato: &PotatoClient, matrix: &MatrixAppserviceClient, @@ -195,7 +208,7 @@ async fn main() -> Result<()> { .init(); let cfg = Config::from_default_path()?; - info!("Loaded config: {:?}", cfg); + log_config(&cfg); let http = reqwest::Client::builder().build()?; let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone()); @@ -740,7 +753,8 @@ mod tests { let mock_register = server .mock("POST", "/_matrix/client/v3/register") - .match_query("kind=user&access_token=AS_TOKEN") + .match_query("kind=user") + .match_header("authorization", "Bearer AS_TOKEN") .with_status(200) .create(); @@ -749,7 +763,8 @@ mod tests { "POST", format!("/_matrix/client/v3/rooms/{}/join", encoded_room).as_str(), ) - .match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str()) + .match_query(format!("user_id={}", encoded_user).as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .with_status(200) .create(); @@ -758,7 +773,8 @@ mod tests { "PUT", format!("/_matrix/client/v3/profile/{}/displayname", encoded_user).as_str(), ) - .match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str()) + .match_query(format!("user_id={}", encoded_user).as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .match_body(mockito::Matcher::PartialJson(serde_json::json!({ "displayname": "Test Node (TN)" }))) @@ -780,7 +796,8 @@ mod tests { ) .as_str(), ) - .match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str()) + .match_query(format!("user_id={}", encoded_user).as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .match_body(mockito::Matcher::PartialJson(serde_json::json!({ "msgtype": "m.text", "body": "`[868][MF][TEST]` Ping", diff --git a/matrix/src/matrix.rs b/matrix/src/matrix.rs index b5bccb92..c52716a9 100644 --- a/matrix/src/matrix.rs +++ b/matrix/src/matrix.rs @@ -66,10 +66,6 @@ impl MatrixAppserviceClient { format!("@{}:{}", localpart, self.cfg.server_name) } - fn auth_query(&self) -> String { - format!("access_token={}", urlencoding::encode(&self.cfg.as_token)) - } - /// Ensure the puppet user exists (register via appservice registration). pub async fn ensure_user_registered(&self, localpart: &str) -> anyhow::Result<()> { #[derive(Serialize)] @@ -80,9 +76,8 @@ impl MatrixAppserviceClient { } let url = format!( - "{}/_matrix/client/v3/register?kind=user&{}", - self.cfg.homeserver, - self.auth_query() + "{}/_matrix/client/v3/register?kind=user", + self.cfg.homeserver ); let body = RegisterReq { @@ -90,7 +85,13 @@ impl MatrixAppserviceClient { username: localpart, }; - let resp = self.http.post(&url).json(&body).send().await?; + let resp = self + .http + .post(&url) + .bearer_auth(&self.cfg.as_token) + .json(&body) + .send() + .await?; if resp.status().is_success() { Ok(()) } else { @@ -109,18 +110,21 @@ impl MatrixAppserviceClient { let encoded_user = urlencoding::encode(user_id); let url = format!( - "{}/_matrix/client/v3/profile/{}/displayname?user_id={}&{}", - self.cfg.homeserver, - encoded_user, - encoded_user, - self.auth_query() + "{}/_matrix/client/v3/profile/{}/displayname?user_id={}", + self.cfg.homeserver, encoded_user, encoded_user ); let body = DisplayNameReq { displayname: display_name, }; - let resp = self.http.put(&url).json(&body).send().await?; + let resp = self + .http + .put(&url) + .bearer_auth(&self.cfg.as_token) + .json(&body) + .send() + .await?; if resp.status().is_success() { Ok(()) } else { @@ -142,14 +146,17 @@ impl MatrixAppserviceClient { let encoded_room = urlencoding::encode(&self.cfg.room_id); let encoded_user = urlencoding::encode(user_id); let url = format!( - "{}/_matrix/client/v3/rooms/{}/join?user_id={}&{}", - self.cfg.homeserver, - encoded_room, - encoded_user, - self.auth_query() + "{}/_matrix/client/v3/rooms/{}/join?user_id={}", + self.cfg.homeserver, encoded_room, encoded_user ); - let resp = self.http.post(&url).json(&JoinReq {}).send().await?; + let resp = self + .http + .post(&url) + .bearer_auth(&self.cfg.as_token) + .json(&JoinReq {}) + .send() + .await?; if resp.status().is_success() { Ok(()) } else { @@ -185,12 +192,8 @@ impl MatrixAppserviceClient { let encoded_user = urlencoding::encode(user_id); let url = format!( - "{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}?user_id={}&{}", - self.cfg.homeserver, - encoded_room, - txn_id, - encoded_user, - self.auth_query() + "{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}?user_id={}", + self.cfg.homeserver, encoded_room, txn_id, encoded_user ); let content = MsgContent { @@ -200,7 +203,13 @@ impl MatrixAppserviceClient { formatted_body, }; - let resp = self.http.put(&url).json(&content).send().await?; + let resp = self + .http + .put(&url) + .bearer_auth(&self.cfg.as_token) + .json(&content) + .send() + .await?; if !resp.status().is_success() { let status = resp.status(); @@ -293,16 +302,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn auth_query_contains_access_token() { - let http = reqwest::Client::builder().build().unwrap(); - let client = MatrixAppserviceClient::new(http, dummy_cfg()); - - let q = client.auth_query(); - assert!(q.starts_with("access_token=")); - assert!(q.contains("AS_TOKEN")); - } - #[test] fn test_new_matrix_client() { let http_client = reqwest::Client::new(); @@ -318,7 +317,8 @@ mod tests { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/_matrix/client/v3/register") - .match_query("kind=user&access_token=AS_TOKEN") + .match_query("kind=user") + .match_header("authorization", "Bearer AS_TOKEN") .with_status(200) .create(); @@ -336,7 +336,8 @@ mod tests { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/_matrix/client/v3/register") - .match_query("kind=user&access_token=AS_TOKEN") + .match_query("kind=user") + .match_header("authorization", "Bearer AS_TOKEN") .with_status(400) // M_USER_IN_USE .create(); @@ -354,12 +355,13 @@ mod tests { let mut server = mockito::Server::new_async().await; let user_id = "@test:example.org"; let encoded_user = urlencoding::encode(user_id); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let query = format!("user_id={}", encoded_user); let path = format!("/_matrix/client/v3/profile/{}/displayname", encoded_user); let mock = server .mock("PUT", path.as_str()) .match_query(query.as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .with_status(200) .create(); @@ -377,12 +379,13 @@ mod tests { let mut server = mockito::Server::new_async().await; let user_id = "@test:example.org"; let encoded_user = urlencoding::encode(user_id); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let query = format!("user_id={}", encoded_user); let path = format!("/_matrix/client/v3/profile/{}/displayname", encoded_user); let mock = server .mock("PUT", path.as_str()) .match_query(query.as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .with_status(500) .create(); @@ -402,12 +405,13 @@ mod tests { let room_id = "!roomid:example.org"; let encoded_user = urlencoding::encode(user_id); let encoded_room = urlencoding::encode(room_id); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let query = format!("user_id={}", encoded_user); let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room); let mock = server .mock("POST", path.as_str()) .match_query(query.as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .with_status(200) .create(); @@ -428,12 +432,13 @@ mod tests { let room_id = "!roomid:example.org"; let encoded_user = urlencoding::encode(user_id); let encoded_room = urlencoding::encode(room_id); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let query = format!("user_id={}", encoded_user); let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room); let mock = server .mock("POST", path.as_str()) .match_query(query.as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .with_status(403) .create(); @@ -462,7 +467,7 @@ mod tests { MatrixAppserviceClient::new(reqwest::Client::new(), cfg) }; let txn_id = client.txn_counter.load(Ordering::SeqCst); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let query = format!("user_id={}", encoded_user); let path = format!( "/_matrix/client/v3/rooms/{}/send/m.room.message/{}", encoded_room, txn_id @@ -471,6 +476,7 @@ mod tests { let mock = server .mock("PUT", path.as_str()) .match_query(query.as_str()) + .match_header("authorization", "Bearer AS_TOKEN") .match_body(mockito::Matcher::PartialJson(serde_json::json!({ "msgtype": "m.text", "body": "`[meta]` hello", diff --git a/matrix/src/matrix_server.rs b/matrix/src/matrix_server.rs index 034c818e..de77c6d3 100644 --- a/matrix/src/matrix_server.rs +++ b/matrix/src/matrix_server.rs @@ -14,7 +14,7 @@ use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::IntoResponse, routing::put, Json, Router, @@ -33,6 +33,42 @@ struct AuthQuery { access_token: Option, } +/// Pull access tokens from supported auth headers. +fn extract_access_token(headers: &HeaderMap) -> Option { + if let Some(value) = headers.get(AUTHORIZATION) { + if let Ok(raw) = value.to_str() { + if let Some(token) = raw.strip_prefix("Bearer ") { + return Some(token.trim().to_string()); + } + if let Some(token) = raw.strip_prefix("bearer ") { + return Some(token.trim().to_string()); + } + } + } + if let Some(value) = headers.get("x-access-token") { + if let Ok(raw) = value.to_str() { + return Some(raw.trim().to_string()); + } + } + None +} + +/// Compare tokens in constant time to avoid timing leakage. +fn constant_time_eq(a: &str, b: &str) -> bool { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let max_len = std::cmp::max(a_bytes.len(), b_bytes.len()); + let mut diff = (a_bytes.len() ^ b_bytes.len()) as u8; + + for idx in 0..max_len { + let left = *a_bytes.get(idx).unwrap_or(&0); + let right = *b_bytes.get(idx).unwrap_or(&0); + diff |= left ^ right; + } + + diff == 0 +} + /// Captures inbound Synapse transaction payloads for logging. #[derive(Debug)] struct SynapseResponse { @@ -55,12 +91,17 @@ async fn handle_transaction( Path(txn_id): Path, State(state): State, Query(auth): Query, + headers: HeaderMap, Json(payload): Json, ) -> impl IntoResponse { - let token_matches = auth - .access_token - .as_ref() - .is_some_and(|token| token == &state.hs_token); + let header_token = extract_access_token(&headers); + let token_matches = if let Some(token) = header_token.as_deref() { + constant_time_eq(token, &state.hs_token) + } else { + auth.access_token + .as_deref() + .is_some_and(|token| constant_time_eq(token, &state.hs_token)) + }; if !token_matches { return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({}))); } @@ -103,7 +144,8 @@ mod tests { .oneshot( Request::builder() .method("PUT") - .uri("/_matrix/appservice/v1/transactions/123?access_token=HS_TOKEN") + .uri("/_matrix/appservice/v1/transactions/123") + .header("authorization", "Bearer HS_TOKEN") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), @@ -161,7 +203,8 @@ mod tests { .oneshot( Request::builder() .method("PUT") - .uri("/_matrix/appservice/v1/transactions/123?access_token=NOPE") + .uri("/_matrix/appservice/v1/transactions/123") + .header("authorization", "Bearer NOPE") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), @@ -176,6 +219,57 @@ mod tests { assert_eq!(body.as_ref(), b"{}"); } + #[tokio::test] + async fn transactions_endpoint_accepts_legacy_query_token() { + let app = build_router(SynapseState { + hs_token: "HS_TOKEN".to_string(), + }); + let payload = serde_json::json!({ + "events": [], + "txn_id": "125" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/_matrix/appservice/v1/transactions/125?access_token=HS_TOKEN") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn transactions_endpoint_accepts_x_access_token_header() { + let app = build_router(SynapseState { + hs_token: "HS_TOKEN".to_string(), + }); + let payload = serde_json::json!({ + "events": [], + "txn_id": "126" + }); + + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/_matrix/appservice/v1/transactions/126") + .header("x-access-token", "HS_TOKEN") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + #[tokio::test] async fn run_synapse_listener_starts_and_can_abort() { let addr = SocketAddr::from(([127, 0, 0, 1], 0));