Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in PrintPreview
DescriptionImproper input validation in PrintPreview
ComponentPrintPreview
Sub-componentUI>Browser>PrintPreview
Bug ClassInput Validation
Tracker563362367
Fix commit494a563798db (chromium/src) +17/-34
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Files Changed

  • chrome/browser/ui/webui/print_preview/extension_printer_handler.cc
  • chrome/browser/ui/webui/print_preview/extension_printer_handler_unittest.cc

Original Bug Report

reported by vm...@google.com

Leak of incognito page title and content to extensions without 'Allow in incognito'

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: When a user prints from an incognito tab, Print Preview offers destinations from installed chrome.printerProvider extensions and, on Print, delivers the incognito tab’s title and the complete rendered document to the extension’s regular-profile service worker — even when the extension’s “Allow in incognito” toggle is OFF (the default). The extension-printer path has no IsIncognitoEnabled gate, and every printerProvider event is dispatched with a null restrict_to_browser_context, so the EventRouter’s cross-incognito filter never engages. This bypasses the consent boundary that extensions/docs/security_faq.md explicitly calls a security bug, and was reproduced end-to-end by an executed proof of concept on an unmodified build (Chrome 156.0.8065.0).

Affected files:

Estimated timestamp from git blame: 2015-02-12

Summary

Preconditions are ordinary: any installed extension with the stable printerProvider permission (extensions/common/api/_permission_features.json:593-598; extension + platform_app types, Win/Mac/Linux/ChromeOS), “Allow in incognito” left OFF, and a user who prints from an incognito window. Printing in incognito is not restricted (chrome/browser/ui/browser_commands.cc:2442-2459), and the Print Preview UI requests extension destinations unconditionally (chrome/browser/resources/print_preview/data/destination_store.ts:588-593).

The generic defense for this exact situation is the EventRouter’s cross-incognito drop: OTR-scoped events are withheld from extensions unless util::IsIncognitoEnabled (spanning mode). PrinterProvider events opt out of it structurally — the printer-provider KeyedService is redirected to the original profile and its events carry no context restriction — and neither Print Preview nor the API adds its own gate. Result: the not-consented extension’s regular-profile service worker is woken to populate the incognito destination list, and on Print receives onPrintRequested with the incognito tab title plus the full rendered document (PDF or PWG raster), auto-fetched as a Blob by the API’s custom bindings.

Executed proof of concept confirmed the complete flow: with chrome.extension.isAllowedIncognitoAccess() returning false (and no incognito grant in profile prefs), the extension’s destination appeared in the incognito print dialog, and after Print the extension received and exfiltrated the incognito tab’s title and a 21 KB PDF whose extracted text contained the incognito page’s per-run secret token. This is a logic flaw (CWE-863) — no memory corruption is claimed.

Chrome’s sibling surfaces show the intended design: toolbar (chrome/browser/ui/toolbar/toolbar_actions_model.cc:344-351), side panel (chrome/browser/ui/extensions/extension_side_panel_manager.cc:90-102), and context menus (chrome/browser/extensions/context_menu_matcher.cc:300,318-323) all hide extension surfaces in incognito unless the extension is incognito-enabled. The print destination picker gives no indication of the extension’s incognito-consent status, so per-interaction destination choice cannot substitute for the consent toggle.


Root Cause Analysis

  1. Incognito print preview runs in the OTR profile: chrome/browser/printing/print_preview_dialog_controller.cc:461-464 — the constrained dialog is created with initiator->GetBrowserContext(), which is the OTR profile for an incognito tab.
  2. Extension printer handler bound to the OTR profile: chrome/browser/ui/webui/print_preview/print_preview_handler.cc:1108-1112 — CreateForExtensionPrinters(Profile::FromWebUI(web_ui())).
  3. The service silently hops to the original profile: extensions/browser/api/printer_provider/printer_provider_api_factory.cc:52-56 returns GetContextRedirectedToOriginal(context) (kServiceRedirectedInIncognito = true, printer_provider_internal_api.h:58). From here on, the OTR provenance of the request is lost.
  4. All four events carry a null restrict_to_browser_context: extensions/browser/api/printer_provider/printer_provider_api.cc:561-564 (onGetPrintersRequested, broadcast), :601-604 (onGetCapabilityRequested), :648-651 (onPrintRequested), :686-689 (onGetUsbPrinterInfoRequested) all use the 3-argument Event constructor, which delegates with nullptr (extensions/browser/event_router.cc:2011-2014).
    // [`extensions/browser/api/printer_provider/printer_provider_api.cc:648-652`](https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/printer_provider/printer_provider_api.cc;l=648)
    auto event = std::make_unique<Event>(
        events::PRINTER_PROVIDER_ON_PRINT_REQUESTED,
        api::printer_provider::OnPrintRequested::kEventName,
        std::move(internal_args));           // <-- no restrict_to_browser_context
    event_router->DispatchEventToExtension(extension_id, std::move(event));
    
  5. The cross-incognito filter never engages: extensions/browser/events/event_dispatch_helper.cc:39-42 — CrossesIncognito() requires a non-null restrict_to_browser_context; with null, CanDispatchEventToBrowserContext (:46-57) returns true without ever consulting CanExtensionCrossIncognito (chrome/browser/extensions/chrome_extensions_browser_client.cc:421-425 → util::CanCrossIncognito, extensions/browser/extension_util.cc:153-161).
    // [`extensions/browser/events/event_dispatch_helper.cc:39-42`](https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/events/event_dispatch_helper.cc;l=39)
    bool CrossesIncognito(const BrowserContext& context, const Event& event) {
      return event.restrict_to_browser_context &&   // <-- null => never crosses
             &context != event.restrict_to_browser_context;
    }
    
  6. No compensating gate anywhere on the path: chrome/browser/ui/webui/print_preview/extension_printer_handler.cc:160-273 forwards enumeration/capability/print/USB-grant unconditionally; grep of chrome/browser/ui/webui/print_preview/, chrome/browser/resources/print_preview/, and extensions/browser/api/printer_provider/ finds no IsIncognitoEnabled/CanCrossIncognito check (the only “incognito” hit is the redirect declaration itself). The will_dispatch_callback (printer_provider_api.cc:786-805) checks only listener existence.
  7. The payload is the incognito title plus the full document: print_preview_handler.cc:748-759 calls StartPrint(print_preview_ui()->initiator_title(), ...) with the incognito tab’s title (print_preview_dialog_controller.cc:496-508 → print_view_manager_base.cc:439-444, web_contents()->GetTitle()) and the complete rendered document. ExtensionPrinterHandler::StartPrint (extension_printer_handler.cc:204-245) packs both into the PrinterProviderPrintJob; the custom bindings auto-fetch the bytes as a Blob before the listener runs (extensions/renderer/resources/printer_provider_custom_bindings.js:89-116, served by extensions/browser/api/printer_provider/printer_provider_internal_api.cc:173-198).

Secondary consequences of the same pattern: (a) onGetPrintersRequested is a broadcast, so every printerProvider extension without incognito consent is woken to service incognito UI and can place attacker-named destinations into the incognito dialog (the event itself carries no context info, so the extension cannot tell it came from incognito until a job arrives); (b) on ChromeOS, the provisional-USB flow (extension_printer_handler.cc:247-273) likewise dispatches onGetUsbPrinterInfoRequested to non-consented extensions from an incognito dialog. Additionally, sticky settings fall through the incognito pref overlay to the regular profile (components/sync_preferences/pref_service_syncable.cc:177-190; kPrintPreviewStickySettings is not in chrome/browser/prefs/pref_service_incognito_allowlist.cc), so a previously used extension destination can even be preselected in the incognito dialog — this variant is code-verified but was not exercised by the executed reproduction.

Reproduction

  1. Install an MV3 extension with only "permissions": ["printerProvider"] whose service worker answers onGetPrintersRequested with a printer (e.g. “Save to MyPrint”), onGetCapabilityRequested with a PDF-capable CDD, and forwards onPrintRequested job title + document. Leave “Allow in incognito” OFF (default).
  2. Open an incognito window, navigate to a sensitive page, press Ctrl+P.
  3. In the destination picker choose “See more…” → “Save to MyPrint”, then click Print.
  4. Expected: a correct implementation would omit the destination from the incognito dialog (as the toolbar/side-panel/context-menu surfaces do) or refuse the dispatch, exactly as the cross-incognito event filter does for context-restricted events.

Observed (executed, unmodified ASan build, Chrome 156.0.8065.0): the non-consented extension’s destination was listed in the incognito dialog; on Print, the extension’s regular-profile service worker received onPrintRequested carrying the incognito tab’s title and the complete rendered 21 KB PDF (extracted text contained the incognito page’s secret token), and exfiltrated both to a remote endpoint — while chrome.extension.isAllowedIncognitoAccess() reported false and profile prefs held no incognito grant. All 13 automated checks passed.

Severity

medium — Browser-process authorization logic flaw with demonstrated information disclosure; no memory corruption. It meets the explicit bar in extensions/docs/security_faq.md:410-420 (“If an extension is able to access incognito contexts without this setting enabled, this may be a security bug”), and fits the severity-guidelines S2 tier (read limited amounts of information): disclosure is bounded to the tab title plus documents the user chooses to print from incognito. Not low — preconditions are routine (stable API category, default toggle state, ordinary print flow, ordinary destination selection); not high — no cross-origin/site-isolation break and no memory safety impact. Consistent with the accepted same-family precedent (spanning-mode side panel served to non-consented extensions in incognito, CWE-863, medium), with a strictly stronger payload here.

Suggested Fix Direction

Gate the print-preview extension surface the way sibling surfaces do: in extension_printer_handler.cc, when profile_->IsOffTheRecord(), filter destinations/capabilities/print/USB-grant to extensions with extensions::util::IsIncognitoEnabled() (a build-verified patch of this shape removes the leak with no regular-profile regression). A complete fix should also set restrict_to_browser_context (or an equivalent context check) on printerProvider event dispatch so the EventRouter’s cross-incognito filter functions as intended for this API.


POC: Printing from incognito to an extension-provided printer delivers the incognito page’s title and full rendered document to an extension without “Allow in incognito”

Strategy: standalone (attacker-controlled printerProvider extension + scripted victim-user flow against an unmodified Chromium build) Bug Type: authorization logic flaw (CWE-863) — incognito consent bypass; no memory corruption, so no ASan report is expected

Every chrome.printerProvider event is dispatched with a null restrict_to_browser_context (extensions/browser/api/printer_provider/printer_provider_api.cc), so the EventRouter’s cross-incognito filter (extensions/browser/events/event_dispatch_helper.cc — CrossesIncognito()) never engages, and the print-preview extension-printer path (chrome/browser/ui/webui/print_preview/extension_printer_handler.cc) has no IsIncognitoEnabled gate of its own. Result: printing from an incognito tab to an extension destination hands the incognito tab’s title and the complete rendered document to an extension whose “Allow in incognito” toggle is OFF (the default) — the exact bar in extensions/docs/security_faq.md (“If an extension is able to access incognito contexts without this setting enabled, this may be a security bug”).

What the POC does

  1. proof-extension/ — a minimal MV3 extension (“MyPrint Saver”, attacker role) with only the stable printerProvider permission. It registers a virtual printer Save to MyPrint and exfiltrates every print job it receives (job.title + job.document Blob) to an attacker server on 127.0.0.1:8080. “Allow in incognito” is never enabled for it.
  2. poc.py — orchestrates the victim flow on an unmodified Chromium:
    • starts the attacker/collection server (also serves the victim’s sensitive page secret.html, containing a per-run secret token);
    • launches Chromium with the extension installed and an incognito window on the sensitive page;
    • simulates the user: opens print preview (Ctrl+P equivalent), picks the extension destination in the chrome://print dialog (“See more…” → “Save to MyPrint”), waits for the preview to finish, clicks Print;
    • verifies the extension (running in the regular profile, with no incognito consent) received onPrintRequested with the incognito tab’s title and the full rendered PDF, and that both were exfiltrated.

All automation only performs actions the victim user would perform; nothing in Chromium is modified or bypassed.

Prerequisites

  • Chromium desktop build (used here: ASan build out/asan/chrome, Chrome/156.0.8065.0; the bug is not ASan/debug-specific).
  • Headful X display, Python 3 (stdlib only), scrot (optional, screenshot).

Reproduction

cd /poc-artifacts
python3 poc.py            # uses /chromium_src/src/out/asan/chrome
# optional overrides:
#   POC_CHROME=/path/to/chrome POC_ARTIFACTS=/path/out python3 poc.py

The script drives Chrome over --remote-debugging-pipe, prints a PASS/FAIL check list, and exits 0 only if the full leak reproduced. Manual reproduction (any desktop Chrome ≥ stable, no flags needed): load proof-extension unpacked (chrome://extensions → Developer mode → Load unpacked), leave “Allow in incognito” OFF, serve any page, open it in an incognito window, Ctrl+P → destination “See more…” → “Save to MyPrint” → Print, and observe the extension’s service worker receive title + document.

Launch flags used by the script and why (none affect the vulnerability):

  • --load-extension — installs the unpacked extension, equivalent to a normal user install of a printerProvider extension.
  • --incognito — opens the startup URL in an incognito window (same as the user pressing Ctrl+Shift+N and navigating).
  • --user-data-dir, --no-first-run, --no-default-browser-check, --password-store=basic, --disable-search-engine-choice-screen, --no-proxy-server, --window-size/position, --remote-debugging-pipe — standard clean-profile/automation plumbing.
  • --disable-gpu — accommodation for this sandbox’s software-GL X server: with the default GPU path, the print-preview PDF viewer never reports load-complete, which stalls the preview UI spinner (an environment rendering quirk; the preview document itself is generated either way and the vulnerable path is unrelated to GPU).

Expected Result

RESULT: VULNERABILITY REPRODUCED and 13/13 PASS checks, including:

  • extension reports isAllowedIncognitoAccess() == false — the extension itself confirms it has no incognito consent (chrome.extension.isAllowedIncognitoAccess), and the profile’s Preferences contain no incognito grant for it;
  • extension destination listed in INCOGNITO print preview — the non-consented extension is woken and its destination offered in the incognito dialog (see screenshot-incognito-preview.png);
  • exfiltrated job title == INCOGNITO tab title;
  • exfiltrated PDF contains the incognito page's secret text — the full rendered document reached the extension.

Artifacts captured from the successful run:

  • poc_output.log — full run log (all checks PASS).
  • screenshot-incognito-preview.png — incognito window, rendered preview of the confidential page, destination “Save to MyPrint”, Print button.
  • exfiltrated_title.txt — the incognito tab title received by the extension.
  • exfiltrated.pdf — the complete rendered document received by the extension (21 KB PDF).
  • exfiltrated_pdf_text.txt — text extracted from that PDF; contains the page’s secret token (SECRETPAYLOAD<nonce>), proving full-content leak.

Output

CHECK [PASS] extension service worker started
CHECK [PASS] extension reports isAllowedIncognitoAccess() == false (reported: false)
CHECK [PASS] incognito tab shows the sensitive page (title: 'INCOGNITO-SECRET-8df8a7beb21a Confidential Patient Record')
CHECK [PASS] extension destination listed in INCOGNITO print preview
CHECK [PASS] extension destination selected
CHECK [PASS] print button enabled
CHECK [PASS] print button clicked
SERVER /status <- {'event': 'onPrintRequested-arrived', 'hasDoc': 'true', 'docSize': '21416'}
SERVER /exfil <- title='INCOGNITO-SECRET-8df8a7beb21a Confidential Patient Record' contentType='application/pdf' bytes=21416
CHECK [PASS] extension received onPrintRequested and exfiltrated data
CHECK [PASS] exfiltrated job title == INCOGNITO tab title (got: 'INCOGNITO-SECRET-8df8a7beb21a Confidential Patient Record')
CHECK [PASS] exfiltrated document is a PDF (contentType='application/pdf', 21416 bytes, b'%PDF-1.4')
CHECK [PASS] exfiltrated PDF contains the incognito page's secret text (token=True, nonce=True, 2 font cmaps)
CHECK [PASS] extension was woken/queried by the incognito print preview
CHECK [PASS] profile prefs confirm no incognito grant
RESULT: VULNERABILITY REPRODUCED
An extension with 'Allow in incognito' OFF received the incognito tab's title and full rendered document.

Extracted text of exfiltrated.pdf (see exfiltrated_pdf_text.txt) — the extension received the whole rendered page, not just metadata:

Confidential patient record (incognito browsing) This page is only ever
opened in an Incognito window. SECRETPAYLOAD8df8a7beb21a Diagnosis:
SECRETPAYLOAD8df8a7beb21a must never reach an extension that was not
granted "Allow in incognito". 9/18/26, 11:08 AM INCOGNITO-SECRET-8df8a7beb21a
Confidential Patient Record 127.0.0.1:8080/secret.html 1/1

Bandaid fix

bandaid-fix.patch (against chrome/browser/ui/webui/print_preview/extension_printer_handler.cc) adds the missing gate at the print-preview boundary, mirroring the sibling surfaces (ToolbarActionsModel::ShouldAddExtension, ExtensionSidePanelManager, ContextMenuMatcher): when the handler’s profile is off-the-record and extensions::util::IsIncognitoEnabled() is false for the extension, it (a) filters that extension’s printers out of the reported destinations (regular + provisional USB), (b) refuses capability requests, (c) refuses StartPrint (the title/document payload), and (d) refuses the USB grant flow.

Verification (see poc_output_fixed.log / poc_output_fixed_regular.log, and screenshot-incognito-preview-fixed.png showing the incognito dialog offering only “Save as PDF”): with the patch applied and Chrome rebuilt, the same POC no longer finds the extension destination in the incognito dialog (leak unreachable), while in a regular window the destination is still offered (no functional regression). The source tree was reverted to its original state after this verification. A complete upstream fix should additionally scope the printerProvider event dispatch itself (the null-restrict_to_browser_context broadcast still wakes non-consented extensions for enumeration).


Attached file hierarchy:

The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.

  • proof-extension__bg.js → proof-extension/bg.js
  • proof-extension__manifest.json → proof-extension/manifest.json

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
Links in the report