Overview

Low
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in WebAppInstalls
DescriptionUI misrepresentation in WebAppInstalls
ComponentWebAppInstalls
Bug ClassSpoofing
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513996595
Fix commitf89efb408bbd (chromium/src) +208/-1
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-07-01 (public git)
Disclosed2026-09-17
Patch gap78 days — fix public in source before the advisory

Background

Bidi / format control characters
invisible Unicode code points such as U+202E (Right-to-Left Override, “RLO”) or U+200E (Left-to-Right Mark, “LRM”), classified U_FORMAT_CHAR, that reorder or hide surrounding text when rendered.
C0/C1 control characters
non-printing code points such as U+0001 (SOH), U+007F (DEL), or U+009F (APC) that base::IsUnicodeControl reports as control characters and that can corrupt or spoof rendered UI strings.
PWA file handler `accept` extensions
the file-type extension strings a web app manifest declares under file_handlers[].accept, which are surfaced to the user in install and file-handling UI.
`TransformFileExtensionsForDisplay`
the web_app_utils.cc helper that converts a set of raw extension strings into upper-cased std::u16string values for display in PWA UI.

Root Cause Analysis

Before the patch, extension strings from a web app manifest’s file_handlers[].accept map were accepted verbatim through the whole pipeline: the Blink manifest parser stored them without character validation, the browser process trusted them over IPC, and TransformFileExtensionsForDisplay only did base::UTF8ToUTF16(base::ToUpperASCII(extension.substr(1))) before handing them to display code. Because no layer filtered U_FORMAT_CHAR or control characters, an attacker-controlled manifest could embed a bidirectional override (e.g. U+202E) inside an extension so that the string rendered in PWA UI misrepresents the true file type the app handles. The invariant violated is that displayed file-extension text should be a faithful, printable representation of the declared extension.

The fix TIGHTENS enforcement at three tiers: parse-time rejection in manifest_parser.cc, browser-side zero-trust IPC rejection in manifest_manager_host.cc (which reports a bad message and kills a misbehaving renderer), and display-time sanitization in TransformFileExtensionsForDisplay that iterates code points with U16_NEXT and drops any that are control or format characters. The added empty-string guard in TransformFileExtensionsForDisplay also avoids calling extension.substr(1) on an empty string.

Key insight
The single mistake is trusting renderer/manifest-supplied file-extension strings as display-safe without filtering invisible bidi and control characters, allowing UI text to be reordered or hidden; the fix rejects such characters at parse and IPC time and strips them at render time so the displayed extension always matches its printable meaning.

Attack Path

  1. Craft a malicious manifest An attacker hosts a web app whose manifest declares a file_handlers entry with an accept extension containing an embedded format character, e.g. ".png‮".
  2. Extension survives parsing Pre-patch, the Blink manifest parser accepts the extension without checking code-point categories, so it is stored in the parsed Manifest and sent to the browser process.
  3. Browser process trusts the string Pre-patch, the browser-side manifest validation in manifest_manager_host.cc performs no control/format-character check, so the tainted extension is retained for the installed PWA.
  4. Spoofed display At render time TransformFileExtensionsForDisplay upper-cases the raw string and returns it unchanged for the bidi/control characters, so the install or file-handler UI shows a reordered or misleading extension, misrepresenting what file type the app will handle (inferred: the specific UI surface and the resulting user deception are implied by the “UI misrepresentation” advisory, not shown in the diff).

Impact Assessment

An attacker who controls a web app manifest can cause PWA install and file-handling UI to display a spoofed file extension, misrepresenting the file types the app handles and potentially deceiving the user into granting handling for a dangerous type; this is a UI-misrepresentation/spoofing issue rather than memory corruption. The manifest content is website-controlled and the browser-side tier additionally defends against a compromised renderer sending such strings over IPC. No code execution, sandbox escape, or in-the-wild exploitation is demonstrated by the patch or claimed here (any such escalation would be inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/web_applications/web_app_utils.cc
modified
for
content/browser/manifest/manifest_manager_host.cc
modified
for
third_party/blink/renderer/modules/manifest/manifest_parser.cc
modified
TEST_F
chrome/browser/web_applications/web_app_utils_unittest.cc
modified

Files Changed

  • third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
  • chrome/browser/web_applications/web_app_utils_unittest.cc
  • content/browser/manifest/manifest_browsertest.cc
  • third_party/blink/renderer/modules/manifest/manifest_parser.cc
  • chrome/browser/web_applications/web_app_utils.cc
  • third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
  • content/browser/manifest/manifest_manager_host.cc

Audit Directions

  • Renderer-to-display string passthrough
    Audit other manifest- or renderer-supplied strings (app name, shortcut names, protocol/handler titles, description) that reach browser UI to confirm they are filtered for bidi/format/control characters before display.
  • Duplicated ad-hoc character validation
    The patch’s own TODO(crbug.com/530303003) notes the control/format check is duplicated across parsing, IPC, and display; hunt for other sites doing partial or missing versions of this filter that should share a common helper.
  • Unsanitized `substr`/index math on strings
    Look for other display transforms like extension.substr(1) that assume a non-empty or well-formed input and can throw or misbehave on empty/edge-case strings from untrusted manifests.
From f89efb408bbd1329e7f06bbac2e78293896b4604 Mon Sep 17 00:00:00 2001
From: Vincent Scheib <[email protected]>
Date: Wed, 01 Jul 2026 16:52:15 -0700
Subject: [PATCH] Sanitize bidi and control characters in PWA file extensions

PWA file extension strings in the manifest can contain unsanitized
bidirectional control characters or other control characters.
This CL implements a three-tier hybrid enforcement strategy to
prevent UI spoofing:

1. Parse-time DevTools Gate: Rejects manifest file handler
   extensions containing U_FORMAT_CHAR or U_CONTROL_CHAR during
   Blink manifest parsing, logging a DevTools console error.
2. Browser-side Zero-Trust IPC Validation: Hardens the trusted
   browser process to reject manifests containing these characters in
   file handler extensions, preventing exploitation even by
   compromised renderers.
3. Display-time Hardening: Sanitizes extensions before UI rendering
   in web_app_utils.cc by actively filtering out U_FORMAT_CHAR
   and U_CONTROL_CHAR.

Tests:
- WebAppUtilsTest.
  TransformFileExtensionsForDisplay_StripsBidiControls
  (covers LRM, RLO, SOH, DEL, APC, and empty string)
- ManifestParserTest.FileHandlerParseRules
  (covers format and control characters)
- ManifestBrowserTest.
  BadMessage_FileHandlersAcceptExtensionInvalidFormatCharacter
- ManifestBrowserTest.
  BadMessage_FileHandlersAcceptExtensionInvalidControlCharacter

BUG=520490618
BUG=513996595
TAG=agy
CONV=d87700cf-7987-4cf2-bb6b-56ac7798e179

Change-Id: I77f874f6dbac094c522eed822486978cdff90a0d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8012267
Reviewed-by: Daniel Murphy <[email protected]>
Reviewed-by: Daniel Cheng <[email protected]>
Reviewed-by: Luke Deng <[email protected]>
Commit-Queue: Vincent Scheib <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1655824}
---
diff --git a/chrome/browser/web_applications/web_app_utils.cc b/chrome/browser/web_applications/web_app_utils.cc
index f2ccb0ce..5a8bf2c8 100644
--- a/chrome/browser/web_applications/web_app_utils.cc
+++ b/chrome/browser/web_applications/web_app_utils.cc
@@ -68,6 +68,8 @@
 #include "third_party/blink/public/common/safe_url_pattern.h"
 #include "third_party/blink/public/mojom/manifest/display_mode.mojom-shared.h"
 #include "third_party/blink/public/mojom/manifest/manifest.mojom-shared.h"
+#include "third_party/icu/source/common/unicode/uchar.h"
+#include "third_party/icu/source/common/unicode/utf16.h"
 #include "third_party/liburlpattern/options.h"
 #include "third_party/liburlpattern/part.h"
 #include "third_party/liburlpattern/pattern.h"
@@ -426,7 +428,33 @@
   std::ranges::transform(
       extensions, std::back_inserter(extensions_for_display),
       [](const std::string& extension) {
-        return base::UTF8ToUTF16(base::ToUpperASCII(extension.substr(1)));
+        if (extension.empty()) {
+          return std::u16string();
+        }
+        std::u16string ext_u16 =
+            base::UTF8ToUTF16(base::ToUpperASCII(extension.substr(1)));
+        std::u16string sanitized;
+        sanitized.reserve(ext_u16.size());
+        for (size_t i = 0; i < ext_u16.length();) {
+          UChar32 c;
+          U16_NEXT(ext_u16, i, ext_u16.length(), c);
+          // TODO(crbug.com/530303003): This check for control and format
+          // characters is duplicated across manifest parsing, IPC validation,
+          // and PWA display. Consider consolidating it into a shared helper in
+          // //base/strings/string_util.h.
+          if (!base::IsUnicodeControl(c) && u_charType(c) != U_FORMAT_CHAR) {
+            if (c <= 0xFFFF) {
+              // Safe to cast UChar32 to char16_t here because the guard ensures
+              // c is within the BMP (<= 0xFFFF), and Unicode code points are
+              // non-negative.
+              sanitized.push_back(static_cast<char16_t>(c));
+            } else {
+              sanitized.push_back(U16_LEAD(c));
+              sanitized.push_back(U16_TRAIL(c));
+            }
+          }
+        }
+        return sanitized;
       });
   return extensions_for_display;
 }
diff --git a/content/browser/manifest/manifest_manager_host.cc b/content/browser/manifest/manifest_manager_host.cc
index ad89aab3..0bf0cd58 100644
--- a/content/browser/manifest/manifest_manager_host.cc
+++ b/content/browser/manifest/manifest_manager_host.cc
@@ -26,6 +26,8 @@
 #include "third_party/blink/public/common/security/protocol_handler_security_level.h"
 #include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
 #include "third_party/blink/public/mojom/manifest/manifest_manager.mojom.h"
+#include "third_party/icu/source/common/unicode/uchar.h"
+#include "third_party/icu/source/common/unicode/utf16.h"
 #include "url/gurl.h"
 #include "url/origin.h"
 
@@ -93,6 +95,22 @@
       if (!document_origin.IsSameOriginWith(file_handler->action)) {
         return "Manifest file_handlers must be same-origin with the document.";
       }
+      for (const auto& [mime_type, extensions] : file_handler->accept) {
+        for (const auto& extension : extensions) {
+          for (size_t i = 0; i < extension.length();) {
+            UChar32 c;
+            U16_NEXT(extension, i, extension.length(), c);
+            // TODO(crbug.com/530303003): This check for control and format
+            // characters is duplicated across manifest parsing, IPC validation,
+            // and PWA display. Consider consolidating it into a shared helper
+            // in //base/strings/string_util.h.
+            if (base::IsUnicodeControl(c) || u_charType(c) == U_FORMAT_CHAR) {
+              return "Manifest file_handlers accept extension contains invalid "
+                     "control or format characters.";
+            }
+          }
+        }
+      }
     }
 
     for (const auto& protocol_handler : manifest.protocol_handlers) {
diff --git a/third_party/blink/renderer/modules/manifest/manifest_parser.cc b/third_party/blink/renderer/modules/manifest/manifest_parser.cc
index 870efde..893a0b2 100644
--- a/third_party/blink/renderer/modules/manifest/manifest_parser.cc
+++ b/third_party/blink/renderer/modules/manifest/manifest_parser.cc
@@ -4,6 +4,8 @@
 
 #include "third_party/blink/renderer/modules/manifest/manifest_parser.h"
 
+#include <unicode/uchar.h>
+
 #include <cstddef>
 #include <memory>
 #include <optional>
@@ -17,6 +19,7 @@
 #include "base/feature_list.h"
 #include "base/metrics/histogram_functions.h"
 #include "base/notreached.h"
+#include "base/strings/string_util.h"
 #include "net/base/mime_util.h"
 #include "net/base/registry_controlled_domains/registry_controlled_domain.h"
 #include "services/device/public/mojom/screen_orientation_lock_types.mojom-blink.h"
@@ -48,6 +51,7 @@
 #include "third_party/blink/renderer/platform/weborigin/kurl.h"
 #include "third_party/blink/renderer/platform/weborigin/security_origin.h"
 #include "third_party/blink/renderer/platform/wtf/hash_map.h"
+#include "third_party/blink/renderer/platform/wtf/text/code_point_iterator.h"
 #include "third_party/blink/renderer/platform/wtf/text/strcat.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_impl.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_utf8_adaptor.h"
@@ -1821,6 +1825,19 @@
     return false;
   }
 
+  // TODO(crbug.com/530303003): This check for control and format characters is
+  // duplicated across manifest parsing, IPC validation, and PWA display.
+  // Consider consolidating it into a shared helper in
+  // //base/strings/string_util.h.
+  for (UChar32 c : *output) {
+    if (base::IsUnicodeControl(c) || u_charType(c) == U_FORMAT_CHAR) {
+      AddErrorInfo(
+          "property 'accept' file extension ignored, contains invalid "
+          "control or format characters.");
+      return false;
+    }
+  }
+
   return true;
 }
 
diff --git a/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py b/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
index 20a6cb7..1a279700 100755
--- a/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
+++ b/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
@@ -278,6 +278,7 @@
             'base::ClampFloor',
             'base::ClampRound',
             'base::IsTypeInRangeForNumericType',
+            'base::IsUnicodeControl',
             'base::IsValueInRangeForNumericType',
             'base::IsValueNegative',
             'base::MakeStrictNum',
diff --git a/chrome/browser/web_applications/web_app_utils_unittest.cc b/chrome/browser/web_applications/web_app_utils_unittest.cc
index 7cae1bd..50659495 100644
--- a/chrome/browser/web_applications/web_app_utils_unittest.cc
+++ b/chrome/browser/web_applications/web_app_utils_unittest.cc
@@ -115,6 +115,23 @@
 #endif
 }
 
+TEST_F(WebAppUtilsTest, TransformFileExtensionsForDisplay_StripsBidiControls) {
+  std::set<std::string> extensions = {
+      ".aa\xE2\x80\x8E",  // LRM (Format)
+      ".bb\xE2\x80\xAE",  // RLO (Format)
+      ".cc\x01",          // SOH (Control)
+      ".dd\x7F",          // DEL (Control)
+      ".ee\xC2\x9F",      // APC (Control)
+      ".bat",             // Safe
+      ""                  // Empty
+  };
+  std::vector<std::u16string> transformed =
+      TransformFileExtensionsForDisplay(extensions);
+
+  EXPECT_THAT(transformed, ::testing::UnorderedElementsAre(
+                               u"", u"AA", u"BB", u"CC", u"DD", u"EE", u"BAT"));
+}
+
 TEST_F(WebAppUtilsTest, AreWebAppsUserInstallable) {
   Profile* regular_profile = profile();
 
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index 2022229..a4485c1 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1245,6 +1245,76 @@
           "Manifest file_handlers must be same-origin with the document."));
 }
 
+IN_PROC_BROWSER_TEST_F(
+    ManifestBrowserTest,
+    BadMessage_FileHandlersAcceptExtensionInvalidFormatCharacter) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = test_url;
+
+  std::vector<std::u16string> extensions;
+  extensions.push_back(u".png\u202E");
+  file_handler->accept[u"image/png"] = std::move(extensions);
+
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest file_handlers accept extension contains invalid "
+                  "control or format characters."));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    ManifestBrowserTest,
+    BadMessage_FileHandlersAcceptExtensionInvalidControlCharacter) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = test_url;
+
+  std::vector<std::u16string> extensions;
+  extensions.push_back(u".png\u0001");
+  file_handler->accept[u"image/png"] = std::move(extensions);
+
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest file_handlers accept extension contains invalid "
+                  "control or format characters."));
+}
+
 IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
                        BadMessage_ProtocolHandlersActionCrossOrigin) {
   const GURL test_url =
diff --git a/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc b/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
index 488fbdcf..3e0076d 100644
--- a/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
+++ b/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
@@ -3040,6 +3040,62 @@
     ASSERT_EQ(0u, file_handlers.size());
   }
 
+  // Extensions that contain format characters are invalid.
+  {
+    auto& manifest = ParseManifest(
+        R"({
+          "file_handlers": [
+            {
+              "name": "name",
+              "action": "/files",
+              "accept": {
+                "image/png": [
+                  ".png\u202E"
+                ]
+              }
+            }
+          ]
+        })");
+    auto& file_handlers = manifest->file_handlers;
+
+    ASSERT_EQ(2u, GetErrorCount());
+    EXPECT_EQ(
+        "property 'accept' file extension ignored, contains invalid "
+        "control or format characters.",
+        errors()[0]);
+    EXPECT_EQ("FileHandler ignored. Property 'accept' is invalid.",
+              errors()[1]);
+    ASSERT_EQ(0u, file_handlers.size());
+  }
+
+  // Extensions that contain control characters are invalid.
+  {
+    auto& manifest = ParseManifest(
+        R"({
+          "file_handlers": [
+            {
+              "name": "name",
+              "action": "/files",
+              "accept": {
+                "image/png": [
+                  ".png\u0001"
+                ]
+              }
+            }
+          ]
+        })");
+    auto& file_handlers = manifest->file_handlers;
+
+    ASSERT_EQ(2u, GetErrorCount());
+    EXPECT_EQ(
+        "property 'accept' file extension ignored, contains invalid "
+        "control or format characters.",
+        errors()[0]);
+    EXPECT_EQ("FileHandler ignored. Property 'accept' is invalid.",
+              errors()[1]);
+    ASSERT_EQ(0u, file_handlers.size());
+  }
+
   // Invalid MIME types and those with parameters are stripped.
   {
     auto& manifest = ParseManifest(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/web_app_utils_unittest.cc b/chrome/browser/web_applications/web_app_utils_unittest.cc
index 7cae1bd..50659495 100644
--- a/chrome/browser/web_applications/web_app_utils_unittest.cc
+++ b/chrome/browser/web_applications/web_app_utils_unittest.cc
@@ -115,6 +115,23 @@
 #endif
 }
 
+TEST_F(WebAppUtilsTest, TransformFileExtensionsForDisplay_StripsBidiControls) {
+  std::set<std::string> extensions = {
+      ".aa\xE2\x80\x8E",  // LRM (Format)
+      ".bb\xE2\x80\xAE",  // RLO (Format)
+      ".cc\x01",          // SOH (Control)
+      ".dd\x7F",          // DEL (Control)
+      ".ee\xC2\x9F",      // APC (Control)
+      ".bat",             // Safe
+      ""                  // Empty
+  };
+  std::vector<std::u16string> transformed =
+      TransformFileExtensionsForDisplay(extensions);
+
+  EXPECT_THAT(transformed, ::testing::UnorderedElementsAre(
+                               u"", u"AA", u"BB", u"CC", u"DD", u"EE", u"BAT"));
+}
+
 TEST_F(WebAppUtilsTest, AreWebAppsUserInstallable) {
   Profile* regular_profile = profile();
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index 2022229..a4485c1 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1245,6 +1245,76 @@
           "Manifest file_handlers must be same-origin with the document."));
 }
 
+IN_PROC_BROWSER_TEST_F(
+    ManifestBrowserTest,
+    BadMessage_FileHandlersAcceptExtensionInvalidFormatCharacter) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = test_url;
+
+  std::vector<std::u16string> extensions;
+  extensions.push_back(u".png\u202E");
+  file_handler->accept[u"image/png"] = std::move(extensions);
+
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest file_handlers accept extension contains invalid "
+                  "control or format characters."));
+}
+
+IN_PROC_BROWSER_TEST_F(
+    ManifestBrowserTest,
+    BadMessage_FileHandlersAcceptExtensionInvalidControlCharacter) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = test_url;
+
+  std::vector<std::u16string> extensions;
+  extensions.push_back(u".png\u0001");
+  file_handler->accept[u"image/png"] = std::move(extensions);
+
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest file_handlers accept extension contains invalid "
+                  "control or format characters."));
+}
+
 IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
                        BadMessage_ProtocolHandlersActionCrossOrigin) {
   const GURL test_url =
diff --git a/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc b/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
index 488fbdcf..3e0076d 100644
--- a/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
+++ b/third_party/blink/renderer/modules/manifest/manifest_parser_unittest.cc
@@ -3040,6 +3040,62 @@
     ASSERT_EQ(0u, file_handlers.size());
   }
 
+  // Extensions that contain format characters are invalid.
+  {
+    auto& manifest = ParseManifest(
+        R"({
+          "file_handlers": [
+            {
+              "name": "name",
+              "action": "/files",
+              "accept": {
+                "image/png": [
+                  ".png\u202E"
+                ]
+              }
+            }
+          ]
+        })");
+    auto& file_handlers = manifest->file_handlers;
+
+    ASSERT_EQ(2u, GetErrorCount());
+    EXPECT_EQ(
+        "property 'accept' file extension ignored, contains invalid "
+        "control or format characters.",
+        errors()[0]);
+    EXPECT_EQ("FileHandler ignored. Property 'accept' is invalid.",
+              errors()[1]);
+    ASSERT_EQ(0u, file_handlers.size());
+  }
+
+  // Extensions that contain control characters are invalid.
+  {
+    auto& manifest = ParseManifest(
+        R"({
+          "file_handlers": [
+            {
+              "name": "name",
+              "action": "/files",
+              "accept": {
+                "image/png": [
+                  ".png\u0001"
+                ]
+              }
+            }
+          ]
+        })");
+    auto& file_handlers = manifest->file_handlers;
+
+    ASSERT_EQ(2u, GetErrorCount());
+    EXPECT_EQ(
+        "property 'accept' file extension ignored, contains invalid "
+        "control or format characters.",
+        errors()[0]);
+    EXPECT_EQ("FileHandler ignored. Property 'accept' is invalid.",
+              errors()[1]);
+    ASSERT_EQ(0u, file_handlers.size());
+  }
+
   // Invalid MIME types and those with parameters are stripped.
   {
     auto& manifest = ParseManifest(
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.