Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Extensions
DescriptionIncorrect authorization in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassAuthorization Error
Tracker513741326
Fix commit4c8966cccbf2 (chromium/src) +153/-20
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/tabs/tabs_api.cc
modified

Files Changed

  • chrome/browser/extensions/api/tabs/tabs_test.cc
  • chrome/browser/extensions/api/tabs/tabs_api.cc
From 4c8966cccbf2a46c7c2dfcd5b897558f41cbb2ec Mon Sep 17 00:00:00 2001
From: Anh Mac <[email protected]>
Date: Thu, 10 Sep 2026 15:43:04 -0700
Subject: [PATCH] [Extensions] Fix cross-origin permission leaks during pending navigation in chrome.tabs.query

This CL updates chrome.tabs.query to check
ExtensionTabUtil::GetScrubTabBehavior when querying tabs by title or
URL. This ensures that permissions are correctly evaluated for both
committed and pending navigation states.

In the existing state, during a pending navigation where url_a is
committed, and url_b is pending (i.e. tab.url:url_a,
tab.pendingUrl:url_b), an extension with host permissions to only url_b
can perform chrome.tabs.query({url:url_b, title:title_a}) and get a
title result for url_a since we are leaking permissions between the
committed and pending host url when using web_contents->GetURL().

This CL updates the permission check when matching against "title" to
ensure that chrome.tabs.query only succeeds if the extension has host
permission on the committed url. While matching against "url", a query
will succeed if it matches against either the committed url OR the
pendingUrl.

Fixed: 513741326
Change-Id: Iba6bb1743567ff08911b65b76cf5de9c23fe012f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8376661
Reviewed-by: Devlin Cronin <[email protected]>
Commit-Queue: Anh Mac <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1695728}
---
diff --git a/chrome/browser/extensions/api/tabs/tabs_api.cc b/chrome/browser/extensions/api/tabs/tabs_api.cc
index 6d67307..0892d1b 100644
--- a/chrome/browser/extensions/api/tabs/tabs_api.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_api.cc
@@ -2068,28 +2068,38 @@
     return false;
   }
 
-  bool check_title = query_info_.title && !query_info_.title->empty();
-  if (check_title || !target_url_patterns.is_empty()) {
-    // "title" and "url" properties are considered privileged data and can
-    // only be checked if the extension has the "tabs" permission or it has
-    // access to the WebContents's origin. Otherwise, this tab is considered
-    // not matched.
-    if (!extension_->permissions_data()->HasAPIPermissionForTab(
-            ExtensionTabUtil::GetTabId(web_contents),
-            mojom::APIPermissionID::kTab) &&
-        !extension_->permissions_data()->HasHostPermission(
-            web_contents->GetURL())) {
+  // "title" and "url" properties are considered privileged data and can
+  // only be checked if the extension has access to the tab's data.
+  // Otherwise, this tab is considered not matched.
+  ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+      ExtensionTabUtil::GetScrubTabBehavior(extension(), source_context_type(),
+                                            web_contents);
+  if (query_info_.title && !query_info_.title->empty()) {
+    bool matches_title =
+        scrub_tab_behavior.committed_info != ExtensionTabUtil::kScrubTabFully &&
+        base::MatchPattern(web_contents->GetTitle(),
+                           base::UTF8ToUTF16(*query_info_.title));
+    if (!matches_title) {
       return false;
     }
+  }
 
-    if (check_title &&
-        !base::MatchPattern(web_contents->GetTitle(),
-                            base::UTF8ToUTF16(*query_info_.title))) {
-      return false;
-    }
+  if (!target_url_patterns.is_empty()) {
+    bool matches_committed =
+        scrub_tab_behavior.committed_info != ExtensionTabUtil::kScrubTabFully &&
+        target_url_patterns.MatchesURL(web_contents->GetLastCommittedURL());
 
-    if (!target_url_patterns.is_empty() &&
-        !target_url_patterns.MatchesURL(web_contents->GetURL())) {
+    content::NavigationEntry* pending_entry =
+        web_contents->GetController().GetPendingEntry();
+    bool matches_pending =
+        pending_entry &&
+        scrub_tab_behavior.pending_info != ExtensionTabUtil::kScrubTabFully &&
+        target_url_patterns.MatchesURL(pending_entry->GetVirtualURL());
+
+    // Note: It's okay to indicate the tab matched even if it only has access
+    // to one of [pending, committed]. The tab will be scrubbed appropriately
+    // when it's returned below.
+    if (!matches_committed && !matches_pending) {
       return false;
     }
   }
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 723621d8..4c5477f 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -9,6 +9,7 @@
 #include <array>
 #include <memory>
 #include <string>
+#include <string_view>
 
 #include "base/feature_list.h"
 #include "base/files/scoped_temp_dir.h"
@@ -87,6 +88,7 @@
 #include "extensions/test/result_catcher.h"
 #include "extensions/test/test_extension_dir.h"
 #include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "pdf/buildflags.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -4402,6 +4404,127 @@
   }
 }
 
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest,
+                       QueryPermissionsWithPendingNavigation) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/slow");
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  // Constants for testing.
+  static constexpr char kHostA[] = "a.example";
+  static constexpr char kHostB[] = "b.example";
+  static constexpr char kTitleA[] = "Site A Title";
+  static constexpr char kTitleB[] = "Site B Title";
+  // Query templates.
+  constexpr char kTitleQuery[] = R"([{"title": "%s"}])";
+  constexpr char kUrlQuery[] = R"([{"url": "*://%s/*"}])";
+  constexpr char kCombinedQuery[] = R"([{"title": "%s", "url": "*://%s/*"}])";
+
+  auto count_matches = [&](const Extension* ext,
+                           const std::string& query_json) {
+    return RunQueryFunction(ext, query_json.c_str()).size();
+  };
+
+  const GURL url_a = embedded_test_server()->GetURL(kHostA, "/empty.html");
+  const GURL url_b = embedded_test_server()->GetURL(kHostB, "/slow");
+
+  // Set up the initial tab.
+  tabs::TabInterface* tab = GetTabListInterface()->OpenTab(url_a, -1);
+  ASSERT_TRUE(tab);
+  content::WebContents* web_contents = tab->GetContents();
+  content::WaitForLoadStop(web_contents);
+  web_contents->GetController().GetVisibleEntry()->SetTitle(
+      base::ASCIIToUTF16(std::string(kTitleA)));
+  EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  // Build test extensions with distinct permission scopes.
+  auto extension_a =
+      ExtensionBuilder("ExtensionA")
+          .AddHostPermission(base::StringPrintf("*://%s/*", kHostA))
+          .Build();
+  auto extension_b =
+      ExtensionBuilder("ExtensionB")
+          .AddHostPermission(base::StringPrintf("*://%s/*", kHostB))
+          .Build();
+  auto extension_tabs =
+      ExtensionBuilder("TabsPermission").AddAPIPermission("tabs").Build();
+
+  // Start in-flight browser-initiated navigation to url_b.
+  content::NavigationController::LoadURLParams params(url_b);
+  params.transition_type = ui::PAGE_TRANSITION_TYPED;
+  web_contents->GetController().LoadURLWithParams(params);
+  response.WaitForRequest();
+
+  // Verify that the committed URL and title have not yet been updated to url_b.
+  EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+  EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  {
+    SCOPED_TRACE("Before navigation (A committed, B pending)");
+
+    // Extension A: matches committed A info, but cannot see pending URL for B.
+    EXPECT_EQ(1u, count_matches(extension_a.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(1u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+
+    // Extension B: can match its pending URL, but cannot see committed title or
+    // URL from A.
+    EXPECT_EQ(0u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+    // Bug fix for crbug.com/513741326: Cross-origin check (B cannot see A's
+    // committed info).
+    EXPECT_EQ(0u, count_matches(extension_b.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(
+        0u, count_matches(extension_b.get(),
+                          base::StringPrintf(kCombinedQuery, kTitleA, kHostB)));
+
+    // Tabs extension: has global access to all titles and URLs.
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+  }
+
+  // Complete the navigation.
+  response.Send(
+      base::StringPrintf("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
+                         "<html><head><title>%s</title></head></html>",
+                         kTitleB));
+  response.Done();
+  content::WaitForLoadStop(web_contents);
+
+  // Verify that the committed URL and title have been updated to url_b.
+  EXPECT_EQ(url_b, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+  EXPECT_EQ(kTitleB, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  {
+    SCOPED_TRACE("After navigation (B committed)");
+
+    // Extension A no longer matches anything on this tab.
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kTitleQuery, kTitleB)));
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+
+    // Extension B now matches committed B title and URL.
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kTitleQuery, kTitleB)));
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+  }
+}
+
 #if BUILDFLAG(ENABLE_PDF)
 // Test that using the PDF extension for tab updates is treated as a
 // renderer-initiated navigation. crbug.com/40085816
@@ -5726,13 +5849,13 @@
                      )";
   }
   test_dir.WriteFile(FILE_PATH_LITERAL("background.js"),
-                     absl::StrFormat(R"(
+                     base::StringPrintf(R"(
       chrome.tabs.create({"url": "about:blank"}, function(created_tab) {
         chrome.tabs.%s.addListener(%s);
         chrome.tabs.discard(created_tab.id);
       });
                                      )",
-                                     event_name, event_listener));
+                                        event_name, event_listener));
 
   ExtensionTestMessageListener success_listener("success");
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 723621d8..4c5477f 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -9,6 +9,7 @@
 #include <array>
 #include <memory>
 #include <string>
+#include <string_view>
 
 #include "base/feature_list.h"
 #include "base/files/scoped_temp_dir.h"
@@ -87,6 +88,7 @@
 #include "extensions/test/result_catcher.h"
 #include "extensions/test/test_extension_dir.h"
 #include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
 #include "net/test/embedded_test_server/embedded_test_server.h"
 #include "pdf/buildflags.h"
 #include "testing/gmock/include/gmock/gmock.h"
@@ -4402,6 +4404,127 @@
   }
 }
 
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest,
+                       QueryPermissionsWithPendingNavigation) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/slow");
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  // Constants for testing.
+  static constexpr char kHostA[] = "a.example";
+  static constexpr char kHostB[] = "b.example";
+  static constexpr char kTitleA[] = "Site A Title";
+  static constexpr char kTitleB[] = "Site B Title";
+  // Query templates.
+  constexpr char kTitleQuery[] = R"([{"title": "%s"}])";
+  constexpr char kUrlQuery[] = R"([{"url": "*://%s/*"}])";
+  constexpr char kCombinedQuery[] = R"([{"title": "%s", "url": "*://%s/*"}])";
+
+  auto count_matches = [&](const Extension* ext,
+                           const std::string& query_json) {
+    return RunQueryFunction(ext, query_json.c_str()).size();
+  };
+
+  const GURL url_a = embedded_test_server()->GetURL(kHostA, "/empty.html");
+  const GURL url_b = embedded_test_server()->GetURL(kHostB, "/slow");
+
+  // Set up the initial tab.
+  tabs::TabInterface* tab = GetTabListInterface()->OpenTab(url_a, -1);
+  ASSERT_TRUE(tab);
+  content::WebContents* web_contents = tab->GetContents();
+  content::WaitForLoadStop(web_contents);
+  web_contents->GetController().GetVisibleEntry()->SetTitle(
+      base::ASCIIToUTF16(std::string(kTitleA)));
+  EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  // Build test extensions with distinct permission scopes.
+  auto extension_a =
+      ExtensionBuilder("ExtensionA")
+          .AddHostPermission(base::StringPrintf("*://%s/*", kHostA))
+          .Build();
+  auto extension_b =
+      ExtensionBuilder("ExtensionB")
+          .AddHostPermission(base::StringPrintf("*://%s/*", kHostB))
+          .Build();
+  auto extension_tabs =
+      ExtensionBuilder("TabsPermission").AddAPIPermission("tabs").Build();
+
+  // Start in-flight browser-initiated navigation to url_b.
+  content::NavigationController::LoadURLParams params(url_b);
+  params.transition_type = ui::PAGE_TRANSITION_TYPED;
+  web_contents->GetController().LoadURLWithParams(params);
+  response.WaitForRequest();
+
+  // Verify that the committed URL and title have not yet been updated to url_b.
+  EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+  EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  {
+    SCOPED_TRACE("Before navigation (A committed, B pending)");
+
+    // Extension A: matches committed A info, but cannot see pending URL for B.
+    EXPECT_EQ(1u, count_matches(extension_a.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(1u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+
+    // Extension B: can match its pending URL, but cannot see committed title or
+    // URL from A.
+    EXPECT_EQ(0u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+    // Bug fix for crbug.com/513741326: Cross-origin check (B cannot see A's
+    // committed info).
+    EXPECT_EQ(0u, count_matches(extension_b.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(
+        0u, count_matches(extension_b.get(),
+                          base::StringPrintf(kCombinedQuery, kTitleA, kHostB)));
+
+    // Tabs extension: has global access to all titles and URLs.
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kTitleQuery, kTitleA)));
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kUrlQuery, kHostA)));
+    EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+  }
+
+  // Complete the navigation.
+  response.Send(
+      base::StringPrintf("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
+                         "<html><head><title>%s</title></head></html>",
+                         kTitleB));
+  response.Done();
+  content::WaitForLoadStop(web_contents);
+
+  // Verify that the committed URL and title have been updated to url_b.
+  EXPECT_EQ(url_b, web_contents->GetLastCommittedURL());
+  EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+  EXPECT_EQ(kTitleB, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+  {
+    SCOPED_TRACE("After navigation (B committed)");
+
+    // Extension A no longer matches anything on this tab.
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kTitleQuery, kTitleB)));
+    EXPECT_EQ(0u, count_matches(extension_a.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+
+    // Extension B now matches committed B title and URL.
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kTitleQuery, kTitleB)));
+    EXPECT_EQ(1u, count_matches(extension_b.get(),
+                                base::StringPrintf(kUrlQuery, kHostB)));
+  }
+}
+
 #if BUILDFLAG(ENABLE_PDF)
 // Test that using the PDF extension for tab updates is treated as a
 // renderer-initiated navigation. crbug.com/40085816
@@ -5726,13 +5849,13 @@
                      )";
   }
   test_dir.WriteFile(FILE_PATH_LITERAL("background.js"),
-                     absl::StrFormat(R"(
+                     base::StringPrintf(R"(
       chrome.tabs.create({"url": "about:blank"}, function(created_tab) {
         chrome.tabs.%s.addListener(%s);
         chrome.tabs.discard(created_tab.id);
       });
                                      )",
-                                     event_name, event_listener));
+                                        event_name, event_listener));
 
   ExtensionTestMessageListener success_listener("success");
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin title disclosure in tabs.query during browser-initiated navigation

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: The chrome.tabs.query API incorrectly uses the visible URL for permission checks while matching against the last-committed page title. During a browser-initiated navigation, this allows an extension to leak a cross-origin site’s title using the title filter as an oracle.

Affected files:

  • chrome/browser/extensions/api/tabs/tabs_api.cc
  • chrome/browser/extensions/extension_tab_util.cc
  • content/browser/web_contents/web_contents_impl.cc
  • content/browser/renderer_host/navigation_controller_impl.cc

Estimated timestamp from git blame: 2016-01-21

Root Cause Analysis

In TabsQueryFunction::MatchesTab (located in chrome/browser/extensions/api/tabs/tabs_api.cc), the logic gates access to privileged tab data (title and URL) by checking the extension’s host permissions. However, there is a discrepancy between the URL used for the permission check and the title data actually matched.

Specifically, the check uses web_contents->GetURL(), which returns the visible URL. During a pending browser-initiated navigation (e.g., initiated via the omnibox), GetURL() returns the destination URL. Conversely, the matching logic uses web_contents->GetTitle(), which returns the last-committed title—the title of the page the user is currently navigating away from.

// chrome/browser/extensions/api/tabs/tabs_api.cc
if (check_title || !target_url_patterns.is_empty()) {
  if (!extension_->permissions_data()->HasAPIPermissionForTab(...) &&
      !extension_->permissions_data()->HasHostPermission(
          web_contents->GetURL())) {        // Returns visible URL (pending destination)
    return false;
  }

  if (check_title &&
      !base::MatchPattern(web_contents->GetTitle(),   // Returns last-committed title
                          base::UTF8ToUTF16(*query_info_.title))) {
    return false;
  }
}

WebContentsImpl::GetURL() calls GetVisibleURL(), which for browser-initiated pending entries returns the pending destination. In contrast, WebContentsImpl::GetTitle() typically uses the last-committed entry’s title during navigations to maintain UI stability. This discrepancy creates a Time-of-Check Time-of-Use (TOCTOU) window.

Potential Vulnerability Mechanism

  1. Victim State: A tab is on https://victim.example with a sensitive title (e.g., “Inbox (5) - [email protected]”).
  2. User Action: The user initiates a navigation to https://permitted.example via the omnibox. This creates a browser-initiated pending navigation. GetVisibleURL() now returns https://permitted.example because browser-initiated pending entries are considered safe to show in the omnibox.
  3. Exploitation: An extension with host permissions for https://permitted.example (but no tabs permission and no permissions for victim.example) calls chrome.tabs.query({title: 'Inbox*'}) while the navigation is in flight.
  4. Bypass: The permission check passes because the visible URL matches the permitted destination. However, the title match is evaluated against the victim’s title retrieved via GetTitle().

Even though the resulting Tab object is scrubbed before being returned to the extension (removing the title and URL fields), the fact that the tab is included in the result array confirms the match, acting as a 1-bit oracle.

Suggested Fix

Ensure that the permission check and the data matching logic use consistent NavigationEntries. TabsQueryFunction::MatchesTab should likely check permissions against the same entry used to retrieve the title. Specifically, the permission check should verify host permissions against the last-committed URL if the title being matched is the last-committed title.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


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