Skip to content

Commit fdd6649

Browse files
committed
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 <bundle-id>. 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.
1 parent 080197e commit fdd6649

File tree

2 files changed

+141
-6
lines changed

2 files changed

+141
-6
lines changed

Lib/test/test_webbrowser.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,8 @@ def test_default(self):
333333
self.assertIsInstance(browser, webbrowser.MacOSX)
334334
self.assertEqual(browser.name, 'default')
335335

336-
def test_default_open(self):
336+
def test_default_http_open(self):
337+
# http/https URLs use /usr/bin/open directly — no bundle ID needed.
337338
browser = webbrowser.MacOSX('default')
338339
with mock.patch('subprocess.run') as mock_run:
339340
mock_run.return_value = mock.Mock(returncode=0)
@@ -344,17 +345,59 @@ def test_default_open(self):
344345
)
345346
self.assertTrue(result)
346347

347-
def test_named_open(self):
348+
def test_default_non_http_uses_bundle_id(self):
349+
# Non-http(s) URLs (e.g. file://) must be routed through the browser
350+
# via -b <bundle-id> to prevent OS file handler dispatch.
351+
file_url = 'file:///tmp/test.html'
352+
browser = webbrowser.MacOSX('default')
353+
with mock.patch('webbrowser._macos_default_browser_bundle_id',
354+
return_value='com.apple.Safari'), \
355+
mock.patch('subprocess.run') as mock_run:
356+
mock_run.return_value = mock.Mock(returncode=0)
357+
result = browser.open(file_url)
358+
mock_run.assert_called_once_with(
359+
['/usr/bin/open', '-b', 'com.apple.Safari', file_url],
360+
stderr=subprocess.DEVNULL,
361+
)
362+
self.assertTrue(result)
363+
364+
def test_default_non_http_fallback_when_no_bundle_id(self):
365+
# If the bundle ID lookup fails, fall back to /usr/bin/open without -b.
366+
file_url = 'file:///tmp/test.html'
367+
browser = webbrowser.MacOSX('default')
368+
with mock.patch('webbrowser._macos_default_browser_bundle_id',
369+
return_value=None), \
370+
mock.patch('subprocess.run') as mock_run:
371+
mock_run.return_value = mock.Mock(returncode=0)
372+
browser.open(file_url)
373+
mock_run.assert_called_once_with(
374+
['/usr/bin/open', file_url],
375+
stderr=subprocess.DEVNULL,
376+
)
377+
378+
def test_named_known_browser_uses_bundle_id(self):
379+
# Named browsers with a known bundle ID use /usr/bin/open -b.
348380
browser = webbrowser.MacOSX('safari')
349381
with mock.patch('subprocess.run') as mock_run:
350382
mock_run.return_value = mock.Mock(returncode=0)
351383
result = browser.open(URL)
352384
mock_run.assert_called_once_with(
353-
['/usr/bin/open', '-a', 'safari', URL],
385+
['/usr/bin/open', '-b', 'com.apple.Safari', URL],
354386
stderr=subprocess.DEVNULL,
355387
)
356388
self.assertTrue(result)
357389

390+
def test_named_unknown_browser_falls_back_to_dash_a(self):
391+
# Named browsers not in the bundle ID map fall back to -a.
392+
browser = webbrowser.MacOSX('lynx')
393+
with mock.patch('subprocess.run') as mock_run:
394+
mock_run.return_value = mock.Mock(returncode=0)
395+
browser.open(URL)
396+
mock_run.assert_called_once_with(
397+
['/usr/bin/open', '-a', 'lynx', URL],
398+
stderr=subprocess.DEVNULL,
399+
)
400+
358401
def test_open_failure(self):
359402
browser = webbrowser.MacOSX('default')
360403
with mock.patch('subprocess.run') as mock_run:

Lib/webbrowser.py

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,16 +613,108 @@ def open(self, url, new=0, autoraise=True):
613613
#
614614

615615
if sys.platform == 'darwin':
616+
def _macos_default_browser_bundle_id():
617+
"""Return the bundle ID of the default web browser via NSWorkspace.
618+
619+
Uses the Objective-C runtime directly to call
620+
NSWorkspace.sharedWorkspace().URLForApplicationToOpenURL() with a
621+
probe https:// URL, then reads the bundle identifier from the
622+
resulting NSBundle. Returns None if ctypes is unavailable or the
623+
lookup fails for any reason.
624+
"""
625+
try:
626+
from ctypes import cdll, c_void_p, c_char_p
627+
from ctypes.util import find_library
628+
629+
objc = cdll.LoadLibrary(find_library('objc'))
630+
objc.objc_getClass.restype = c_void_p
631+
objc.sel_registerName.restype = c_void_p
632+
objc.objc_msgSend.restype = c_void_p
633+
634+
def cls(name):
635+
return objc.objc_getClass(name)
636+
637+
def sel(name):
638+
return objc.sel_registerName(name)
639+
640+
# Build probe NSURL for "https://python.org"
641+
NSString = cls(b'NSString')
642+
objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_char_p]
643+
ns_str = objc.objc_msgSend(
644+
NSString, sel(b'stringWithUTF8String:'), b'https://python.org'
645+
)
646+
647+
NSURL = cls(b'NSURL')
648+
objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p]
649+
probe_url = objc.objc_msgSend(NSURL, sel(b'URLWithString:'), ns_str)
650+
651+
# Ask NSWorkspace which app handles https://
652+
NSWorkspace = cls(b'NSWorkspace')
653+
objc.objc_msgSend.argtypes = [c_void_p, c_void_p]
654+
workspace = objc.objc_msgSend(NSWorkspace, sel(b'sharedWorkspace'))
655+
656+
objc.objc_msgSend.argtypes = [c_void_p, c_void_p, c_void_p]
657+
app_url = objc.objc_msgSend(
658+
workspace, sel(b'URLForApplicationToOpenURL:'), probe_url
659+
)
660+
661+
# Get bundle identifier from that app's NSBundle
662+
NSBundle = cls(b'NSBundle')
663+
bundle = objc.objc_msgSend(NSBundle, sel(b'bundleWithURL:'), app_url)
664+
665+
objc.objc_msgSend.argtypes = [c_void_p, c_void_p]
666+
bundle_id_ns = objc.objc_msgSend(bundle, sel(b'bundleIdentifier'))
667+
668+
objc.objc_msgSend.restype = c_char_p
669+
bundle_id_bytes = objc.objc_msgSend(bundle_id_ns, sel(b'UTF8String'))
670+
return bundle_id_bytes.decode() if bundle_id_bytes else None
671+
except Exception:
672+
return None
673+
616674
class MacOSX(BaseBrowser):
617-
"""Launcher class for macOS browsers, using /usr/bin/open."""
675+
"""Launcher class for macOS browsers, using /usr/bin/open.
676+
677+
For http/https URLs with the default browser, /usr/bin/open is called
678+
directly; macOS routes these to the registered browser.
679+
680+
For all other URL schemes (e.g. file://) and for named browsers,
681+
/usr/bin/open -b <bundle-id> is used so that the URL is always passed
682+
to a browser application rather than dispatched by the OS file handler.
683+
This prevents file injection attacks where a file:// URL pointing to an
684+
executable bundle could otherwise be launched by the OS.
685+
686+
Named browsers with known bundle IDs use -b; unknown names fall back
687+
to -a.
688+
"""
689+
690+
_BUNDLE_IDS = {
691+
'google chrome': 'com.google.Chrome',
692+
'firefox': 'org.mozilla.firefox',
693+
'safari': 'com.apple.Safari',
694+
'chromium': 'org.chromium.Chromium',
695+
'opera': 'com.operasoftware.Opera',
696+
'microsoft edge': 'com.microsoft.Edge',
697+
}
618698

619699
def open(self, url, new=0, autoraise=True):
620700
sys.audit("webbrowser.open", url)
621701
self._check_url(url)
622702
if self.name == 'default':
623-
cmd = ['/usr/bin/open', url]
703+
proto, sep, _ = url.partition(':')
704+
if sep and proto.lower() in {'http', 'https'}:
705+
cmd = ['/usr/bin/open', url]
706+
else:
707+
bundle_id = _macos_default_browser_bundle_id()
708+
if bundle_id:
709+
cmd = ['/usr/bin/open', '-b', bundle_id, url]
710+
else:
711+
cmd = ['/usr/bin/open', url]
624712
else:
625-
cmd = ['/usr/bin/open', '-a', self.name, url]
713+
bundle_id = self._BUNDLE_IDS.get(self.name.lower())
714+
if bundle_id:
715+
cmd = ['/usr/bin/open', '-b', bundle_id, url]
716+
else:
717+
cmd = ['/usr/bin/open', '-a', self.name, url]
626718
proc = subprocess.run(cmd, stderr=subprocess.DEVNULL)
627719
return proc.returncode == 0
628720

0 commit comments

Comments
 (0)