diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 389648d4f393e4..e74c0f8215a310 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -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')`` | | +------------------------+-----------------------------------------+-------+ @@ -207,7 +209,7 @@ Notes: .. versionadded:: 3.2 A new :class:`!MacOSXOSAScript` class has been added - and is used on Mac instead of the previous :class:`!MacOSX` class. + and is used on Mac instead of the previous :class:`!MacOS` class. This adds support for opening browsers not currently set as the OS default. .. versionadded:: 3.3 @@ -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:: next + :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/' diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index d5bb1400d2717a..33c98bd2e80912 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -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 @@ -323,6 +324,97 @@ 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 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.apple.Safari'), \ + 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.apple.Safari', file_url], + stderr=subprocess.DEVNULL, + ) + self.assertTrue(result) + + def test_default_non_http_fallback_when_no_bundle_id(self): + # If the bundle ID lookup fails, fall back to /usr/bin/open without -b. + file_url = 'file:///tmp/test.html' + browser = webbrowser.MacOS('default') + with mock.patch('webbrowser._macos_default_browser_bundle_id', + return_value=None), \ + mock.patch('subprocess.run') as mock_run: + mock_run.return_value = mock.Mock(returncode=0) + browser.open(file_url) + mock_run.assert_called_once_with( + ['/usr/bin/open', file_url], + stderr=subprocess.DEVNULL, + ) + + 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.MacOSOSAScript('default') + + @unittest.skipUnless(sys.platform == "darwin", "macOS specific test") @requires_subprocess() class MacOSXOSAScriptTest(unittest.TestCase): @@ -334,16 +426,14 @@ def setUp(self): env.unset("BROWSER") support.patch(self, os, "popen", self.mock_popen) - self.browser = webbrowser.MacOSXOSAScript("default") + self.enterContext(warnings.catch_warnings()) + warnings.simplefilter("ignore", DeprecationWarning) + self.browser = webbrowser.MacOSOSAScript("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" @@ -370,7 +460,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.MacOSOSAScript("safari") browser.open("https://python.org") script = self.popen_pipe.pipe.getvalue() self.assertIn('tell application "safari"', script) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 9ead2990e818e5..129eb1038049de 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -491,10 +491,13 @@ 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("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')) # macOS can use below Unix support (but we prefer using the macOS # specific stuff) @@ -613,8 +616,84 @@ 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 None if the file + is absent or no https handler is recorded. + """ + import plistlib, os + plist = os.path.expanduser( + '~/Library/Preferences/com.apple.LaunchServices/' + 'com.apple.launchservices.secure.plist' + ) + try: + with 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 Exception: + pass + return None + + 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 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 = frozendict({ + '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', + }) + + 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() + if bundle_id: + cmd = ['/usr/bin/open', '-b', bundle_id, url] + else: + cmd = ['/usr/bin/open', 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.warn( + "MacOSXOSAScript is deprecated, use MacOS instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(name) def open(self, url, new=0, autoraise=True): diff --git a/Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst b/Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst new file mode 100644 index 00000000000000..bac380811f8275 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst @@ -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:`!MacOSX`. diff --git a/Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst b/Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst new file mode 100644 index 00000000000000..640d4caf4f732f --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst @@ -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:`!MacOSX` +class uses ``/usr/bin/open`` directly, eliminating the dependency on +``osascript`` entirely.