This commit is contained in:
Jeff Lyon 2026-05-04 09:11:50 +12:00 committed by GitHub
commit ed3aa580bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 214 additions and 15 deletions

View file

@ -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

View file

@ -172,14 +172,16 @@ 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) |
+------------------------+-----------------------------------------+-------+
| ``'chrome'`` | ``MacOS('google chrome')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'firefox'`` | ``MacOS('firefox')`` | \(3) |
+------------------------+-----------------------------------------+-------+
| ``'google-chrome'`` | ``Chrome('google-chrome')`` | |
+------------------------+-----------------------------------------+-------+
| ``'chrome'`` | ``Chrome('chrome')`` | |
+------------------------+-----------------------------------------+-------+
| ``'chromium'`` | ``Chromium('chromium')`` | |
+------------------------+-----------------------------------------+-------+
| ``'chromium-browser'`` | ``Chromium('chromium-browser')`` | |
@ -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/'

View file

@ -1546,6 +1546,19 @@ wave
(Contributed by Lionel Koenig and Michiel W. Beijen in :gh:`60729`.)
webbrowser
----------
* On macOS, the new :class:`!webbrowser.MacOS` class opens URLs via
:program:`/usr/bin/open` instead of constructing and executing AppleScript
via :program:`osascript`. The default browser is detected from the
LaunchServices preferences file using :mod:`plistlib`, with
:class:`!com.apple.Safari` as the fallback on fresh installations.
For non-HTTP(S) URLs, :program:`open -b <bundle-id>` is used to route the
URL through a browser rather than the OS file handler, preventing
file injection attacks.
(Contributed by Jeff Lyon in :gh:`137586`.)
xml.parsers.expat
-----------------
@ -1961,6 +1974,12 @@ New deprecations
merely imported or accessed from the :mod:`!typing` module.
* :mod:`webbrowser`:
* :class:`!webbrowser.MacOSXOSAScript` is deprecated in favour of
:class:`!webbrowser.MacOS` and scheduled for removal in Python 3.17.
(Contributed by Jeff Lyon in :gh:`137586`.)
* ``__version__``
* The ``__version__``, ``version`` and ``VERSION`` attributes have been

View file

@ -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
@ -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):
@ -345,17 +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"
self.browser.open(url)
@ -381,7 +456,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)

View file

@ -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
@ -492,10 +493,15 @@ def register_standard_browsers():
_tryorder = []
if sys.platform == 'darwin':
register("MacOSX", None, MacOSXOSAScript('default'))
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)
@ -614,8 +620,80 @@ def open(self, url, new=0, autoraise=True):
#
if sys.platform == 'darwin':
def _macos_default_browser_bundle_id():
"""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)
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',
'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):

View file

@ -0,0 +1,3 @@
Add :class:`!MacOS` 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`.

View file

@ -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.