From 00fa6c7145f232e35595f71cebaa3d378f40d847 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 18:31:07 -0700 Subject: [PATCH 01/25] gh-137586: Add MacOSX browser class using /usr/bin/open, deprecate MacOSXOSAScript Add a new MacOSX class that opens URLs via subprocess.run(['/usr/bin/open', ...]) instead of piping AppleScript to osascript. For named browsers, /usr/bin/open -a is used; for the default browser, /usr/bin/open defers directly to the OS URL handler. MacOSXOSAScript is deprecated with a DeprecationWarning pointing users to MacOSX. register_standard_browsers() is updated to use MacOSX for all macOS registrations. osascript is a general-purpose scripting interpreter that is routinely blocked on managed endpoints due to its abuse potential, causing webbrowser.open() to fail silently. /usr/bin/open is Apple's purpose-built URL-opening primitive and carries no such restrictions. This also eliminates the PATH-injection vector in the existing os.popen("osascript", "w") call. --- Lib/webbrowser.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 9ead2990e818e5..0d703aa2d4621e 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -491,10 +491,10 @@ 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("MacOSX", None, MacOSX('default')) + register("chrome", None, MacOSX('google chrome')) + register("firefox", None, MacOSX('firefox')) + register("safari", None, MacOSX('safari')) # macOS can use below Unix support (but we prefer using the macOS # specific stuff) @@ -613,8 +613,27 @@ def open(self, url, new=0, autoraise=True): # if sys.platform == 'darwin': + class MacOSX(BaseBrowser): + """Launcher class for macOS browsers, using /usr/bin/open.""" + + def open(self, url, new=0, autoraise=True): + sys.audit("webbrowser.open", url) + self._check_url(url) + if self.name == 'default': + cmd = ['/usr/bin/open', 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 MacOSX instead.", + DeprecationWarning, + stacklevel=2, + ) super().__init__(name) def open(self, url, new=0, autoraise=True): From f697cd583cb4caa37404a58209cbe64b530482be Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 18:31:16 -0700 Subject: [PATCH 02/25] gh-137586: Add tests for MacOSX browser class and MacOSXOSAScript deprecation Add MacOSXTest covering default browser open, named browser open, and failure case (non-zero returncode). Add MacOSXOSAScriptDeprecationTest verifying that instantiating MacOSXOSAScript emits a DeprecationWarning. All tests mock subprocess.run. --- Lib/test/test_webbrowser.py | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index d5bb1400d2717a..94787bc39b8f7b 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -323,6 +323,49 @@ def close(self): return None +@unittest.skipUnless(sys.platform == "darwin", "macOS specific test") +@requires_subprocess() +class MacOSXTest(unittest.TestCase): + + def test_default_open(self): + browser = webbrowser.MacOSX('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_named_open(self): + browser = webbrowser.MacOSX('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', '-a', 'safari', URL], + stderr=subprocess.DEVNULL, + ) + self.assertTrue(result) + + def test_open_failure(self): + browser = webbrowser.MacOSX('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): From 77810331ec8cf82b6c217aa57b23a7d37c45eba2 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 18:31:21 -0700 Subject: [PATCH 03/25] gh-137586: Document MacOSXOSAScript deprecation in webbrowser docs --- Doc/library/webbrowser.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 389648d4f393e4..ff9e0627c18d16 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -205,6 +205,13 @@ Notes: (4) Only on iOS. +.. deprecated:: 3.14 + :class:`!MacOSXOSAScript` is deprecated in favour of :class:`!MacOSX`. + 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. + .. versionadded:: 3.2 A new :class:`!MacOSXOSAScript` class has been added and is used on Mac instead of the previous :class:`!MacOSX` class. From 60662214f7c8cfc9b36ede6dbc611f137a0deff4 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 18:42:25 -0700 Subject: [PATCH 04/25] gh-137586: Add NEWS entries for MacOSX webbrowser change --- .../Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst | 3 +++ .../Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst create mode 100644 Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst 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..9903fdf93eaae2 --- /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..bfeecfcee0d1fc --- /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. From d54293f0a1906757d88fb5feed5c7aed3746eea0 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 18:47:22 -0700 Subject: [PATCH 05/25] gh-137586: Fix NEWS entry class references with ! prefix to suppress Sphinx lookup --- .../Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst | 4 ++-- .../Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 9903fdf93eaae2..bac380811f8275 100644 --- 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 @@ -1,3 +1,3 @@ -Add :class:`MacOSX` to :mod:`webbrowser` for macOS, which opens URLs via +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`. +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 index bfeecfcee0d1fc..640d4caf4f732f 100644 --- 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 @@ -1,4 +1,4 @@ Fix a PATH-injection vulnerability in :mod:`webbrowser` on macOS where -``osascript`` was invoked without an absolute path. The new :class:`MacOSX` +``osascript`` was invoked without an absolute path. The new :class:`!MacOSX` class uses ``/usr/bin/open`` directly, eliminating the dependency on ``osascript`` entirely. From 080197ecd7ceef50d667ff7c8984eb36f58db14a Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 19:09:35 -0700 Subject: [PATCH 06/25] gh-137586: Fix MacOSXOSAScriptTest for MacOSX registration change - Add test_default to MacOSXTest asserting webbrowser.get() returns MacOSX - Remove test_default from MacOSXOSAScriptTest (no longer the registered default) - Suppress DeprecationWarning in MacOSXOSAScriptTest setUp and test_explicit_browser using warnings.catch_warnings() so tests for OSAScript behaviour still run cleanly - Add warnings import --- Lib/test/test_webbrowser.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 94787bc39b8f7b..827d26eddba271 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 @@ -327,6 +328,11 @@ def close(self): @requires_subprocess() class MacOSXTest(unittest.TestCase): + def test_default(self): + browser = webbrowser.get() + self.assertIsInstance(browser, webbrowser.MacOSX) + self.assertEqual(browser.name, 'default') + def test_default_open(self): browser = webbrowser.MacOSX('default') with mock.patch('subprocess.run') as mock_run: @@ -377,16 +383,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" @@ -413,7 +417,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) From fdd664965f21bf8eb79812c995a0f990eff6743c Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 20:01:44 -0700 Subject: [PATCH 07/25] gh-137586: Use bundle IDs in MacOSX to prevent file injection via OS handler For non-http(s) URLs (e.g. file://), /usr/bin/open dispatches via the OS file handler, which would launch an .app bundle rather than open it in a browser. Fix this by routing non-http(s) URLs through the browser explicitly using /usr/bin/open -b . Named browsers use a static bundle ID map (Chrome, Firefox, Safari, Chromium, Opera, Edge). Unknown named browsers fall back to -a. For the default browser, the bundle ID is resolved at runtime via the Objective-C runtime using NSWorkspace.URLForApplicationToOpenURL, the same lookup MacOSXOSAScript performed via AppleScript. Falls back to direct open if ctypes is unavailable. http/https URLs with the default browser continue to use /usr/bin/open directly, as macOS always routes these to the registered browser. --- Lib/test/test_webbrowser.py | 49 +++++++++++++++++-- Lib/webbrowser.py | 98 +++++++++++++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 827d26eddba271..d0ca2c53338bce 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -333,7 +333,8 @@ def test_default(self): self.assertIsInstance(browser, webbrowser.MacOSX) self.assertEqual(browser.name, 'default') - def test_default_open(self): + def test_default_http_open(self): + # http/https URLs use /usr/bin/open directly — no bundle ID needed. browser = webbrowser.MacOSX('default') with mock.patch('subprocess.run') as mock_run: mock_run.return_value = mock.Mock(returncode=0) @@ -344,17 +345,59 @@ def test_default_open(self): ) self.assertTrue(result) - def test_named_open(self): + 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.MacOSX('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.MacOSX('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.MacOSX('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', '-a', 'safari', URL], + ['/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.MacOSX('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.MacOSX('default') with mock.patch('subprocess.run') as mock_run: diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 0d703aa2d4621e..ef8947122bfb90 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -613,16 +613,108 @@ 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 via NSWorkspace. + + Uses the Objective-C runtime directly to call + NSWorkspace.sharedWorkspace().URLForApplicationToOpenURL() with a + probe https:// URL, then reads the bundle identifier from the + resulting NSBundle. Returns None if ctypes is unavailable or the + lookup fails for any reason. + """ + try: + from ctypes import cdll, c_void_p, c_char_p + from ctypes.util import find_library + + objc = cdll.LoadLibrary(find_library('objc')) + objc.objc_getClass.restype = c_void_p + objc.sel_registerName.restype = c_void_p + objc.objc_msgSend.restype = c_void_p + + def cls(name): + return objc.objc_getClass(name) + + def sel(name): + return objc.sel_registerName(name) + + # Build probe NSURL for "https://python.org" + NSString = cls(b'NSString') + objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p] + ns_str = objc.objc_msgSend( + NSString, sel(b'stringWithUTF8String:'), b'https://python.org' + ) + + NSURL = cls(b'NSURL') + objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] + probe_url = objc.objc_msgSend(NSURL, sel(b'URLWithString:'), ns_str) + + # Ask NSWorkspace which app handles https:// + NSWorkspace = cls(b'NSWorkspace') + objc.objc_msgSend.argtypes = [c_void_p, c_void_p] + workspace = objc.objc_msgSend(NSWorkspace, sel(b'sharedWorkspace')) + + objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] + app_url = objc.objc_msgSend( + workspace, sel(b'URLForApplicationToOpenURL:'), probe_url + ) + + # Get bundle identifier from that app's NSBundle + NSBundle = cls(b'NSBundle') + bundle = objc.objc_msgSend(NSBundle, sel(b'bundleWithURL:'), app_url) + + objc.objc_msgSend.argtypes = [c_void_p, c_void_p] + bundle_id_ns = objc.objc_msgSend(bundle, sel(b'bundleIdentifier')) + + objc.objc_msgSend.restype = c_char_p + bundle_id_bytes = objc.objc_msgSend(bundle_id_ns, sel(b'UTF8String')) + return bundle_id_bytes.decode() if bundle_id_bytes else None + except Exception: + return None + class MacOSX(BaseBrowser): - """Launcher class for macOS browsers, using /usr/bin/open.""" + """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 = { + '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.Edge', + } def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) self._check_url(url) if self.name == 'default': - cmd = ['/usr/bin/open', url] + 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: - cmd = ['/usr/bin/open', '-a', self.name, url] + 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 From 8e1eef4b96c595ab818cec6c8909c801157d169f Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 25 Mar 2026 20:06:27 -0700 Subject: [PATCH 08/25] gh-137586: Load AppKit before NSWorkspace lookup in _macos_default_browser_bundle_id NSWorkspace is an AppKit class and is not registered in the ObjC runtime until AppKit is loaded. Without the explicit LoadLibrary call, objc_getClass returns nil for NSWorkspace, causing the entire lookup to silently fall back to /usr/bin/open without -b. --- Lib/webbrowser.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index ef8947122bfb90..5722916daec5b5 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -626,6 +626,10 @@ def _macos_default_browser_bundle_id(): from ctypes import cdll, c_void_p, c_char_p from ctypes.util import find_library + # NSWorkspace is an AppKit class; load AppKit to register it. + cdll.LoadLibrary( + '/System/Library/Frameworks/AppKit.framework/AppKit' + ) objc = cdll.LoadLibrary(find_library('objc')) objc.objc_getClass.restype = c_void_p objc.sel_registerName.restype = c_void_p @@ -652,18 +656,26 @@ def sel(name): NSWorkspace = cls(b'NSWorkspace') objc.objc_msgSend.argtypes = [c_void_p, c_void_p] workspace = objc.objc_msgSend(NSWorkspace, sel(b'sharedWorkspace')) + if not workspace: + return None objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] app_url = objc.objc_msgSend( workspace, sel(b'URLForApplicationToOpenURL:'), probe_url ) + if not app_url: + return None # Get bundle identifier from that app's NSBundle NSBundle = cls(b'NSBundle') bundle = objc.objc_msgSend(NSBundle, sel(b'bundleWithURL:'), app_url) + if not bundle: + return None objc.objc_msgSend.argtypes = [c_void_p, c_void_p] bundle_id_ns = objc.objc_msgSend(bundle, sel(b'bundleIdentifier')) + if not bundle_id_ns: + return None objc.objc_msgSend.restype = c_char_p bundle_id_bytes = objc.objc_msgSend(bundle_id_ns, sel(b'UTF8String')) From e193626ce48dbce3284e591b6e8941ba44514b56 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 09:22:40 -0700 Subject: [PATCH 09/25] gh-137586: Register chromium, opera, microsoft-edge in register_standard_browsers on macOS These browsers were present in MacOSX._BUNDLE_IDS but not registered, causing webbrowser.get("opera") etc. to raise Error: could not locate runnable browser. Co-Authored-By: Claude Sonnet 4.6 --- Lib/webbrowser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 5722916daec5b5..f4dd493b5dc8d2 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -493,8 +493,11 @@ def register_standard_browsers(): if sys.platform == 'darwin': register("MacOSX", None, MacOSX('default')) register("chrome", None, MacOSX('google chrome')) + register("chromium", None, MacOSX('chromium')) register("firefox", None, MacOSX('firefox')) register("safari", None, MacOSX('safari')) + register("opera", None, MacOSX('opera')) + register("microsoft-edge", None, MacOSX('microsoft edge')) # macOS can use below Unix support (but we prefer using the macOS # specific stuff) From 98dd1d8633ca117ee2ae60c779b271dd0a46bb29 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 10:40:48 -0700 Subject: [PATCH 10/25] gh-137586: Fix Microsoft Edge bundle ID on macOS (com.microsoft.edgemac) --- Lib/webbrowser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index f4dd493b5dc8d2..9ecc053d7619e0 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -708,7 +708,7 @@ class MacOSX(BaseBrowser): 'safari': 'com.apple.Safari', 'chromium': 'org.chromium.Chromium', 'opera': 'com.operasoftware.Opera', - 'microsoft edge': 'com.microsoft.Edge', + 'microsoft edge': 'com.microsoft.edgemac', } def open(self, url, new=0, autoraise=True): From bdfc2e6a542a8f0abe36117ac03578b9d3f073f1 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 11:06:25 -0700 Subject: [PATCH 11/25] gh-137586: Replace _macos_default_browser_bundle_id with plistlib to address memory and os.fork() concerns --- Lib/webbrowser.py | 81 ++++++++++------------------------------------- 1 file changed, 17 insertions(+), 64 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 9ecc053d7619e0..d9bbee007d6e52 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -617,74 +617,27 @@ 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 via NSWorkspace. + """Return the bundle ID of the default web browser. - Uses the Objective-C runtime directly to call - NSWorkspace.sharedWorkspace().URLForApplicationToOpenURL() with a - probe https:// URL, then reads the bundle identifier from the - resulting NSBundle. Returns None if ctypes is unavailable or the - lookup fails for any reason. + 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: - from ctypes import cdll, c_void_p, c_char_p - from ctypes.util import find_library - - # NSWorkspace is an AppKit class; load AppKit to register it. - cdll.LoadLibrary( - '/System/Library/Frameworks/AppKit.framework/AppKit' - ) - objc = cdll.LoadLibrary(find_library('objc')) - objc.objc_getClass.restype = c_void_p - objc.sel_registerName.restype = c_void_p - objc.objc_msgSend.restype = c_void_p - - def cls(name): - return objc.objc_getClass(name) - - def sel(name): - return objc.sel_registerName(name) - - # Build probe NSURL for "https://python.org" - NSString = cls(b'NSString') - objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p] - ns_str = objc.objc_msgSend( - NSString, sel(b'stringWithUTF8String:'), b'https://python.org' - ) - - NSURL = cls(b'NSURL') - objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] - probe_url = objc.objc_msgSend(NSURL, sel(b'URLWithString:'), ns_str) - - # Ask NSWorkspace which app handles https:// - NSWorkspace = cls(b'NSWorkspace') - objc.objc_msgSend.argtypes = [c_void_p, c_void_p] - workspace = objc.objc_msgSend(NSWorkspace, sel(b'sharedWorkspace')) - if not workspace: - return None - - objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p] - app_url = objc.objc_msgSend( - workspace, sel(b'URLForApplicationToOpenURL:'), probe_url - ) - if not app_url: - return None - - # Get bundle identifier from that app's NSBundle - NSBundle = cls(b'NSBundle') - bundle = objc.objc_msgSend(NSBundle, sel(b'bundleWithURL:'), app_url) - if not bundle: - return None - - objc.objc_msgSend.argtypes = [c_void_p, c_void_p] - bundle_id_ns = objc.objc_msgSend(bundle, sel(b'bundleIdentifier')) - if not bundle_id_ns: - return None - - objc.objc_msgSend.restype = c_char_p - bundle_id_bytes = objc.objc_msgSend(bundle_id_ns, sel(b'UTF8String')) - return bundle_id_bytes.decode() if bundle_id_bytes else None + 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: - return None + pass + return None class MacOSX(BaseBrowser): """Launcher class for macOS browsers, using /usr/bin/open. From 614078f962c82038e17e6116e140c7218dba67b6 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 11:15:07 -0700 Subject: [PATCH 12/25] gh-137586: Rename MacOSX to MacOS --- Doc/library/webbrowser.rst | 4 ++-- Lib/test/test_webbrowser.py | 22 +++++++++++----------- Lib/webbrowser.py | 18 +++++++++--------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index ff9e0627c18d16..47be956cd8fad6 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -206,7 +206,7 @@ Notes: Only on iOS. .. deprecated:: 3.14 - :class:`!MacOSXOSAScript` is deprecated in favour of :class:`!MacOSX`. + :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 @@ -214,7 +214,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 diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index d0ca2c53338bce..33c98bd2e80912 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -326,16 +326,16 @@ def close(self): @unittest.skipUnless(sys.platform == "darwin", "macOS specific test") @requires_subprocess() -class MacOSXTest(unittest.TestCase): +class MacOSTest(unittest.TestCase): def test_default(self): browser = webbrowser.get() - self.assertIsInstance(browser, webbrowser.MacOSX) + 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.MacOSX('default') + browser = webbrowser.MacOS('default') with mock.patch('subprocess.run') as mock_run: mock_run.return_value = mock.Mock(returncode=0) result = browser.open(URL) @@ -349,7 +349,7 @@ 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.MacOSX('default') + 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: @@ -364,7 +364,7 @@ def test_default_non_http_uses_bundle_id(self): 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.MacOSX('default') + browser = webbrowser.MacOS('default') with mock.patch('webbrowser._macos_default_browser_bundle_id', return_value=None), \ mock.patch('subprocess.run') as mock_run: @@ -377,7 +377,7 @@ def test_default_non_http_fallback_when_no_bundle_id(self): def test_named_known_browser_uses_bundle_id(self): # Named browsers with a known bundle ID use /usr/bin/open -b. - browser = webbrowser.MacOSX('safari') + browser = webbrowser.MacOS('safari') with mock.patch('subprocess.run') as mock_run: mock_run.return_value = mock.Mock(returncode=0) result = browser.open(URL) @@ -389,7 +389,7 @@ def test_named_known_browser_uses_bundle_id(self): 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.MacOSX('lynx') + browser = webbrowser.MacOS('lynx') with mock.patch('subprocess.run') as mock_run: mock_run.return_value = mock.Mock(returncode=0) browser.open(URL) @@ -399,7 +399,7 @@ def test_named_unknown_browser_falls_back_to_dash_a(self): ) def test_open_failure(self): - browser = webbrowser.MacOSX('default') + browser = webbrowser.MacOS('default') with mock.patch('subprocess.run') as mock_run: mock_run.return_value = mock.Mock(returncode=1) result = browser.open(URL) @@ -412,7 +412,7 @@ class MacOSXOSAScriptDeprecationTest(unittest.TestCase): def test_deprecation_warning(self): with self.assertWarns(DeprecationWarning): - webbrowser.MacOSXOSAScript('default') + webbrowser.MacOSOSAScript('default') @unittest.skipUnless(sys.platform == "darwin", "macOS specific test") @@ -428,7 +428,7 @@ def setUp(self): support.patch(self, os, "popen", self.mock_popen) self.enterContext(warnings.catch_warnings()) warnings.simplefilter("ignore", DeprecationWarning) - self.browser = webbrowser.MacOSXOSAScript("default") + self.browser = webbrowser.MacOSOSAScript("default") def mock_popen(self, cmd, mode): self.popen_pipe = MockPopenPipe(cmd, mode) @@ -462,7 +462,7 @@ def test_default_browser_lookup(self): def test_explicit_browser(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - browser = webbrowser.MacOSXOSAScript("safari") + 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 d9bbee007d6e52..c5477b7aa4c40b 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -491,13 +491,13 @@ def register_standard_browsers(): _tryorder = [] if sys.platform == 'darwin': - register("MacOSX", None, MacOSX('default')) - register("chrome", None, MacOSX('google chrome')) - register("chromium", None, MacOSX('chromium')) - register("firefox", None, MacOSX('firefox')) - register("safari", None, MacOSX('safari')) - register("opera", None, MacOSX('opera')) - register("microsoft-edge", None, MacOSX('microsoft edge')) + 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) @@ -639,7 +639,7 @@ def _macos_default_browser_bundle_id(): pass return None - class MacOSX(BaseBrowser): + 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 @@ -690,7 +690,7 @@ class MacOSXOSAScript(BaseBrowser): def __init__(self, name='default'): import warnings warnings.warn( - "MacOSXOSAScript is deprecated, use MacOSX instead.", + "MacOSXOSAScript is deprecated, use MacOS instead.", DeprecationWarning, stacklevel=2, ) From c77f4b8ab74e92359364ba3a3016cf7329d4e07c Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 11:19:02 -0700 Subject: [PATCH 13/25] gh-137586: Use frozendict for MacOS._BUNDLE_IDS --- Lib/webbrowser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index c5477b7aa4c40b..129eb1038049de 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -655,14 +655,14 @@ class MacOS(BaseBrowser): to -a. """ - _BUNDLE_IDS = { + _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) From c725927e1e95a97b6ec62c0c9ba780232258d351 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 11:28:09 -0700 Subject: [PATCH 14/25] gh-137586: Update webbrowser.rst for MacOS class, fix version directives --- Doc/library/webbrowser.rst | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 47be956cd8fad6..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')`` | | +------------------------+-----------------------------------------+-------+ @@ -205,13 +207,6 @@ Notes: (4) Only on iOS. -.. deprecated:: 3.14 - :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. - .. versionadded:: 3.2 A new :class:`!MacOSXOSAScript` class has been added and is used on Mac instead of the previous :class:`!MacOS` class. @@ -228,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/' From 1316dbf18984d82ce73fa2a6791668239373fd24 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 12:21:40 -0700 Subject: [PATCH 15/25] gh-137586: Revert frozendict for MacOS._BUNDLE_IDS pending frozendict release --- Lib/webbrowser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 129eb1038049de..c5477b7aa4c40b 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -655,14 +655,14 @@ class MacOS(BaseBrowser): to -a. """ - _BUNDLE_IDS = frozendict({ + _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', - }) + } def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) From 612d2768e972f10bc890b1f2a547c777d5fe5b69 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Mon, 6 Apr 2026 12:42:24 -0700 Subject: [PATCH 16/25] gh-137586: Fix MacOSXOSAScript mangled to MacOSOSAScript in test rename --- Lib/test/test_webbrowser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 33c98bd2e80912..2fe3e8f436b94e 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -412,7 +412,7 @@ class MacOSXOSAScriptDeprecationTest(unittest.TestCase): def test_deprecation_warning(self): with self.assertWarns(DeprecationWarning): - webbrowser.MacOSOSAScript('default') + webbrowser.MacOSXOSAScript('default') @unittest.skipUnless(sys.platform == "darwin", "macOS specific test") @@ -428,7 +428,7 @@ def setUp(self): support.patch(self, os, "popen", self.mock_popen) self.enterContext(warnings.catch_warnings()) warnings.simplefilter("ignore", DeprecationWarning) - self.browser = webbrowser.MacOSOSAScript("default") + self.browser = webbrowser.MacOSXOSAScript("default") def mock_popen(self, cmd, mode): self.popen_pipe = MockPopenPipe(cmd, mode) @@ -462,7 +462,7 @@ def test_default_browser_lookup(self): def test_explicit_browser(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - browser = webbrowser.MacOSOSAScript("safari") + browser = webbrowser.MacOSXOSAScript("safari") browser.open("https://python.org") script = self.popen_pipe.pipe.getvalue() self.assertIn('tell application "safari"', script) From 386af23c53a346f150719bf9ff480fd68a69fb74 Mon Sep 17 00:00:00 2001 From: Jeff Lyon <146767590+secengjeff@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:37:23 -0700 Subject: [PATCH 17/25] Update Misc/NEWS.d/next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .../next/Library/2026-03-26-01-42-20.gh-issue-137586.KmHRwR.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bac380811f8275..7a352f67231d94 100644 --- 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 @@ -1,3 +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`. +Deprecate :class:`!MacOSXOSAScript` in favour of :class:`!MacOS`. From e4e89f1a99cbc947ab4466400540551f9266a949 Mon Sep 17 00:00:00 2001 From: Jeff Lyon <146767590+secengjeff@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:37:55 -0700 Subject: [PATCH 18/25] Update Misc/NEWS.d/next/Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .../Security/2026-03-26-01-42-15.gh-issue-137586.j3SkOm.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 640d4caf4f732f..ce9387adc069a8 100644 --- 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 @@ -1,4 +1,4 @@ Fix a PATH-injection vulnerability in :mod:`webbrowser` on macOS where -``osascript`` was invoked without an absolute path. The new :class:`!MacOSX` +``osascript`` was invoked without an absolute path. The new :class:`!MacOS` class uses ``/usr/bin/open`` directly, eliminating the dependency on ``osascript`` entirely. From b513903417c64cf17462ffad87ba4d9025de1d3c Mon Sep 17 00:00:00 2001 From: Jeff Lyon <146767590+secengjeff@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:38:24 -0700 Subject: [PATCH 19/25] Update Doc/library/webbrowser.rst Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/library/webbrowser.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index e74c0f8215a310..1114e287cb5216 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -209,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:`!MacOS` class. + and is used on Mac instead of the previous :class:`!MacOSX` class. This adds support for opening browsers not currently set as the OS default. .. versionadded:: 3.3 From aacbceb01934118b34c4f0851b192e8177e337fb Mon Sep 17 00:00:00 2001 From: Jeff Lyon <146767590+secengjeff@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:39:49 -0700 Subject: [PATCH 20/25] Update Doc/library/webbrowser.rst Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Doc/library/webbrowser.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 1114e287cb5216..35359143314216 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -172,7 +172,7 @@ for the controller classes, all defined in this module. +------------------------+-----------------------------------------+-------+ | ``'windows-default'`` | ``WindowsDefault`` | \(2) | +------------------------+-----------------------------------------+-------+ -| ``'MacOS'`` | ``MacOS('default')`` | \(3) | +| ``'macos'`` | ``MacOS('default')`` | \(3) | +------------------------+-----------------------------------------+-------+ | ``'safari'`` | ``MacOS('safari')`` | \(3) | +------------------------+-----------------------------------------+-------+ From a6d76d6b95a97655c94bd18ae000d7e21ee8329b Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 22 Apr 2026 20:10:11 -0700 Subject: [PATCH 21/25] gh-137586: Fix MacOS plist fallback, builtins.open shadow, add Brave Return 'com.apple.Safari' instead of None when the LaunchServices plist is absent (fresh installs never write it). None caused the non-http branch to fall back to bare '/usr/bin/open ', bypassing -b and triggering the OS file handler. Addresses gpshead's CHANGES_REQUESTED. Fix infinite recursion: webbrowser's module-level open() shadowed builtins.open, so open(plist, 'rb') called webbrowser.open() recursively on every non-http/https URL. Use builtins.open() explicitly. Narrow 'except Exception' to '(OSError, KeyError, ValueError)'. Addresses hugovk's review comment. Add 'brave browser': 'com.brave.Browser' to _BUNDLE_IDS and register it so webbrowser.get('brave') works and Brave uses precise -b dispatch instead of the name-based -a fallback. Tested locally against Safari, Chrome, Firefox, Opera, Edge, and Brave with https, http, and file:// URLs. 29/29 checks pass. --- Lib/test/test_webbrowser.py | 18 ++---------------- Lib/webbrowser.py | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 2fe3e8f436b94e..382f8461c64ed8 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -351,30 +351,16 @@ def test_default_non_http_uses_bundle_id(self): file_url = 'file:///tmp/test.html' browser = webbrowser.MacOS('default') with mock.patch('webbrowser._macos_default_browser_bundle_id', - return_value='com.apple.Safari'), \ + 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.apple.Safari', file_url], + ['/usr/bin/open', '-b', 'com.google.Chrome', 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') diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index c5477b7aa4c40b..15393fc4c53d76 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -498,6 +498,7 @@ def register_standard_browsers(): 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) @@ -620,24 +621,27 @@ 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. + 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. """ - import plistlib, os + import builtins, plistlib, os plist = os.path.expanduser( '~/Library/Preferences/com.apple.LaunchServices/' 'com.apple.launchservices.secure.plist' ) try: - with open(plist, 'rb') as f: + 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 Exception: + except (OSError, KeyError, ValueError): pass - return None + return 'com.apple.Safari' class MacOS(BaseBrowser): """Launcher class for macOS browsers, using /usr/bin/open. @@ -662,6 +666,7 @@ class MacOS(BaseBrowser): '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): @@ -673,10 +678,7 @@ def open(self, url, new=0, autoraise=True): 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] + cmd = ['/usr/bin/open', '-b', bundle_id, url] else: bundle_id = self._BUNDLE_IDS.get(self.name.lower()) if bundle_id: From de9ec7766f00f6137db8a15876638ed3fa5f17ec Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Wed, 22 Apr 2026 20:22:19 -0700 Subject: [PATCH 22/25] gh-137586: Address hugovk review comments Move plistlib to top-level imports alongside the existing import block. Remove os from the inline import inside _macos_default_browser_bundle_id as it is already imported at module level. Remove the stale '# Maintained by Georg Brandl.' comment. Replace manual warnings.warn() in MacOSXOSAScript.__init__ with warnings._deprecated("webbrowser.MacOSXOSAScript", remove=(3, 17)). Change 'deprecated:: next' to 'deprecated-removed:: next 3.17' in Doc/library/webbrowser.rst. Add MacOSXOSAScript to Doc/deprecations/pending-removal-in-3.17.rst. Add register("MacOSX", None, MacOS('default')) as a backward compatibility alias so webbrowser.get("MacOSX") continues to work. --- Doc/deprecations/pending-removal-in-3.17.rst | 5 +++++ Doc/library/webbrowser.rst | 2 +- Lib/webbrowser.py | 11 ++++------- 3 files changed, 10 insertions(+), 8 deletions(-) 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 35359143314216..30e4df1688d7a0 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -227,7 +227,7 @@ Notes: :class:`!MacOS` has been added as a replacement for :class:`!MacOSXOSAScript`, opening browsers via :program:`/usr/bin/open` instead of :program:`osascript`. -.. deprecated:: next +.. 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 diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 8119a58ddf69f0..b06d192ec6ebf7 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -1,7 +1,7 @@ """Interfaces for launching and remotely controlling web browsers.""" -# Maintained by Georg Brandl. import os +import plistlib import shlex import shutil import sys @@ -493,6 +493,7 @@ def register_standard_browsers(): if sys.platform == 'darwin': 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')) @@ -628,7 +629,7 @@ def _macos_default_browser_bundle_id(): LaunchServices plist is not written until the user explicitly changes their default browser. """ - import builtins, plistlib, os + import builtins plist = os.path.expanduser( '~/Library/Preferences/com.apple.LaunchServices/' 'com.apple.launchservices.secure.plist' @@ -692,11 +693,7 @@ def open(self, url, new=0, autoraise=True): class MacOSXOSAScript(BaseBrowser): def __init__(self, name='default'): import warnings - warnings.warn( - "MacOSXOSAScript is deprecated, use MacOS instead.", - DeprecationWarning, - stacklevel=2, - ) + warnings._deprecated("webbrowser.MacOSXOSAScript", remove=(3, 17)) super().__init__(name) def open(self, url, new=0, autoraise=True): From 6ddf67f7715271bf267aa714098e3d91fb172643 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" Date: Thu, 23 Apr 2026 05:51:27 +0000 Subject: [PATCH 23/25] lazy import plistlib --- Lib/webbrowser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index b06d192ec6ebf7..02884b361a27f1 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -1,7 +1,8 @@ """Interfaces for launching and remotely controlling web browsers.""" +import builtins # because we override open import os -import plistlib +lazy import plistlib import shlex import shutil import sys @@ -629,7 +630,6 @@ def _macos_default_browser_bundle_id(): LaunchServices plist is not written until the user explicitly changes their default browser. """ - import builtins plist = os.path.expanduser( '~/Library/Preferences/com.apple.LaunchServices/' 'com.apple.launchservices.secure.plist' From f874d67619d3b5965b62af9a8489cc4a61cec254 Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Thu, 23 Apr 2026 07:10:45 -0700 Subject: [PATCH 24/25] gh-137586: Address hugovk post-approval concerns Move webbrowser entry in Doc/deprecations/pending-removal-in-3.17.rst to after typing, to maintain alphabetical order. Move import plistlib to after import threading, per isort/Ruff style of placing stdlib imports after the main import block. Fix :class:`!MacOSX` -> :class:`!MacOS` in the Library NEWS entry. Remove extra blank line in MacOSXOSAScriptTest.mock_popen. Add webbrowser entries to Doc/whatsnew/3.15.rst: one under "Improved modules" describing the new MacOS class, and one under "New deprecations" for MacOSXOSAScript scheduled removal in 3.17. --- Doc/whatsnew/3.15.rst | 19 +++++++++++++++++++ Lib/test/test_webbrowser.py | 1 - ...-03-26-01-42-20.gh-issue-137586.KmHRwR.rst | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 500797910edb90..d525a32dd3af8b 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1390,6 +1390,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 ` 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 ----------------- @@ -1780,6 +1793,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 c1f7a3356b6bf7..51d627d24c5a8a 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -431,7 +431,6 @@ def mock_popen(self, cmd, mode): self.popen_pipe = MockPopenPipe(cmd, mode) return self.popen_pipe - def test_default_open(self): url = "https://python.org" self.browser.open(url) 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 index 7a352f67231d94..70122c8ceae507 100644 --- 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 @@ -1,3 +1,3 @@ -Add :class:`!MacOSX` to :mod:`webbrowser` for macOS, which opens URLs via +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`. From 3b390bb7bb6206200fa7e4519d5400915628cc1f Mon Sep 17 00:00:00 2001 From: Jeffrey Lyon Date: Thu, 23 Apr 2026 07:16:28 -0700 Subject: [PATCH 25/25] gh-137586: Fix Sphinx ref.class warnings in whatsnew/3.15.rst Use :class:`!webbrowser.MacOS` and :class:`!webbrowser.MacOSXOSAScript` with the ! prefix to suppress cross-reference lookup for classes not yet present in upstream. --- Doc/whatsnew/3.15.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index d525a32dd3af8b..fdf29214360e16 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1393,7 +1393,7 @@ wave webbrowser ---------- -* On macOS, the new :class:`~webbrowser.MacOS` class opens URLs via +* 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 @@ -1795,8 +1795,8 @@ New deprecations * :mod:`webbrowser`: - * :class:`~webbrowser.MacOSXOSAScript` is deprecated in favour of - :class:`~webbrowser.MacOS` and scheduled for removal in Python 3.17. + * :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__``