Skip to content
Open
7 changes: 7 additions & 0 deletions Doc/library/webbrowser.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 97 additions & 5 deletions Lib/test/test_webbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -323,6 +324,97 @@ def close(self):
return None


@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
@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_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)
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.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', '-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:
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):
Expand All @@ -334,16 +426,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"
Expand All @@ -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.MacOSXOSAScript("safari")
browser.open("https://python.org")
script = self.popen_pipe.pipe.getvalue()
self.assertIn('tell application "safari"', script)
Expand Down
131 changes: 127 additions & 4 deletions Lib/webbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -613,8 +613,131 @@ 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

# 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
except Exception:
return None

class MacOSX(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.Edge',
}

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 MacOSX instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(name)

def open(self, url, new=0, autoraise=True):
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
Loading