From aaa47139aae3a6dc42c063a6b59cc21202dfe5dd Mon Sep 17 00:00:00 2001 From: JR Conlin Date: Wed, 4 Oct 2023 10:19:57 -0700 Subject: [PATCH] feat: Reject Legacy GCM endpoints (#459) * feat: Reject Legacy GCM endpoints This will force GCM endpoints to return 410, and remove the subscription from the user's record. Closes: SYNC-3946 --- autoendpoint/src/extractors/user.rs | 19 +++++++ autoendpoint/src/routers/fcm/client.rs | 23 +------- autoendpoint/src/routers/fcm/error.rs | 13 ++--- autoendpoint/src/routers/fcm/router.rs | 79 ++------------------------ 4 files changed, 31 insertions(+), 103 deletions(-) diff --git a/autoendpoint/src/extractors/user.rs b/autoendpoint/src/extractors/user.rs index 23ce6f155..dc5301bea 100644 --- a/autoendpoint/src/extractors/user.rs +++ b/autoendpoint/src/extractors/user.rs @@ -3,6 +3,7 @@ use crate::error::{ApiErrorKind, ApiResult}; use crate::extractors::routers::RouterType; use crate::server::AppState; +use actix_http::StatusCode; use autopush_common::db::{client::DbClient, User}; use cadence::{CountedExt, StatsdClient}; use uuid::Uuid; @@ -27,6 +28,24 @@ pub async fn validate_user( } }; + // Legacy GCM support was discontinued by Google in Sept 2023. + // Since we do not have access to the account that originally created the GCM project + // and credentials, we cannot move those users to modern FCM implementations, so we + // must drop them. + if router_type == RouterType::GCM { + debug!("Encountered GCM record, dropping user"; "user" => ?user); + // record the bridge error for accounting reasons. + app_state + .metrics + .incr_with_tags("notification.bridge.error") + .with_tag("platform", "gcm") + .with_tag("reason", "gcm_kill") + .with_tag("error", &StatusCode::GONE.to_string()) + .send(); + drop_user(user.uaid, app_state.db.as_ref(), &app_state.metrics).await?; + return Err(ApiErrorKind::Router(crate::routers::RouterError::NotFound).into()); + } + if router_type == RouterType::WebPush { validate_webpush_user(user, channel_id, app_state.db.as_ref(), &app_state.metrics).await?; } diff --git a/autoendpoint/src/routers/fcm/client.rs b/autoendpoint/src/routers/fcm/client.rs index d96c24dc0..ca4f883f5 100644 --- a/autoendpoint/src/routers/fcm/client.rs +++ b/autoendpoint/src/routers/fcm/client.rs @@ -140,7 +140,7 @@ impl FcmClient { .map_err(FcmError::DeserializeResponse)?; if raw_data.is_empty() { warn!("Empty FCM response [{status}]"); - return Err(FcmError::EmptyResponse(status, self.is_gcm).into()); + return Err(FcmError::EmptyResponse(status).into()); } let data: FcmResponse = serde_json::from_slice(&raw_data).map_err(|e| { let s = String::from_utf8(raw_data.to_vec()).unwrap_or_else(|e| e.to_string()); @@ -150,25 +150,8 @@ impl FcmClient { // we only ever send one. return Err(match (status, data.error) { - (StatusCode::UNAUTHORIZED, _) => { - if self.is_gcm { - RouterError::GCMAuthentication - } else { - RouterError::Authentication - } - } - (StatusCode::NOT_FOUND, _) => { - // GCM ERROR - if self.is_gcm { - warn!("Converting GCM NOT FOUND to FCM SERVICE_UNAVAILABLE"); - RouterError::Upstream { - status: StatusCode::SERVICE_UNAVAILABLE.to_string(), - message: "FCM did not find GCM user".to_owned(), - } - } else { - RouterError::NotFound - } - } + (StatusCode::UNAUTHORIZED, _) => RouterError::Authentication, + (StatusCode::NOT_FOUND, _) => RouterError::NotFound, (_, Some(error)) => RouterError::Upstream { status: error.status, message: error.message, diff --git a/autoendpoint/src/routers/fcm/error.rs b/autoendpoint/src/routers/fcm/error.rs index 18b6eb789..6dcc09483 100644 --- a/autoendpoint/src/routers/fcm/error.rs +++ b/autoendpoint/src/routers/fcm/error.rs @@ -21,7 +21,7 @@ pub enum FcmError { InvalidResponse(#[source] serde_json::Error, String, StatusCode), #[error("Empty response from FCM")] - EmptyResponse(StatusCode, bool), + EmptyResponse(StatusCode), #[error("No OAuth token was present")] NoOAuthToken, @@ -50,7 +50,7 @@ impl FcmError { | FcmError::NoOAuthToken => StatusCode::INTERNAL_SERVER_ERROR, FcmError::DeserializeResponse(_) - | FcmError::EmptyResponse(_, _) + | FcmError::EmptyResponse(_) | FcmError::InvalidResponse(_, _, _) => StatusCode::BAD_GATEWAY, } } @@ -66,7 +66,7 @@ impl FcmError { | FcmError::OAuthClientBuild(_) | FcmError::OAuthToken(_) | FcmError::DeserializeResponse(_) - | FcmError::EmptyResponse(_, _) + | FcmError::EmptyResponse(_) | FcmError::InvalidResponse(_, _, _) | FcmError::NoOAuthToken => None, } @@ -74,11 +74,8 @@ impl FcmError { pub fn extras(&self) -> Vec<(&str, String)> { match self { - FcmError::EmptyResponse(status, is_gcm) => { - vec![ - ("status", status.to_string()), - ("is_gcm", is_gcm.to_string()), - ] + FcmError::EmptyResponse(status) => { + vec![("status", status.to_string())] } FcmError::InvalidResponse(_, body, status) => { vec![("status", status.to_string()), ("body", body.to_owned())] diff --git a/autoendpoint/src/routers/fcm/router.rs b/autoendpoint/src/routers/fcm/router.rs index 81b62bcbd..05bd560ce 100644 --- a/autoendpoint/src/routers/fcm/router.rs +++ b/autoendpoint/src/routers/fcm/router.rs @@ -154,45 +154,18 @@ impl Router for FcmRouter { .as_ref() .ok_or(FcmError::NoRegistrationToken)?; - // Older "GCM" set the router data as "senderID" : "auth" - // Newer "FCM" set the router data as "app_id": "token" - // The first element is the project identifier, which is - // matched against the values specified in the settings to - // get the authentication method. The second is the client - // provided routing token. This is a proprietary identifier - // that is sent by the client at registration. - // - // Try reading as FCM and fall back to GCM. let (routing_token, app_id) = self.routing_info(router_data, ¬ification.subscription.user.uaid)?; let ttl = MAX_TTL.min(self.settings.min_ttl.max(notification.headers.ttl as usize)); - let message_data = build_message_data(notification)?; // Send the notification to FCM - // (Sigh, errors do not have tags support. ) let client = self .clients .get(&app_id) .ok_or_else(|| FcmError::InvalidAppId(app_id.clone()))?; - // GCM is the older message format for android, and it's not possible to generate - // new test keys. As of 2023-09, GCM should use FCM OAuth2 in order to authenticate. - // FCM operates by using the more complex token as part of an OAuth2 - // transaction. According to the documentation, GCM and FCM are interoperable, - // meaning that the client provided tokens should match up, and that the structure - // of the data should also not make a huge difference. - // - // In Oct of 2023, "legacy" GCM support was deprecated. After some - // research, it was hypothesised that the current FCM project may be able - // to accept GCM routing identifiers as FCM message tokens. The current code presumes - // that this is a fragile relationship and so GCM errors do not immediately drop the - // remote user's endpoint (potentially causing a UA reset.) - // - let platform = if client.is_gcm { - "gcm_as_fcmv1" - } else { - "fcmv1" - }; + let message_data = build_message_data(notification)?; + let platform = "fcmv1"; trace!("Sending message to {platform}: [{:?}]", &app_id); if let Err(e) = client.send(message_data, routing_token, ttl).await { return Err(handle_error( @@ -261,7 +234,8 @@ mod tests { }, GCM_PROJECT_ID: { "project_id": GCM_PROJECT_ID, - "credential": gcm_credential + "credential": gcm_credential, + "is_gcm": true, } }) .to_string(), @@ -322,51 +296,6 @@ mod tests { fcm_mock.assert(); } - /// A notification with no data is sent to GCM if the subscription specifies it. - /* - #[tokio::test] - async fn successful_gcm_fallback() { - let auth_key = "AIzaSyB0ecSrqnEDXQ7yjLXqVc0CUGOeSlq9BsM"; // this is a nonce value used only for testing. - let registration_id = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - // let project_id = GCM_PROJECT_ID; - let db = MockDbClient::new().into_boxed_arc(); - let router = make_router(make_service_key(), auth_key.to_owned(), db).await; - assert!(router.active()); - // body must match the composed body in `gcm_send` exactly (order, values, etc.) - let body = serde_json::json!({ - "registration_ids": [registration_id], - "time_to_live": 60_i32, - "delay_while_idle": false, - "data": { - "chid": CHANNEL_ID - }, - }) - .to_string(); - let _token_mock = mock_token_endpoint(); - let fcm_mock = mock_gcm_endpoint_builder() - .match_header("Authorization", format!("key={}", &auth_key).as_str()) - .match_header("Content-Type", "application/json") - .with_body( - r#"{ "multicast_id": 216,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"1:02"}]}"#, - ) - .match_body(body.as_str()) - .create(); - let notification = make_notification( - gcm_router_data(registration_id.to_owned()), - None, - RouterType::GCM, - ); - - let result = router.route_notification(¬ification).await; - assert!(result.is_ok(), "result = {result:?}"); - assert_eq!( - result.unwrap(), - RouterResponse::success("http://localhost:8080/m/test-message-id".to_string(), 0) - ); - fcm_mock.assert(); - } - */ - /// A notification with data is sent to FCM #[tokio::test] async fn successful_routing_with_data() {