Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
00fa6c7
gh-137586: Add MacOSX browser class using /usr/bin/open, deprecate Ma…
secengjeff Mar 26, 2026
f697cd5
gh-137586: Add tests for MacOSX browser class and MacOSXOSAScript dep…
secengjeff Mar 26, 2026
7781033
gh-137586: Document MacOSXOSAScript deprecation in webbrowser docs
secengjeff Mar 26, 2026
6066221
gh-137586: Add NEWS entries for MacOSX webbrowser change
secengjeff Mar 26, 2026
d54293f
gh-137586: Fix NEWS entry class references with ! prefix to suppress …
secengjeff Mar 26, 2026
080197e
gh-137586: Fix MacOSXOSAScriptTest for MacOSX registration change
secengjeff Mar 26, 2026
fdd6649
gh-137586: Use bundle IDs in MacOSX to prevent file injection via OS …
secengjeff Mar 26, 2026
8e1eef4
gh-137586: Load AppKit before NSWorkspace lookup in _macos_default_br…
secengjeff Mar 26, 2026
e193626
gh-137586: Register chromium, opera, microsoft-edge in register_stand…
secengjeff Apr 6, 2026
98dd1d8
gh-137586: Fix Microsoft Edge bundle ID on macOS (com.microsoft.edgemac)
secengjeff Apr 6, 2026
bdfc2e6
gh-137586: Replace _macos_default_browser_bundle_id with plistlib to …
secengjeff Apr 6, 2026
614078f
gh-137586: Rename MacOSX to MacOS
secengjeff Apr 6, 2026
c77f4b8
gh-137586: Use frozendict for MacOS._BUNDLE_IDS
secengjeff Apr 6, 2026
c725927
gh-137586: Update webbrowser.rst for MacOS class, fix version directives
secengjeff Apr 6, 2026
1316dbf
gh-137586: Revert frozendict for MacOS._BUNDLE_IDS pending frozendict…
secengjeff Apr 6, 2026
612d276
gh-137586: Fix MacOSXOSAScript mangled to MacOSOSAScript in test rename
secengjeff Apr 6, 2026
386af23
Update Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.K…
secengjeff Apr 23, 2026
e4e89f1
Update Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.…
secengjeff Apr 23, 2026
b513903
Update Doc/library/webbrowser.rst
secengjeff Apr 23, 2026
aacbceb
Update Doc/library/webbrowser.rst
secengjeff Apr 23, 2026
a6d76d6
gh-137586: Fix MacOS plist fallback, builtins.open shadow, add Brave
secengjeff Apr 23, 2026
5a4fdad
Merge branch 'main' into gh-137586-macosx-open
secengjeff Apr 23, 2026
de9ec77
gh-137586: Address hugovk review comments
secengjeff Apr 23, 2026
6ddf67f
lazy import plistlib
gpshead Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Doc/deprecations/pending-removal-in-3.17.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ Pending removal in Python 3.17
is deprecated and scheduled for removal in Python 3.17.
(Contributed by Stan Ulbrych in :gh:`136702`.)

* :mod:`webbrowser`:

- :class:`!webbrowser.MacOSXOSAScript` is deprecated in favour of
:class:`!webbrowser.MacOS`. (:gh:`137586`)

* :mod:`typing`:

- Before Python 3.14, old-style unions were implemented using the private class
Expand Down
21 changes: 17 additions & 4 deletions Doc/library/webbrowser.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,15 @@ for the controller classes, all defined in this module.
+------------------------+-----------------------------------------+-------+
| ``'windows-default'`` | ``WindowsDefault`` | \(2) |
+------------------------+-----------------------------------------+-------+
| ``'macosx'`` | ``MacOSXOSAScript('default')`` | \(3) |
| ``'macos'`` | ``MacOS('default')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'safari'`` | ``MacOSXOSAScript('safari')`` | \(3) |
| ``'safari'`` | ``MacOS('safari')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'google-chrome'`` | ``Chrome('google-chrome')`` | |
| ``'chrome'`` | ``MacOS('google chrome')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'firefox'`` | ``MacOS('firefox')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'chrome'`` | ``Chrome('chrome')`` | |
| ``'google-chrome'`` | ``Chrome('google-chrome')`` | |
+------------------------+-----------------------------------------+-------+
| ``'chromium'`` | ``Chromium('chromium')`` | |
+------------------------+-----------------------------------------+-------+
Expand Down Expand Up @@ -221,6 +223,17 @@ Notes:
.. versionchanged:: 3.13
Support for iOS has been added.

.. versionadded:: next
:class:`!MacOS` has been added as a replacement for :class:`!MacOSXOSAScript`,
opening browsers via :program:`/usr/bin/open` instead of :program:`osascript`.

.. deprecated-removed:: next 3.17
:class:`!MacOSXOSAScript` is deprecated in favour of :class:`!MacOS`.
Using :program:`/usr/bin/open` instead of :program:`osascript` is a
security and usability improvement: :program:`osascript` may be blocked
on managed systems due to its abuse potential as a general-purpose
scripting interpreter.

Here are some simple examples::

url = 'https://docs.python.org/'
Expand Down
88 changes: 83 additions & 5 deletions Lib/test/test_webbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
import sys
import unittest
import warnings
import webbrowser
from test import support
from test.support import force_not_colorized_test_class
Expand Down Expand Up @@ -335,6 +336,83 @@ def close(self):
return None


@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
@requires_subprocess()
class MacOSTest(unittest.TestCase):

def test_default(self):
browser = webbrowser.get()
self.assertIsInstance(browser, webbrowser.MacOS)
self.assertEqual(browser.name, 'default')

def test_default_http_open(self):
# http/https URLs use /usr/bin/open directly — no bundle ID needed.
browser = webbrowser.MacOS('default')
with mock.patch('subprocess.run') as mock_run:
mock_run.return_value = mock.Mock(returncode=0)
result = browser.open(URL)
mock_run.assert_called_once_with(
['/usr/bin/open', URL],
stderr=subprocess.DEVNULL,
)
self.assertTrue(result)

def test_default_non_http_uses_bundle_id(self):
# Non-http(s) URLs (e.g. file://) must be routed through the browser
# via -b <bundle-id> to prevent OS file handler dispatch.
file_url = 'file:///tmp/test.html'
browser = webbrowser.MacOS('default')
with mock.patch('webbrowser._macos_default_browser_bundle_id',
return_value='com.google.Chrome'), \
mock.patch('subprocess.run') as mock_run:
mock_run.return_value = mock.Mock(returncode=0)
result = browser.open(file_url)
mock_run.assert_called_once_with(
['/usr/bin/open', '-b', 'com.google.Chrome', file_url],
stderr=subprocess.DEVNULL,
)
self.assertTrue(result)

def test_named_known_browser_uses_bundle_id(self):
# Named browsers with a known bundle ID use /usr/bin/open -b.
browser = webbrowser.MacOS('safari')
with mock.patch('subprocess.run') as mock_run:
mock_run.return_value = mock.Mock(returncode=0)
result = browser.open(URL)
mock_run.assert_called_once_with(
['/usr/bin/open', '-b', 'com.apple.Safari', URL],
stderr=subprocess.DEVNULL,
)
self.assertTrue(result)

def test_named_unknown_browser_falls_back_to_dash_a(self):
# Named browsers not in the bundle ID map fall back to -a.
browser = webbrowser.MacOS('lynx')
with mock.patch('subprocess.run') as mock_run:
mock_run.return_value = mock.Mock(returncode=0)
browser.open(URL)
mock_run.assert_called_once_with(
['/usr/bin/open', '-a', 'lynx', URL],
stderr=subprocess.DEVNULL,
)

def test_open_failure(self):
browser = webbrowser.MacOS('default')
with mock.patch('subprocess.run') as mock_run:
mock_run.return_value = mock.Mock(returncode=1)
result = browser.open(URL)
self.assertFalse(result)


@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
@requires_subprocess()
class MacOSXOSAScriptDeprecationTest(unittest.TestCase):

def test_deprecation_warning(self):
with self.assertWarns(DeprecationWarning):
webbrowser.MacOSXOSAScript('default')


@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
@requires_subprocess()
class MacOSXOSAScriptTest(unittest.TestCase):
Expand All @@ -345,16 +423,14 @@ def setUp(self):
env.unset("BROWSER")

support.patch(self, os, "popen", self.mock_popen)
self.enterContext(warnings.catch_warnings())
warnings.simplefilter("ignore", DeprecationWarning)
self.browser = webbrowser.MacOSXOSAScript("default")

def mock_popen(self, cmd, mode):
self.popen_pipe = MockPopenPipe(cmd, mode)
return self.popen_pipe

def test_default(self):
browser = webbrowser.get()
assert isinstance(browser, webbrowser.MacOSXOSAScript)
self.assertEqual(browser.name, "default")

def test_default_open(self):
url = "https://python.org"
Expand All @@ -381,7 +457,9 @@ def test_default_browser_lookup(self):
self.assertIn(f'open location "{url}"', script)

def test_explicit_browser(self):
browser = webbrowser.MacOSXOSAScript("safari")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
browser = webbrowser.MacOSXOSAScript("safari")
browser.open("https://python.org")
script = self.popen_pipe.pipe.getvalue()
self.assertIn('tell application "safari"', script)
Expand Down
88 changes: 83 additions & 5 deletions Lib/webbrowser.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Interfaces for launching and remotely controlling web browsers."""
# Maintained by Georg Brandl.

import builtins # because we override open
import os
lazy import plistlib
import shlex
import shutil
import sys
Expand Down Expand Up @@ -492,10 +493,15 @@ def register_standard_browsers():
_tryorder = []

if sys.platform == 'darwin':
register("MacOSX", None, MacOSXOSAScript('default'))
Comment thread
gpshead marked this conversation as resolved.
register("chrome", None, MacOSXOSAScript('google chrome'))
register("firefox", None, MacOSXOSAScript('firefox'))
register("safari", None, MacOSXOSAScript('safari'))
register("MacOS", None, MacOS('default'))
register("MacOSX", None, MacOS('default')) # backward compat alias
register("chrome", None, MacOS('google chrome'))
register("chromium", None, MacOS('chromium'))
register("firefox", None, MacOS('firefox'))
register("safari", None, MacOS('safari'))
register("opera", None, MacOS('opera'))
register("microsoft-edge", None, MacOS('microsoft edge'))
register("brave", None, MacOS('brave browser'))
# macOS can use below Unix support (but we prefer using the macOS
# specific stuff)

Expand Down Expand Up @@ -614,8 +620,80 @@ def open(self, url, new=0, autoraise=True):
#

if sys.platform == 'darwin':
def _macos_default_browser_bundle_id():
Comment thread
gpshead marked this conversation as resolved.
"""Return the bundle ID of the default web browser.

Reads the LaunchServices preferences file that macOS maintains
when the user sets a default browser. Returns 'com.apple.Safari'
if the file is absent or no https handler is recorded, because on
a fresh macOS installation Safari is the default browser and the
LaunchServices plist is not written until the user explicitly
changes their default browser.
"""
plist = os.path.expanduser(
'~/Library/Preferences/com.apple.LaunchServices/'
'com.apple.launchservices.secure.plist'
)
try:
with builtins.open(plist, 'rb') as f:
data = plistlib.load(f)
Comment thread
gpshead marked this conversation as resolved.
for handler in data.get('LSHandlers', []):
if handler.get('LSHandlerURLScheme') == 'https':
return (handler.get('LSHandlerRoleAll')
or handler.get('LSHandlerRoleViewer'))
except (OSError, KeyError, ValueError):
pass
return 'com.apple.Safari'

class MacOS(BaseBrowser):
"""Launcher class for macOS browsers, using /usr/bin/open.

For http/https URLs with the default browser, /usr/bin/open is called
directly; macOS routes these to the registered browser.

For all other URL schemes (e.g. file://) and for named browsers,
/usr/bin/open -b <bundle-id> is used so that the URL is always passed
to a browser application rather than dispatched by the OS file handler.
This prevents file injection attacks where a file:// URL pointing to an
executable bundle could otherwise be launched by the OS.

Named browsers with known bundle IDs use -b; unknown names fall back
to -a.
"""

_BUNDLE_IDS = {
'google chrome': 'com.google.Chrome',
'firefox': 'org.mozilla.firefox',
'safari': 'com.apple.Safari',
'chromium': 'org.chromium.Chromium',
'opera': 'com.operasoftware.Opera',
Comment thread
hugovk marked this conversation as resolved.
'microsoft edge': 'com.microsoft.edgemac',
'brave browser': 'com.brave.Browser',
}

def open(self, url, new=0, autoraise=True):
sys.audit("webbrowser.open", url)
self._check_url(url)
if self.name == 'default':
proto, sep, _ = url.partition(':')
if sep and proto.lower() in {'http', 'https'}:
cmd = ['/usr/bin/open', url]
else:
bundle_id = _macos_default_browser_bundle_id()
cmd = ['/usr/bin/open', '-b', bundle_id, url]
else:
bundle_id = self._BUNDLE_IDS.get(self.name.lower())
if bundle_id:
cmd = ['/usr/bin/open', '-b', bundle_id, url]
else:
cmd = ['/usr/bin/open', '-a', self.name, url]
proc = subprocess.run(cmd, stderr=subprocess.DEVNULL)
return proc.returncode == 0

class MacOSXOSAScript(BaseBrowser):
def __init__(self, name='default'):
import warnings
warnings._deprecated("webbrowser.MacOSXOSAScript", remove=(3, 17))
super().__init__(name)

def open(self, url, new=0, autoraise=True):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add :class:`!MacOSX` to :mod:`webbrowser` for macOS, which opens URLs via
``/usr/bin/open`` instead of piping AppleScript to ``osascript``.
Deprecate :class:`!MacOSXOSAScript` in favour of :class:`!MacOS`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fix a PATH-injection vulnerability in :mod:`webbrowser` on macOS where
``osascript`` was invoked without an absolute path. The new :class:`!MacOS`
class uses ``/usr/bin/open`` directly, eliminating the dependency on
``osascript`` entirely.
Loading