Skip to content

Commit aeb02ac

Browse files
secengjeffclaudehugovkgpshead
authored
gh-137586: Replace 'osascript' with 'open' on macOS in webbrowser (#146439)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: Gregory P. Smith <greg@krypto.org>
1 parent 1bdfc0f commit aeb02ac

7 files changed

Lines changed: 215 additions & 15 deletions

File tree

Doc/deprecations/pending-removal-in-3.17.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ Pending removal in Python 3.17
3737
is deprecated and scheduled for removal in Python 3.17.
3838
(Contributed by Stan Ulbrych in :gh:`136702`.)
3939

40+
* :mod:`webbrowser`:
41+
42+
- :class:`!webbrowser.MacOSXOSAScript` is deprecated in favour of
43+
:class:`!webbrowser.MacOS`. (:gh:`137586`)
44+
4045
* :mod:`typing`:
4146

4247
- Before Python 3.14, old-style unions were implemented using the private class

Doc/library/webbrowser.rst

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,15 @@ for the controller classes, all defined in this module.
172172
+------------------------+-----------------------------------------+-------+
173173
| ``'windows-default'`` | ``WindowsDefault`` | \(2) |
174174
+------------------------+-----------------------------------------+-------+
175-
| ``'macosx'`` | ``MacOSXOSAScript('default')`` | \(3) |
175+
| ``'macos'`` | ``MacOS('default')`` | \(3) |
176176
+------------------------+-----------------------------------------+-------+
177-
| ``'safari'`` | ``MacOSXOSAScript('safari')`` | \(3) |
177+
| ``'safari'`` | ``MacOS('safari')`` | \(3) |
178178
+------------------------+-----------------------------------------+-------+
179-
| ``'google-chrome'`` | ``Chrome('google-chrome')`` | |
179+
| ``'chrome'`` | ``MacOS('google chrome')`` | \(3) |
180+
+------------------------+-----------------------------------------+-------+
181+
| ``'firefox'`` | ``MacOS('firefox')`` | \(3) |
180182
+------------------------+-----------------------------------------+-------+
181-
| ``'chrome'`` | ``Chrome('chrome')`` | |
183+
| ``'google-chrome'`` | ``Chrome('google-chrome')`` | |
182184
+------------------------+-----------------------------------------+-------+
183185
| ``'chromium'`` | ``Chromium('chromium')`` | |
184186
+------------------------+-----------------------------------------+-------+
@@ -221,6 +223,17 @@ Notes:
221223
.. versionchanged:: 3.13
222224
Support for iOS has been added.
223225

226+
.. versionadded:: next
227+
:class:`!MacOS` has been added as a replacement for :class:`!MacOSXOSAScript`,
228+
opening browsers via :program:`/usr/bin/open` instead of :program:`osascript`.
229+
230+
.. deprecated-removed:: next 3.17
231+
:class:`!MacOSXOSAScript` is deprecated in favour of :class:`!MacOS`.
232+
Using :program:`/usr/bin/open` instead of :program:`osascript` is a
233+
security and usability improvement: :program:`osascript` may be blocked
234+
on managed systems due to its abuse potential as a general-purpose
235+
scripting interpreter.
236+
224237
Here are some simple examples::
225238

226239
url = 'https://docs.python.org/'

Doc/whatsnew/3.15.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,20 @@ wave
16931693
(Contributed by Lionel Koenig and Michiel W. Beijen in :gh:`60729`.)
16941694

16951695

1696+
webbrowser
1697+
----------
1698+
1699+
* On macOS, the new :class:`!webbrowser.MacOS` class opens URLs via
1700+
:program:`/usr/bin/open` instead of constructing and executing AppleScript
1701+
via :program:`osascript`. The default browser is detected from the
1702+
LaunchServices preferences file using :mod:`plistlib`, with
1703+
:class:`!com.apple.Safari` as the fallback on fresh installations.
1704+
For non-HTTP(S) URLs, :program:`open -b <bundle-id>` is used to route the
1705+
URL through a browser rather than the OS file handler, preventing
1706+
file injection attacks.
1707+
(Contributed by Jeff Lyon in :gh:`137586`.)
1708+
1709+
16961710
xml
16971711
---
16981712

@@ -2132,6 +2146,12 @@ New deprecations
21322146
merely imported or accessed from the :mod:`!typing` module.
21332147

21342148

2149+
* :mod:`webbrowser`:
2150+
2151+
* :class:`!webbrowser.MacOSXOSAScript` is deprecated in favour of
2152+
:class:`!webbrowser.MacOS` and scheduled for removal in Python 3.17.
2153+
(Contributed by Jeff Lyon in :gh:`137586`.)
2154+
21352155
* ``__version__``
21362156

21372157
* The ``__version__``, ``version`` and ``VERSION`` attributes have been

Lib/test/test_webbrowser.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import subprocess
66
import sys
77
import unittest
8+
import warnings
89
import webbrowser
910
from test import support
1011
from test.support import force_not_colorized_test_class
@@ -335,6 +336,83 @@ def close(self):
335336
return None
336337

337338

339+
@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
340+
@requires_subprocess()
341+
class MacOSTest(unittest.TestCase):
342+
343+
def test_default(self):
344+
browser = webbrowser.get()
345+
self.assertIsInstance(browser, webbrowser.MacOS)
346+
self.assertEqual(browser.name, 'default')
347+
348+
def test_default_http_open(self):
349+
# http/https URLs use /usr/bin/open directly — no bundle ID needed.
350+
browser = webbrowser.MacOS('default')
351+
with mock.patch('subprocess.run') as mock_run:
352+
mock_run.return_value = mock.Mock(returncode=0)
353+
result = browser.open(URL)
354+
mock_run.assert_called_once_with(
355+
['/usr/bin/open', URL],
356+
stderr=subprocess.DEVNULL,
357+
)
358+
self.assertTrue(result)
359+
360+
def test_default_non_http_uses_bundle_id(self):
361+
# Non-http(s) URLs (e.g. file://) must be routed through the browser
362+
# via -b <bundle-id> to prevent OS file handler dispatch.
363+
file_url = 'file:///tmp/test.html'
364+
browser = webbrowser.MacOS('default')
365+
with mock.patch('webbrowser._macos_default_browser_bundle_id',
366+
return_value='com.google.Chrome'), \
367+
mock.patch('subprocess.run') as mock_run:
368+
mock_run.return_value = mock.Mock(returncode=0)
369+
result = browser.open(file_url)
370+
mock_run.assert_called_once_with(
371+
['/usr/bin/open', '-b', 'com.google.Chrome', file_url],
372+
stderr=subprocess.DEVNULL,
373+
)
374+
self.assertTrue(result)
375+
376+
def test_named_known_browser_uses_bundle_id(self):
377+
# Named browsers with a known bundle ID use /usr/bin/open -b.
378+
browser = webbrowser.MacOS('safari')
379+
with mock.patch('subprocess.run') as mock_run:
380+
mock_run.return_value = mock.Mock(returncode=0)
381+
result = browser.open(URL)
382+
mock_run.assert_called_once_with(
383+
['/usr/bin/open', '-b', 'com.apple.Safari', URL],
384+
stderr=subprocess.DEVNULL,
385+
)
386+
self.assertTrue(result)
387+
388+
def test_named_unknown_browser_falls_back_to_dash_a(self):
389+
# Named browsers not in the bundle ID map fall back to -a.
390+
browser = webbrowser.MacOS('lynx')
391+
with mock.patch('subprocess.run') as mock_run:
392+
mock_run.return_value = mock.Mock(returncode=0)
393+
browser.open(URL)
394+
mock_run.assert_called_once_with(
395+
['/usr/bin/open', '-a', 'lynx', URL],
396+
stderr=subprocess.DEVNULL,
397+
)
398+
399+
def test_open_failure(self):
400+
browser = webbrowser.MacOS('default')
401+
with mock.patch('subprocess.run') as mock_run:
402+
mock_run.return_value = mock.Mock(returncode=1)
403+
result = browser.open(URL)
404+
self.assertFalse(result)
405+
406+
407+
@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
408+
@requires_subprocess()
409+
class MacOSXOSAScriptDeprecationTest(unittest.TestCase):
410+
411+
def test_deprecation_warning(self):
412+
with self.assertWarns(DeprecationWarning):
413+
webbrowser.MacOSXOSAScript('default')
414+
415+
338416
@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
339417
@requires_subprocess()
340418
class MacOSXOSAScriptTest(unittest.TestCase):
@@ -345,17 +423,14 @@ def setUp(self):
345423
env.unset("BROWSER")
346424

347425
support.patch(self, os, "popen", self.mock_popen)
426+
self.enterContext(warnings.catch_warnings())
427+
warnings.simplefilter("ignore", DeprecationWarning)
348428
self.browser = webbrowser.MacOSXOSAScript("default")
349429

350430
def mock_popen(self, cmd, mode):
351431
self.popen_pipe = MockPopenPipe(cmd, mode)
352432
return self.popen_pipe
353433

354-
def test_default(self):
355-
browser = webbrowser.get()
356-
assert isinstance(browser, webbrowser.MacOSXOSAScript)
357-
self.assertEqual(browser.name, "default")
358-
359434
def test_default_open(self):
360435
url = "https://python.org"
361436
self.browser.open(url)
@@ -381,7 +456,9 @@ def test_default_browser_lookup(self):
381456
self.assertIn(f'open location "{url}"', script)
382457

383458
def test_explicit_browser(self):
384-
browser = webbrowser.MacOSXOSAScript("safari")
459+
with warnings.catch_warnings():
460+
warnings.simplefilter("ignore", DeprecationWarning)
461+
browser = webbrowser.MacOSXOSAScript("safari")
385462
browser.open("https://python.org")
386463
script = self.popen_pipe.pipe.getvalue()
387464
self.assertIn('tell application "safari"', script)

Lib/webbrowser.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Interfaces for launching and remotely controlling web browsers."""
2-
# Maintained by Georg Brandl.
32

3+
import builtins # because we override open
44
import os
5+
lazy import plistlib
56
import shlex
67
import shutil
78
import sys
@@ -492,10 +493,15 @@ def register_standard_browsers():
492493
_tryorder = []
493494

494495
if sys.platform == 'darwin':
495-
register("MacOSX", None, MacOSXOSAScript('default'))
496-
register("chrome", None, MacOSXOSAScript('google chrome'))
497-
register("firefox", None, MacOSXOSAScript('firefox'))
498-
register("safari", None, MacOSXOSAScript('safari'))
496+
register("MacOS", None, MacOS('default'))
497+
register("MacOSX", None, MacOS('default')) # backward compat alias
498+
register("chrome", None, MacOS('google chrome'))
499+
register("chromium", None, MacOS('chromium'))
500+
register("firefox", None, MacOS('firefox'))
501+
register("safari", None, MacOS('safari'))
502+
register("opera", None, MacOS('opera'))
503+
register("microsoft-edge", None, MacOS('microsoft edge'))
504+
register("brave", None, MacOS('brave browser'))
499505
# macOS can use below Unix support (but we prefer using the macOS
500506
# specific stuff)
501507

@@ -614,8 +620,80 @@ def open(self, url, new=0, autoraise=True):
614620
#
615621

616622
if sys.platform == 'darwin':
623+
def _macos_default_browser_bundle_id():
624+
"""Return the bundle ID of the default web browser.
625+
626+
Reads the LaunchServices preferences file that macOS maintains
627+
when the user sets a default browser. Returns 'com.apple.Safari'
628+
if the file is absent or no https handler is recorded, because on
629+
a fresh macOS installation Safari is the default browser and the
630+
LaunchServices plist is not written until the user explicitly
631+
changes their default browser.
632+
"""
633+
plist = os.path.expanduser(
634+
'~/Library/Preferences/com.apple.LaunchServices/'
635+
'com.apple.launchservices.secure.plist'
636+
)
637+
try:
638+
with builtins.open(plist, 'rb') as f:
639+
data = plistlib.load(f)
640+
for handler in data.get('LSHandlers', []):
641+
if handler.get('LSHandlerURLScheme') == 'https':
642+
return (handler.get('LSHandlerRoleAll')
643+
or handler.get('LSHandlerRoleViewer'))
644+
except (OSError, KeyError, ValueError):
645+
pass
646+
return 'com.apple.Safari'
647+
648+
class MacOS(BaseBrowser):
649+
"""Launcher class for macOS browsers, using /usr/bin/open.
650+
651+
For http/https URLs with the default browser, /usr/bin/open is called
652+
directly; macOS routes these to the registered browser.
653+
654+
For all other URL schemes (e.g. file://) and for named browsers,
655+
/usr/bin/open -b <bundle-id> is used so that the URL is always passed
656+
to a browser application rather than dispatched by the OS file handler.
657+
This prevents file injection attacks where a file:// URL pointing to an
658+
executable bundle could otherwise be launched by the OS.
659+
660+
Named browsers with known bundle IDs use -b; unknown names fall back
661+
to -a.
662+
"""
663+
664+
_BUNDLE_IDS = {
665+
'google chrome': 'com.google.Chrome',
666+
'firefox': 'org.mozilla.firefox',
667+
'safari': 'com.apple.Safari',
668+
'chromium': 'org.chromium.Chromium',
669+
'opera': 'com.operasoftware.Opera',
670+
'microsoft edge': 'com.microsoft.edgemac',
671+
'brave browser': 'com.brave.Browser',
672+
}
673+
674+
def open(self, url, new=0, autoraise=True):
675+
sys.audit("webbrowser.open", url)
676+
self._check_url(url)
677+
if self.name == 'default':
678+
proto, sep, _ = url.partition(':')
679+
if sep and proto.lower() in {'http', 'https'}:
680+
cmd = ['/usr/bin/open', url]
681+
else:
682+
bundle_id = _macos_default_browser_bundle_id()
683+
cmd = ['/usr/bin/open', '-b', bundle_id, url]
684+
else:
685+
bundle_id = self._BUNDLE_IDS.get(self.name.lower())
686+
if bundle_id:
687+
cmd = ['/usr/bin/open', '-b', bundle_id, url]
688+
else:
689+
cmd = ['/usr/bin/open', '-a', self.name, url]
690+
proc = subprocess.run(cmd, stderr=subprocess.DEVNULL)
691+
return proc.returncode == 0
692+
617693
class MacOSXOSAScript(BaseBrowser):
618694
def __init__(self, name='default'):
695+
import warnings
696+
warnings._deprecated("webbrowser.MacOSXOSAScript", remove=(3, 17))
619697
super().__init__(name)
620698

621699
def open(self, url, new=0, autoraise=True):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add :class:`!MacOS` to :mod:`webbrowser` for macOS, which opens URLs via
2+
``/usr/bin/open`` instead of piping AppleScript to ``osascript``.
3+
Deprecate :class:`!MacOSXOSAScript` in favour of :class:`!MacOS`.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix a PATH-injection vulnerability in :mod:`webbrowser` on macOS where
2+
``osascript`` was invoked without an absolute path. The new :class:`!MacOS`
3+
class uses ``/usr/bin/open`` directly, eliminating the dependency on
4+
``osascript`` entirely.

0 commit comments

Comments
 (0)