Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Compositing
DescriptionImproper input validation in Compositing
ComponentCompositing
Sub-componentInternals>Compositing
Bug ClassInput Validation
Tracker520573237
Fix commitf1b07e8cf05b (chromium/src) +367/-16
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
TEST
content/browser/renderer_host/render_widget_host_unittest.cc
modified
for
content/browser/renderer_host/render_widget_host_unittest.cc
modified

Files Changed

  • cc/input/browser_controls_offset_manager.cc
  • content/browser/renderer_host/render_widget_host_unittest.cc
  • cc/mojom/render_frame_metadata_mojom_traits.cc
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java
  • cc/input/browser_controls_offset_manager_unittest.cc
From f1b07e8cf05bdffded5b77a259d40d88fbdf74a5 Mon Sep 17 00:00:00 2001
From: Ahmed Raslan <[email protected]>
Date: Fri, 18 Sep 2026 09:06:03 -0700
Subject: [PATCH] Reland "Validate RenderFrameMetadata fields during Mojo deserialization"

This is a reland of commit 18f5043d95fb9ebc102007fd6c17b959f6cc3283.

Original change description:
> Validate RenderFrameMetadata fields during Mojo deserialization
>
> Add validation checks for floating-point values during Mojo
> deserialization of cc::BrowserControlsMetadata and
> cc::RenderFrameMetadata. This ensures that device and page scale
> factors are finite and positive, min and max page scale factor limits
> are finite and non-negative (allowing 0 to accommodate
> default-constructed instances), top/bottom control heights are finite
> and non-negative, and controls shown ratios fall within [0.0, 1.0].
> Also enlarge the failed check crash key buffer size from Size32 to
> Size64 to prevent truncation of longer diagnostic check names.
>
> Furthermore, update CustomTabBottomBarDelegate.java to ensure computed
> heights remain non-negative.

Reason for reland / fix for crbug.com/561281250: The previous CL was
reverted because height-change animations in
BrowserControlsOffsetManager (e.g. when moving the Omnibox between
bottom and top on Android) calculate an initial animation ratio of
`old_height / new_height`, which legitimately exceeds 1.0f during the
animation. When GetMetadata() emitted this unclamped ratio over Mojo,
the browser process rejected the message and terminated the renderer.

This reland fixes the issue by: 1. Clamping `top_controls_shown_ratio`
and `bottom_controls_shown_ratio` to
   [0.0, 1.0] in BrowserControlsOffsetManager::GetMetadata() before
   emitting metadata over IPC.
2. Clamping renderer offsets in BrowserControlsManager.java to ensure
   offsets stay within valid bounds [0, height] or [-height, 0].
3. Adding unit test coverage in
browser_controls_offset_manager_unittest.cc
   and render_widget_host_unittest.cc.

[email protected]

Bug: 561281250, 560600688, 560600688
Change-Id: I41b2ec44c735e27b618a25d60eefb260842c28e4
Fixed: 520573237
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8422301
Reviewed-by: Jinsuk Kim <[email protected]>
Reviewed-by: Ken Buchanan <[email protected]>
Reviewed-by: Jonathan Ross <[email protected]>
Reviewed-by: Andrew Paseltiner <[email protected]>
Auto-Submit: Ahmed Raslan <[email protected]>
Commit-Queue: Ken Buchanan <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1700830}
---
diff --git a/cc/input/browser_controls_offset_manager.cc b/cc/input/browser_controls_offset_manager.cc
index 79db515..5b6a1a6 100644
--- a/cc/input/browser_controls_offset_manager.cc
+++ b/cc/input/browser_controls_offset_manager.cc
@@ -183,10 +183,12 @@
 BrowserControlsMetadata BrowserControlsOffsetManager::GetMetadata() const {
   BrowserControlsMetadata metadata;
   metadata.top_controls_height = TopControlsHeight();
-  metadata.top_controls_shown_ratio = TopControlsShownRatio();
+  metadata.top_controls_shown_ratio =
+      std::clamp(TopControlsShownRatio(), 0.f, 1.f);
 #if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
   metadata.bottom_controls_height = BottomControlsHeight();
-  metadata.bottom_controls_shown_ratio = BottomControlsShownRatio();
+  metadata.bottom_controls_shown_ratio =
+      std::clamp(BottomControlsShownRatio(), 0.f, 1.f);
   metadata.top_controls_min_height_offset = TopControlsMinHeightOffset();
   metadata.bottom_controls_min_height_offset = BottomControlsMinHeightOffset();
   metadata.has_offset_tag =
diff --git a/cc/mojom/render_frame_metadata_mojom_traits.cc b/cc/mojom/render_frame_metadata_mojom_traits.cc
index 19a50dc..d2e6bc6 100644
--- a/cc/mojom/render_frame_metadata_mojom_traits.cc
+++ b/cc/mojom/render_frame_metadata_mojom_traits.cc
@@ -4,6 +4,7 @@
 
 #include "cc/mojom/render_frame_metadata_mojom_traits.h"
 
+#include <cmath>
 #include <string_view>
 
 #include "base/debug/crash_logging.h"
@@ -21,7 +22,7 @@
 
 void SetFailedCheckCrashKey(std::string_view check_name) {
   static auto* const crash_key = base::debug::AllocateCrashKeyString(
-      "rfm_failed_check", base::debug::CrashKeySize::Size32);
+      "rfm_failed_check", base::debug::CrashKeySize::Size64);
   base::debug::SetCrashKeyString(crash_key, check_name);
 }
 
@@ -41,15 +42,56 @@
                   cc::BrowserControlsMetadata>::
     Read(cc::mojom::BrowserControlsMetadataDataView data,
          cc::BrowserControlsMetadata* out) {
-  out->top_controls_height = data.top_controls_height();
-  out->top_controls_shown_ratio = data.top_controls_shown_ratio();
+  // Validate every field before touching `out`, so that a rejected message
+  // never leaves the output struct partially populated with untrusted values.
+  const float top_controls_height = data.top_controls_height();
+  if (!std::isfinite(top_controls_height) || top_controls_height < 0.f) {
+    SetFailedCheckCrashKey("top_controls_height");
+    return false;
+  }
+  const float top_controls_shown_ratio = data.top_controls_shown_ratio();
+  if (!std::isfinite(top_controls_shown_ratio) ||
+      top_controls_shown_ratio < 0.f || top_controls_shown_ratio > 1.f) {
+    SetFailedCheckCrashKey("top_controls_shown_ratio");
+    return false;
+  }
 #if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
-  out->bottom_controls_height = data.bottom_controls_height();
-  out->bottom_controls_shown_ratio = data.bottom_controls_shown_ratio();
-  out->top_controls_min_height_offset = data.top_controls_min_height_offset();
-  out->bottom_controls_min_height_offset =
+  const float bottom_controls_height = data.bottom_controls_height();
+  if (!std::isfinite(bottom_controls_height) || bottom_controls_height < 0.f) {
+    SetFailedCheckCrashKey("bottom_controls_height");
+    return false;
+  }
+  const float bottom_controls_shown_ratio = data.bottom_controls_shown_ratio();
+  if (!std::isfinite(bottom_controls_shown_ratio) ||
+      bottom_controls_shown_ratio < 0.f || bottom_controls_shown_ratio > 1.f) {
+    SetFailedCheckCrashKey("bottom_controls_shown_ratio");
+    return false;
+  }
+  const float top_controls_min_height_offset =
+      data.top_controls_min_height_offset();
+  if (!std::isfinite(top_controls_min_height_offset) ||
+      top_controls_min_height_offset < 0.f) {
+    SetFailedCheckCrashKey("top_controls_min_height_offset");
+    return false;
+  }
+  const float bottom_controls_min_height_offset =
       data.bottom_controls_min_height_offset();
+  if (!std::isfinite(bottom_controls_min_height_offset) ||
+      bottom_controls_min_height_offset < 0.f) {
+    SetFailedCheckCrashKey("bottom_controls_min_height_offset");
+    return false;
+  }
 #endif
+
+  out->top_controls_height = top_controls_height;
+  out->top_controls_shown_ratio = top_controls_shown_ratio;
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  out->bottom_controls_height = bottom_controls_height;
+  out->bottom_controls_shown_ratio = bottom_controls_shown_ratio;
+  out->top_controls_min_height_offset = top_controls_min_height_offset;
+  out->bottom_controls_min_height_offset = bottom_controls_min_height_offset;
+#endif
+
   return true;
 }
 
@@ -58,19 +100,53 @@
     cc::mojom::RenderFrameMetadataDataView,
     cc::RenderFrameMetadata>::Read(cc::mojom::RenderFrameMetadataDataView data,
                                    cc::RenderFrameMetadata* out) {
+  // Validate the scalar fields before touching `out`, so that a rejected
+  // message never leaves the output struct partially populated with untrusted
+  // values.
+  const float device_scale_factor = data.device_scale_factor();
+  if (!std::isfinite(device_scale_factor) || device_scale_factor <= 0.f) {
+    SetFailedCheckCrashKey("device_scale_factor");
+    return false;
+  }
+  const float page_scale_factor = data.page_scale_factor();
+  if (!std::isfinite(page_scale_factor) || page_scale_factor <= 0.f) {
+    SetFailedCheckCrashKey("page_scale_factor");
+    return false;
+  }
+  const float external_page_scale_factor = data.external_page_scale_factor();
+  if (!std::isfinite(external_page_scale_factor) ||
+      external_page_scale_factor <= 0.f) {
+    SetFailedCheckCrashKey("external_page_scale_factor");
+    return false;
+  }
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  const float min_page_scale_factor = data.min_page_scale_factor();
+  if (!std::isfinite(min_page_scale_factor) || min_page_scale_factor < 0.f) {
+    SetFailedCheckCrashKey("min_page_scale_factor");
+    return false;
+  }
+  const float max_page_scale_factor = data.max_page_scale_factor();
+  if (!std::isfinite(max_page_scale_factor) || max_page_scale_factor < 0.f ||
+      max_page_scale_factor < min_page_scale_factor) {
+    SetFailedCheckCrashKey("max_page_scale_factor");
+    return false;
+  }
+#endif
+
   out->is_scroll_offset_at_top = data.is_scroll_offset_at_top();
   out->is_mobile_optimized = data.is_mobile_optimized();
-  out->device_scale_factor = data.device_scale_factor();
-  out->page_scale_factor = data.page_scale_factor();
-  out->external_page_scale_factor = data.external_page_scale_factor();
+  out->device_scale_factor = device_scale_factor;
+  out->page_scale_factor = page_scale_factor;
+  out->external_page_scale_factor = external_page_scale_factor;
   out->primary_main_frame_item_sequence_number =
       data.primary_main_frame_item_sequence_number();
 #if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
-  out->min_page_scale_factor = data.min_page_scale_factor();
-  out->max_page_scale_factor = data.max_page_scale_factor();
+  out->min_page_scale_factor = min_page_scale_factor;
+  out->max_page_scale_factor = max_page_scale_factor;
   out->root_overflow_y_hidden = data.root_overflow_y_hidden();
   out->has_transparent_background = data.has_transparent_background();
 #endif
+
   if (!data.ReadBrowserControlsMetadata(&out->browser_controls_metadata)) {
     SetFailedCheckCrashKey("browser_controls_metadata");
     return false;
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java
index b3a0fc8..32a7bf44 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java
@@ -621,8 +621,8 @@
 
         // Make the bottom controls height smaller by the shadow height so that
         // the shadow is displayed over the bottom of the WebContents.
-        mBrowserControlsSizer.setBottomControlsHeight(
-                minHeight + height - mShadowHeightPx, minHeight);
+        int bottomControlsHeight = Math.max(0, height - mShadowHeightPx);
+        mBrowserControlsSizer.setBottomControlsHeight(minHeight + bottomControlsHeight, minHeight);
     }
 
     // SwipeGestureListener.SwipeHandler methods
diff --git a/cc/input/browser_controls_offset_manager_unittest.cc b/cc/input/browser_controls_offset_manager_unittest.cc
index b79b7d3..37a239d 100644
--- a/cc/input/browser_controls_offset_manager_unittest.cc
+++ b/cc/input/browser_controls_offset_manager_unittest.cc
@@ -890,6 +890,26 @@
 }
 
 TEST_F(BrowserControlsOffsetManagerTest,
+       MetadataShownRatioClampedDuringHeightChangeAnimation) {
+  MockBrowserControlsOffsetManagerClient client(100.0f, 0.5f, 0.5f);
+  BrowserControlsOffsetManager* manager = client.manager();
+
+  EXPECT_FLOAT_EQ(100.0f, manager->ContentTopOffset());
+  EXPECT_FLOAT_EQ(1.0f, manager->TopControlsShownRatio());
+
+  // Shrinking the height with animation initiates an animation where
+  // the initial shown ratio exceeds 1.0f (old_height / new_height = 2.0f).
+  client.SetBrowserControlsParams({50, 0, 0, 0, true, false});
+  EXPECT_TRUE(manager->HasAnimation());
+
+  // GetMetadata() must clamp the ratio to [0.0f, 1.0f].
+  BrowserControlsMetadata metadata = manager->GetMetadata();
+  EXPECT_GE(metadata.top_controls_shown_ratio, 0.0f);
+  EXPECT_LE(metadata.top_controls_shown_ratio, 1.0f);
+  EXPECT_FLOAT_EQ(1.0f, metadata.top_controls_shown_ratio);
+}
+
+TEST_F(BrowserControlsOffsetManagerTest,
        ShrinkingHeightKeepsBrowserControlsHidden) {
   MockBrowserControlsOffsetManagerClient client(100.0f, 0.5f, 0.5f);
   BrowserControlsOffsetManager* manager = client.manager();
diff --git a/content/browser/renderer_host/render_widget_host_unittest.cc b/content/browser/renderer_host/render_widget_host_unittest.cc
index 38e7c81..50f291cd 100644
--- a/content/browser/renderer_host/render_widget_host_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_unittest.cc
@@ -5,6 +5,7 @@
 #include <stddef.h>
 #include <stdint.h>
 
+#include <limits>
 #include <memory>
 #include <string_view>
 #include <tuple>
@@ -69,6 +70,7 @@
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "skia/ext/skia_utils_base.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -3325,4 +3327,255 @@
       ->AnimateDoubleTapZoomInMainFrame(tap_point, valid_rect);
 }
 
+TEST(RenderFrameMetadataMojoTraitsTest, ValidMetadata) {
+  cc::RenderFrameMetadata input;
+  input.device_scale_factor = 2.0f;
+  input.page_scale_factor = 1.0f;
+  input.external_page_scale_factor = 1.0f;
+  input.browser_controls_metadata.top_controls_height = 100.0f;
+  input.browser_controls_metadata.top_controls_shown_ratio = 1.0f;
+  input.viewport_size_in_pixels = gfx::Size(800, 600);
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  input.browser_controls_metadata.bottom_controls_height = 50.0f;
+  input.browser_controls_metadata.bottom_controls_shown_ratio = 0.5f;
+  input.browser_controls_metadata.top_controls_min_height_offset = 10.0f;
+  input.browser_controls_metadata.bottom_controls_min_height_offset = 5.0f;
+  input.min_page_scale_factor = 0.5f;
+  input.max_page_scale_factor = 4.0f;
+  input.scrollable_viewport_size = gfx::SizeF(800.0f, 600.0f);
+  input.root_layer_size = gfx::SizeF(800.0f, 2000.0f);
+#endif
+
+  cc::RenderFrameMetadata output;
+  EXPECT_TRUE(
+      mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+          input, output));
+  EXPECT_EQ(input.device_scale_factor, output.device_scale_factor);
+  EXPECT_EQ(input.page_scale_factor, output.page_scale_factor);
+  EXPECT_EQ(input.external_page_scale_factor,
+            output.external_page_scale_factor);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_height,
+            output.browser_controls_metadata.top_controls_height);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_shown_ratio,
+            output.browser_controls_metadata.top_controls_shown_ratio);
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_height,
+            output.browser_controls_metadata.bottom_controls_height);
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_shown_ratio,
+            output.browser_controls_metadata.bottom_controls_shown_ratio);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_min_height_offset,
+            output.browser_controls_metadata.top_controls_min_height_offset);
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_min_height_offset,
+            output.browser_controls_metadata.bottom_controls_min_height_offset);
+  EXPECT_EQ(input.min_page_scale_factor, output.min_page_scale_factor);
+  EXPECT_EQ(input.max_page_scale_factor, output.max_page_scale_factor);
+#endif
+}
+
+TEST(RenderFrameMetadataMojoTraitsTest, BoundaryShownRatios) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  for (float ratio : {0.0f, 0.5f, 1.0f}) {
+    input.browser_controls_metadata.top_controls_shown_ratio = ratio;
+    EXPECT_TRUE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+    EXPECT_EQ(ratio, output.browser_controls_metadata.top_controls_shown_ratio);
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+    input.browser_controls_metadata.bottom_controls_shown_ratio = ratio;
+    EXPECT_TRUE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+    EXPECT_EQ(ratio,
+              output.browser_controls_metadata.bottom_controls_shown_ratio);
+#endif
+  }
+}
+
+TEST(RenderFrameMetadataMojoTraitsTest, InvalidScaleFactors) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  const float invalid_scales[] = {
+      0.0f,
+      -0.001f,
+      -1.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float scale : invalid_scales) {
+    input = cc::RenderFrameMetadata();
+    input.device_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.external_page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  const float invalid_scale_limits[] = {
+      -0.001f,
+      -1.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float scale : invalid_scale_limits) {
+    input = cc::RenderFrameMetadata();
+    input.min_page_scale_factor = scale;
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/cc/input/browser_controls_offset_manager_unittest.cc b/cc/input/browser_controls_offset_manager_unittest.cc
index b79b7d3..37a239d 100644
--- a/cc/input/browser_controls_offset_manager_unittest.cc
+++ b/cc/input/browser_controls_offset_manager_unittest.cc
@@ -890,6 +890,26 @@
 }
 
 TEST_F(BrowserControlsOffsetManagerTest,
+       MetadataShownRatioClampedDuringHeightChangeAnimation) {
+  MockBrowserControlsOffsetManagerClient client(100.0f, 0.5f, 0.5f);
+  BrowserControlsOffsetManager* manager = client.manager();
+
+  EXPECT_FLOAT_EQ(100.0f, manager->ContentTopOffset());
+  EXPECT_FLOAT_EQ(1.0f, manager->TopControlsShownRatio());
+
+  // Shrinking the height with animation initiates an animation where
+  // the initial shown ratio exceeds 1.0f (old_height / new_height = 2.0f).
+  client.SetBrowserControlsParams({50, 0, 0, 0, true, false});
+  EXPECT_TRUE(manager->HasAnimation());
+
+  // GetMetadata() must clamp the ratio to [0.0f, 1.0f].
+  BrowserControlsMetadata metadata = manager->GetMetadata();
+  EXPECT_GE(metadata.top_controls_shown_ratio, 0.0f);
+  EXPECT_LE(metadata.top_controls_shown_ratio, 1.0f);
+  EXPECT_FLOAT_EQ(1.0f, metadata.top_controls_shown_ratio);
+}
+
+TEST_F(BrowserControlsOffsetManagerTest,
        ShrinkingHeightKeepsBrowserControlsHidden) {
   MockBrowserControlsOffsetManagerClient client(100.0f, 0.5f, 0.5f);
   BrowserControlsOffsetManager* manager = client.manager();
diff --git a/content/browser/renderer_host/render_widget_host_unittest.cc b/content/browser/renderer_host/render_widget_host_unittest.cc
index 38e7c81..50f291cd 100644
--- a/content/browser/renderer_host/render_widget_host_unittest.cc
+++ b/content/browser/renderer_host/render_widget_host_unittest.cc
@@ -5,6 +5,7 @@
 #include <stddef.h>
 #include <stdint.h>
 
+#include <limits>
 #include <memory>
 #include <string_view>
 #include <tuple>
@@ -69,6 +70,7 @@
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/remote.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
 #include "skia/ext/skia_utils_base.h"
 #include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -3325,4 +3327,255 @@
       ->AnimateDoubleTapZoomInMainFrame(tap_point, valid_rect);
 }
 
+TEST(RenderFrameMetadataMojoTraitsTest, ValidMetadata) {
+  cc::RenderFrameMetadata input;
+  input.device_scale_factor = 2.0f;
+  input.page_scale_factor = 1.0f;
+  input.external_page_scale_factor = 1.0f;
+  input.browser_controls_metadata.top_controls_height = 100.0f;
+  input.browser_controls_metadata.top_controls_shown_ratio = 1.0f;
+  input.viewport_size_in_pixels = gfx::Size(800, 600);
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  input.browser_controls_metadata.bottom_controls_height = 50.0f;
+  input.browser_controls_metadata.bottom_controls_shown_ratio = 0.5f;
+  input.browser_controls_metadata.top_controls_min_height_offset = 10.0f;
+  input.browser_controls_metadata.bottom_controls_min_height_offset = 5.0f;
+  input.min_page_scale_factor = 0.5f;
+  input.max_page_scale_factor = 4.0f;
+  input.scrollable_viewport_size = gfx::SizeF(800.0f, 600.0f);
+  input.root_layer_size = gfx::SizeF(800.0f, 2000.0f);
+#endif
+
+  cc::RenderFrameMetadata output;
+  EXPECT_TRUE(
+      mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+          input, output));
+  EXPECT_EQ(input.device_scale_factor, output.device_scale_factor);
+  EXPECT_EQ(input.page_scale_factor, output.page_scale_factor);
+  EXPECT_EQ(input.external_page_scale_factor,
+            output.external_page_scale_factor);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_height,
+            output.browser_controls_metadata.top_controls_height);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_shown_ratio,
+            output.browser_controls_metadata.top_controls_shown_ratio);
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_height,
+            output.browser_controls_metadata.bottom_controls_height);
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_shown_ratio,
+            output.browser_controls_metadata.bottom_controls_shown_ratio);
+  EXPECT_EQ(input.browser_controls_metadata.top_controls_min_height_offset,
+            output.browser_controls_metadata.top_controls_min_height_offset);
+  EXPECT_EQ(input.browser_controls_metadata.bottom_controls_min_height_offset,
+            output.browser_controls_metadata.bottom_controls_min_height_offset);
+  EXPECT_EQ(input.min_page_scale_factor, output.min_page_scale_factor);
+  EXPECT_EQ(input.max_page_scale_factor, output.max_page_scale_factor);
+#endif
+}
+
+TEST(RenderFrameMetadataMojoTraitsTest, BoundaryShownRatios) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  for (float ratio : {0.0f, 0.5f, 1.0f}) {
+    input.browser_controls_metadata.top_controls_shown_ratio = ratio;
+    EXPECT_TRUE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+    EXPECT_EQ(ratio, output.browser_controls_metadata.top_controls_shown_ratio);
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+    input.browser_controls_metadata.bottom_controls_shown_ratio = ratio;
+    EXPECT_TRUE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+    EXPECT_EQ(ratio,
+              output.browser_controls_metadata.bottom_controls_shown_ratio);
+#endif
+  }
+}
+
+TEST(RenderFrameMetadataMojoTraitsTest, InvalidScaleFactors) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  const float invalid_scales[] = {
+      0.0f,
+      -0.001f,
+      -1.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float scale : invalid_scales) {
+    input = cc::RenderFrameMetadata();
+    input.device_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.external_page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+  const float invalid_scale_limits[] = {
+      -0.001f,
+      -1.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float scale : invalid_scale_limits) {
+    input = cc::RenderFrameMetadata();
+    input.min_page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.max_page_scale_factor = scale;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+
+  // Max page scale factor less than min page scale factor.
+  input = cc::RenderFrameMetadata();
+  input.min_page_scale_factor = 2.0f;
+  input.max_page_scale_factor = 1.0f;
+  EXPECT_FALSE(
+      mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+          input, output));
+#endif
+}
+
+TEST(RenderFrameMetadataMojoTraitsTest, InvalidTopControlsValues) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  const float invalid_heights[] = {
+      -0.001f,
+      -1.0f,
+      -100.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float height : invalid_heights) {
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.top_controls_height = height;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+
+  const float invalid_ratios[] = {
+      -0.1f,
+      -0.001f,
+      1.001f,
+      1.1f,
+      100.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float ratio : invalid_ratios) {
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.top_controls_shown_ratio = ratio;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+}
+
+#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
+TEST(RenderFrameMetadataMojoTraitsTest, InvalidBottomControlsAndOffsets) {
+  cc::RenderFrameMetadata input;
+  cc::RenderFrameMetadata output;
+
+  const float invalid_heights[] = {
+      -0.001f,
+      -1.0f,
+      -100.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float height : invalid_heights) {
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.bottom_controls_height = height;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.top_controls_min_height_offset = height;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.bottom_controls_min_height_offset = height;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+
+  const float invalid_ratios[] = {
+      -0.1f,
+      -0.001f,
+      1.001f,
+      1.1f,
+      100.0f,
+      std::numeric_limits<float>::infinity(),
+      -std::numeric_limits<float>::infinity(),
+      std::numeric_limits<float>::quiet_NaN(),
+  };
+
+  for (float ratio : invalid_ratios) {
+    input = cc::RenderFrameMetadata();
+    input.browser_controls_metadata.bottom_controls_shown_ratio = ratio;
+    EXPECT_FALSE(
+        mojo::test::SerializeAndDeserialize<cc::mojom::RenderFrameMetadata>(
+            input, output));
+  }
+}
+#endif
+
+TEST_F(RenderWidgetHostTest, RenderFrameMetadataOutOfRangeMessageDisconnects) {
+  mojo::Remote<cc::mojom::RenderFrameMetadataObserverClient> client_remote;
+  mojo::PendingRemote<cc::mojom::RenderFrameMetadataObserver> observer_remote;
+  mojo::PendingReceiver<cc::mojom::RenderFrameMetadataObserver>
+      observer_receiver = observer_remote.InitWithNewPipeAndPassReceiver();
+
+  host_->RegisterRenderFrameMetadataObserver(
+      client_remote.BindNewPipeAndPassReceiver(), std::move(observer_remote));
+
+  base::RunLoop run_loop;
+  client_remote.set_disconnect_handler(run_loop.QuitClosure());
+
+  cc::RenderFrameMetadata invalid_metadata;
+  invalid_metadata.browser_controls_metadata.top_controls_shown_ratio = 100.0f;
+
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Security UI removal on Android via unvalidated top_controls_shown_ratio in Mojo traits

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential vulnerability in RenderFrameMetadata Mojo traits fails to restrict the top_controls_shown_ratio to a valid range, allowing a compromised renderer to submit excessively large values. This bypasses security invariants intended to keep top controls on-screen, generating a positive translation offset that survives Android browser-side clamps. As a result, both the physical Android toolbar and the composited URL-bar can be positioned off-screen, leading to security UI removal.

Affected files:

  • cc/mojom/render_frame_metadata_mojom_traits.cc
  • content/browser/renderer_host/render_widget_host_view_android.cc
  • chrome/android/java/src/org/chromium/chrome/browser/fullscreen/BrowserControlsManager.java
  • chrome/browser/android/compositor/layer/toolbar_layer.cc

Estimated timestamp from git blame: 2018-05-15

Description

A potential logic-based security UI removal vulnerability exists on Android due to missing input validation on the top_controls_shown_ratio and bottom_controls_shown_ratio fields inside the Mojo traits for cc::RenderFrameMetadata. A compromised renderer can supply abnormally large positive floats (e.g., 100.0f) for the shown ratios, which bypass browser-process clamps and displace the Android top controls (including the omnibox/URL bar) and content layer far off-screen.

Root Cause

In cc/mojom/render_frame_metadata_mojom_traits.cc, the deserialization logic performs validations to ensure the controls heights are non-negative and finite, but fails to check the range of the shown ratios:

  out->top_controls_height = data.top_controls_height();
  out->top_controls_shown_ratio = data.top_controls_shown_ratio();

  // Ensure top controls are finite and non-negative heights so that the top
  // controls cannot be moved offscreen by the renderer.
  if (!std::isfinite(out->top_controls_height) ||
      out->top_controls_height < 0 ||
      !std::isfinite(out->top_controls_shown_ratio)) {
    return false;
  }

While it validates that top_controls_shown_ratio is finite, it does not validate its range (specifically, that it is bounded within [0.0, 1.0]).

Potential Attack Flow / Trigger Path

(Note: These are suggested/potential steps based on codebase analysis; our tooling agent does not currently have the capability to execute code or run a working Proof-of-Concept):

  1. A compromised Android renderer sends cc.mojom.RenderFrameMetadataObserverClient.OnRenderFrameMetadataChanged with top_controls_height = 168.0f and a malicious top_controls_shown_ratio = 100.0f.
  2. The Mojo traits reader accepts the payload since both variables are finite and heights are positive.
  3. In RenderWidgetHostViewAndroid::UpdateControls (content/browser/renderer_host/render_widget_host_view_android.cc), the browser calculates the translation offset:
    • top_content_offset = 168.0f * 100.0f = 16800.0f
    • top_translate = 16800.0f - 168.0f = +16632.0f and dispatches it via JNI to the Java layer.
  4. In BrowserControlsManager.java (chrome/android/java/src/org/chromium/chrome/browser/fullscreen/BrowserControlsManager.java), the clamp used to restrict the offset is only one-sided:
    int rendererTopControlOffset = Math.max(topControlsOffset, -getTopControlsHeight());
    
    Thus, rendererTopControlOffset resolves to +16632 and mControlOffsetRatio becomes ~99.0f.
  5. Since mControlOffsetRatio > 0, shouldShowAndroidControls() evaluates to false and sets the physical Android-View toolbar container to View.INVISIBLE.
  6. Concurrently, TopControlsStacker propagates the large positive translation of +16632 down to the layers. In locked SHOWN states, hasMinHeight is true, so the OffsetTag is nulled, allowing the offset to bypass the Viz compositor-side OffsetTagConstraints clamp.
  7. In native ToolbarLayer::PushResource (chrome/browser/android/compositor/layer/toolbar_layer.cc), the composited URL-bar layer is positioned statically at y_offset = +16632, drawing it way off-screen below the visual viewport.
  8. Similarly, StaticLayout.java updates CONTENT_OFFSET_Y to 16800, shifting the page’s static content layer far below the screen.
  9. This results in the complete visual removal of the primary security UI (the Omnibox/URL bar) while the browser process intends it to be locked shown, which can lead to severe UI confusion or URL spoofing.

Suggested Fix

We suggest constraining both top_controls_shown_ratio and bottom_controls_shown_ratio to the valid [0.0, 1.0] range directly during Mojo deserialization in cc/mojom/render_frame_metadata_mojom_traits.cc.

For example, update the deserialization validation checks as follows:

  if (!std::isfinite(out->top_controls_height) ||
      out->top_controls_height < 0 ||
      !std::isfinite(out->top_controls_shown_ratio) ||
      out->top_controls_shown_ratio < 0 ||
      out->top_controls_shown_ratio > 1) {
    return false;
  }

And make a corresponding change for the bottom controls validation check.

Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker