Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3.15

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. deprecated:: 3.14
.. deprecated:: next

And precede this deprecated block with a versionadded for the new class, and move both after versionchanged:: 3.13 below.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the MacOSXOSAScripts in the table above need updating, and should we also add chrome and firefox (with note (3)) as also returning the new class?

: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
134 changes: 130 additions & 4 deletions Lib/webbrowser.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,13 @@ def register_standard_browsers():
_tryorder = []

if sys.platform == 'darwin':
register("MacOSX", None, MacOSXOSAScript('default'))
register("chrome", None, MacOSXOSAScript('google chrome'))
register("firefox", None, MacOSXOSAScript('firefox'))
register("safari", None, MacOSXOSAScript('safari'))
register("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)

Expand Down Expand Up @@ -613,8 +616,131 @@ def open(self, url, new=0, autoraise=True):
#

if sys.platform == 'darwin':
def _macos_default_browser_bundle_id():
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function leaks memory due to not performing objective-c reference count updates.

Also: This introduces a new dependency on an Apple system framework, which in the past has caused problems for folks starting new worker processes using os.fork (without exec-in a new executable).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that hardcoding /usr/bin/osascript addresses the PATH injection issue directly. This PR is intended as an incremental improvement on top of that.

The core motivation is that osascript is increasingly blocked at the EDR/MDM level in enterprise environments as a broad response to ClickFix campaigns and supply chain attacks like the axios incident. /usr/bin/open is both incrementally safer as a purpose-built URL dispatch primitive and far less likely to be subject to those same restrictions. When osascript is blocked, users of Python-based tools may see a failure with no obvious connection to osascript, and the path to diagnosing an endpoint security policy conflict is not straightforward for most users.

On _macos_default_browser_bundle_id(): agreed, I'll fix the missing Objective-C reference count cleanup and address the os.fork() concern.

"""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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been macOS for 10 years, shall we use drop the X for the new class?

Suggested change
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
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 = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a frozendict if not backporting?

'google chrome': 'com.google.Chrome',
'firefox': 'org.mozilla.firefox',
'safari': 'com.apple.Safari',
'chromium': 'org.chromium.Chromium',
'opera': 'com.operasoftware.Opera',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have Opera installed on macOS, and confirm this is the correct bundle ID in the plist, but whilst this works:

import webbrowser
web = webbrowser.get("firefox")
web.open("https://www.python.org/")

This doesn't:

import webbrowser
web = webbrowser.get("opera")
web.open("https://www.python.org/")
Traceback (most recent call last):
  File "/Users/hugo/github/python/cpython/main/1.py", line 2, in <module>
    web = webbrowser.get("opera")
  File "/Users/hugo/github/python/cpython/main/Lib/webbrowser.py", line 68, in get
    raise Error("could not locate runnable browser")
webbrowser.Error: could not locate runnable browser

Do these new ones need registering too, or removing from here?

'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