browsers.fail
← firefox

bugzilla #2077834

Native MS Graph client sends OAuth2 token to server-supplied @odata.nextLink URL without host validation (token leak + SSRF)

firefox low other RESOLVED / FIXED

description

Thunderbird's native Microsoft Graph client (rust/ms_graph_tb + rust/graph_xpcom,
used by the native Exchange/Office365 account backend) follows the server-supplied
OData pagination link (@odata.nextLink / @odata.deltaLink) and attaches the
account's OAuth2 Bearer access token to the request, WITHOUT validating that the
link's host is the configured Graph endpoint (graph.microsoft.com). This violates
OAuth 2.0 Security BCP (access tokens must only be sent to the resource server) and
enables (a) exfiltration of the Graph access token to an arbitrary host and (b)
SSRF from the client to arbitrary (including internal) hosts.

== Code path (all live, reached on every paginated sync) ==

A paginated/delta Graph response is deserialized into a NextPage whose URI is taken
verbatim from the server-controlled @odata.nextLink:

  rust/ms_graph_tb/src/pagination.rs
    struct NextPageWire { #[serde(rename="@odata.nextLink")] next_link: String }
    impl TryFrom<NextPageWire> for NextPage<R> {
        next_uri: value.next_link.try_into()?   // parse server string -> http::Uri, no host check
    }
    impl Operation for NextPage<R> {
      fn build_request(self) -> ... {
        http::Request::builder().uri(&self.next_uri).method(GET)   // full server URL, "should not be modified"
      }
    }

The sync operations fetch the next page using this request:

  rust/graph_xpcom/src/client/sync_messages_for_folder.rs:167
  rust/graph_xpcom/src/client/sync_calendar_items.rs:103
  rust/graph_xpcom/src/client/sync_folder_hierarchy.rs:112
    DeltaResponse::NextLink { next_page, .. } =>
        self....send_request_json_response(next_page, Default::default())

And the shared send path attaches the OAuth2 Authorization header to whatever URL
the request carries, with no host validation:

  rust/protocol_shared/src/operation_sender/send_request.rs
    let url = Url::parse(&request.uri().to_string())?;          // = server nextLink
    let mut request_builder = client.request(method, &url)?;
    ...
    let auth_hdr_value = server.auth_header_value().await?;     // OAuth2 Bearer
    if let Some(ref hdr_value) = auth_hdr_value {
        request_builder = request_builder.header("Authorization", hdr_value);
    }
    request_builder.send().await?

There is no same-host / same-origin check on next_uri anywhere in ms_graph_tb,
graph_xpcom, protocol_shared, or moz_http before the token is attached and the
request is sent.

== Impact ==

A Graph response whose @odata.nextLink points to a non-Graph host causes Thunderbird
to:
- send the user's Graph OAuth2 access token (full mailbox scope) to that host, and
- issue an HTTP GET to an attacker-chosen host, including internal/loopback/cloud-
  metadata addresses the attacker cannot otherwise reach (SSRF).

== Threat model (stated honestly) ==

The nextLink arrives in the Graph HTTPS response, so directly injecting it requires
one of: a man-in-the-middle on the Graph connection (e.g. a TLS-intercepting
corporate proxy, which is common in the enterprise environments where Office365 is
used), a compromised/misconfigured Graph endpoint, or a Graph base URL steered to an
attacker host during account setup. A full MITM also observes the token on
legitimate requests; however this report is about the missing control itself, which
is a defense-in-depth requirement (OAuth BCP): the token must never be sent off the
resource origin, and the client must not be usable as an SSRF primitive to reach
hosts the network attacker cannot otherwise address. The code is new (native Graph
backend) and not yet hardened for this.

== Suggested fix ==

Before building/sending a NextPage (and before attaching the Authorization header),
validate that next_uri's scheme is https and its host matches the configured Graph
base URL (graph.microsoft.com / the account's configured endpoint). Reject
otherwise. Microsoft's own Graph SDKs and OData clients validate nextLink host for
this reason. Apply the same to @odata.deltaLink.