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
+------------------------+-----------------------------------------+-------+
| ``'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')`` | |
+------------------------+-----------------------------------------+-------+
.. 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/'
(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
---
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
import subprocess
import sys
import unittest
+import warnings
import webbrowser
from test import support
from test.support import force_not_colorized_test_class
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):
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)
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)
"""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
_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)
#
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):
--- /dev/null
+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`.
--- /dev/null
+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.