https://github.com/python/cpython/commit/aeb02ac42b113bff8218df890b7102d8abc66a9d
commit: aeb02ac42b113bff8218df890b7102d8abc66a9d
branch: main
author: Jeff Lyon <[email protected]>
committer: hugovk <[email protected]>
date: 2026-05-06T16:56:17+03:00
summary:

gh-137586: Replace 'osascript' with 'open' on macOS in webbrowser (#146439)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: Gregory P. Smith <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst
A Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst
M Doc/deprecations/pending-removal-in-3.17.rst
M Doc/library/webbrowser.rst
M Doc/whatsnew/3.15.rst
M Lib/test/test_webbrowser.py
M Lib/webbrowser.py

diff --git a/Doc/deprecations/pending-removal-in-3.17.rst 
b/Doc/deprecations/pending-removal-in-3.17.rst
index 952ffad64356d9..8ee7f335cc9514 100644
--- a/Doc/deprecations/pending-removal-in-3.17.rst
+++ b/Doc/deprecations/pending-removal-in-3.17.rst
@@ -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
diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst
index 389648d4f393e4..30e4df1688d7a0 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')``                |       |
 +------------------------+-----------------------------------------+-------+
@@ -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/'
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 1043fe08d5b075..6007d772f8e2d7 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -1693,6 +1693,20 @@ 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
 ---
 
@@ -2132,6 +2146,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
diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py
index 2ba3af8d5bf22f..51d627d24c5a8a 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
@@ -335,6 +336,83 @@ def close(self):
         return None
 
 
[email protected](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)
+
+
[email protected](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)
diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py
index c2ee0df0ef8885..ec8b544a6b0523 100644
--- a/Lib/webbrowser.py
+++ b/Lib/webbrowser.py
@@ -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):
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..70122c8ceae507
--- /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:`!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`.
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..ce9387adc069a8
--- /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:`!MacOS`
+class uses ``/usr/bin/open`` directly, eliminating the dependency on
+``osascript`` entirely.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to