Skip to content

Commit 3384603

Browse files
authored
Merge aa2a55c into 7736fdd
2 parents 7736fdd + aa2a55c commit 3384603

9 files changed

Lines changed: 133 additions & 19 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ dependencies = [
2929
# pinned to a commit in tool.uv.sources
3030
"configobj",
3131
"requests==2.32.3",
32+
# Overrides certifi so that requests uses system certificates instead
33+
"pip_system_certs==5.2",
3234
"url-normalize==1.4.3",
3335
"schedule==1.2.2",
3436
# NVDA_DMP requires diff-match-patch
@@ -122,6 +124,7 @@ only_licenses = ["BSD", "MIT", "Python", "LGPLV3+", "Apache"]
122124
ignore_packages = [
123125
# Compatible licenses:
124126
"certifi", # Mozilla Public License 2.0
127+
"pip_system_certs", # BSD 2-Clause "Simplified" License, but not in PyPI
125128
"markdown-link-attr-modifier", # GPLV3 license, but not in PyPI correctly
126129
"pycaw", # MIT license, but not in PyPI
127130
"urllib3", # MIT license, but not in PyPI

source/_remoteClient/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from select import select
3434
from itertools import count
3535
from typing import Any, Final
36+
from unittest import mock
3637

3738
import cffi # noqa # required for cryptography
3839
from cryptography import x509
@@ -294,7 +295,13 @@ def createServerSocket(self, family: int, type: int, bindAddress: tuple[str, int
294295
"""
295296
serverSocket = socket.socket(family, type)
296297
sslContext = self.certManager.createSSLContext()
297-
serverSocket = sslContext.wrap_socket(serverSocket, server_side=True)
298+
299+
# Prevent pip_system_certs from patching wrap_socket due to a bug.
300+
with mock.patch(
301+
"pip._vendor.truststore._api._verify_peercerts",
302+
lambda *a, **kw: None,
303+
):
304+
serverSocket = sslContext.wrap_socket(serverSocket, server_side=True)
298305
serverSocket.bind(bindAddress)
299306
serverSocket.listen(5) # Set the maximum number of queued connections
300307
return serverSocket

source/_remoteClient/transport.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from logHandler import log
3737
from queue import Queue
3838
from typing import Any, Literal, Optional, Self
39+
from unittest import mock
3940

4041
import wx
4142
from extensionPoints import Action, HandlerRegistrar
@@ -433,14 +434,21 @@ def createOutboundSocket(
433434
serverSock.settimeout(self.timeout)
434435
serverSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
435436
serverSock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 60000, 2000))
436-
ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
437+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
438+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
439+
ctx.maximum_version = ssl.TLSVersion.TLSv1_3
437440
if insecure:
438441
ctx.verify_mode = ssl.CERT_NONE
439442
log.warn(f"Skipping certificate verification for {host}:{port}")
440443
ctx.check_hostname = not insecure
441444
ctx.load_default_certs()
442445

443-
serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host)
446+
# Prevent pip_system_certs from patching wrap_socket due to a bug.
447+
with mock.patch(
448+
"pip._vendor.truststore._api._verify_peercerts",
449+
lambda *a, **kw: None,
450+
):
451+
serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host)
444452
return serverSock
445453

446454
def getpeercert(

source/addonStore/dataManager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
125125
url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion)
126126
try:
127127
log.debug(f"Fetching add-on data from {url}")
128-
response = requests.get(url, timeout=FETCH_TIMEOUT_S)
128+
response = _fetchUrlAndUpdateRootCertificates(url)
129129
except requests.exceptions.RequestException as e:
130130
log.debugWarning(f"Unable to fetch addon data: {e}")
131131
return None

source/addonStore/network.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# A part of NonVisual Desktop Access (NVDA)
2-
# Copyright (C) 2022-2024 NV Access Limited
2+
# Copyright (C) 2022-2025 NV Access Limited
33
# This file is covered by the GNU General Public License.
44
# See the file COPYING for more details.
55

66
from concurrent.futures import (
77
Future,
88
ThreadPoolExecutor,
99
)
10+
import hashlib
1011
import os
1112
import pathlib
1213
import shutil
@@ -18,6 +19,7 @@
1819
Optional,
1920
Tuple,
2021
)
22+
from urllib.parse import urlparse
2123

2224
import requests
2325

@@ -28,6 +30,11 @@
2830
from NVDAState import WritePaths
2931
import threading
3032
from utils.security import sha256_checksum
33+
from utils.networking import (
34+
_getCertificate,
35+
_is_cert_verification_error,
36+
_updateWindowsRootCertificates,
37+
)
3138
from config import conf
3239

3340
from .models.addon import (
@@ -216,7 +223,11 @@ def _downloadAddonToPath(
216223
# Some add-ons are quite large, so we need to allow for a long download time.
217224
# 1GB at 0.5 MB/s takes 4.5hr to download.
218225
MAX_ADDON_DOWNLOAD_TIME = 60 * 60 * 6 # 6 hours
219-
with requests.get(addonData.model.URL, stream=True, timeout=MAX_ADDON_DOWNLOAD_TIME) as r:
226+
with requests.get(
227+
addonData.model.URL,
228+
stream=True,
229+
timeout=MAX_ADDON_DOWNLOAD_TIME,
230+
) as r:
220231
with open(downloadFilePath, "wb") as fd:
221232
# Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow
222233
# interrupting the download. This is particularly important on a slow connection, to provide
@@ -236,11 +247,56 @@ def _downloadAddonToPath(
236247
return False # The download was cancelled
237248
return True
238249

239-
def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[os.PathLike]:
240-
from gui.message import DisplayableError
250+
# Translators: A title for a dialog notifying a user of an add-on download failure.
251+
_addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure")
241252

242-
# Translators: A title for a dialog notifying a user of an add-on download failure.
243-
_addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure")
253+
def _handleCertVerificationError(
254+
self,
255+
exception: requests.exceptions.SSLError,
256+
listItem: "AddonListItemVM[_AddonStoreModel]",
257+
) -> os.PathLike | None:
258+
import wx
259+
from gui.message import messageBox, DisplayableError
260+
261+
if _is_cert_verification_error(exception):
262+
cert = _getCertificate(listItem.model.URL)
263+
certFingerprint = hashlib.sha256(cert).hexdigest()
264+
265+
if (
266+
messageBox(
267+
message=pgettext(
268+
"addonStore",
269+
# Translators: A message to the user if an add-on download fails.
270+
# url is replaced with the base URL of the add-on download e.g. (github.com).
271+
# fingerprint is replaced with the SHA256 fingerprint of the certificate.
272+
"The website where you are downloading the add-on from has a certificate that is not trusted. "
273+
"Do you want to trust the root certificate for {url}? "
274+
"This will allow you to download add-ons from this website in the future. "
275+
"Only do this if you trust the website. "
276+
"The certificate's SHA256 fingerprint is: {fingerprint}. ",
277+
).format(url=urlparse(listItem.model.URL).netloc, fingerprint=certFingerprint),
278+
caption=self._addonDownloadFailureMessageTitle,
279+
style=wx.OK | wx.CANCEL | wx.CENTRE | wx.ICON_WARNING,
280+
)
281+
== wx.OK
282+
):
283+
_updateWindowsRootCertificates(cert)
284+
return self._download(listItem)
285+
else:
286+
return None # The download was cancelled
287+
else:
288+
log.debugWarning(f"Unable to download addon file: {exception}")
289+
raise DisplayableError(
290+
pgettext(
291+
"addonStore",
292+
# Translators: A message to the user if an add-on download fails
293+
"Unable to download add-on: {name}",
294+
).format(name=listItem.model.displayName),
295+
self._addonDownloadFailureMessageTitle,
296+
)
297+
298+
def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> os.PathLike | None:
299+
from gui.message import DisplayableError
244300

245301
addonData = listItem.model
246302
log.debug(f"starting download: {addonData.addonId}")
@@ -257,6 +313,8 @@ def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[o
257313
try:
258314
if not self._downloadAddonToPath(listItem, inProgressFilePath):
259315
return None # The download was cancelled
316+
except requests.exceptions.SSLError as e:
317+
return self._handleCertVerificationError(e, listItem)
260318
except requests.exceptions.RequestException as e:
261319
log.debugWarning(f"Unable to download addon file: {e}")
262320
raise DisplayableError(
@@ -265,7 +323,7 @@ def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[o
265323
# Translators: A message to the user if an add-on download fails
266324
"Unable to download add-on: {name}",
267325
).format(name=addonData.displayName),
268-
_addonDownloadFailureMessageTitle,
326+
self._addonDownloadFailureMessageTitle,
269327
)
270328
except OSError as e:
271329
log.debugWarning(f"Unable to save addon file ({inProgressFilePath}): {e}")
@@ -275,7 +333,7 @@ def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[o
275333
# Translators: A message to the user if an add-on download fails
276334
"Unable to save add-on as a file: {name}",
277335
).format(name=addonData.displayName),
278-
_addonDownloadFailureMessageTitle,
336+
self._addonDownloadFailureMessageTitle,
279337
)
280338
if not self._checkChecksum(inProgressFilePath, addonData):
281339
with self.DOWNLOAD_LOCK:
@@ -287,7 +345,7 @@ def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[o
287345
# Translators: A message to the user if an add-on download is not safe
288346
"Add-on download not safe: checksum failed for {name}",
289347
).format(name=addonData.displayName),
290-
_addonDownloadFailureMessageTitle,
348+
self._addonDownloadFailureMessageTitle,
291349
)
292350
log.debug(f"Download complete: {inProgressFilePath}")
293351
with self.DOWNLOAD_LOCK:

source/core.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import NVDAState
3333
from NVDAState import WritePaths
3434

35+
import pip_system_certs.wrapt_requests
36+
3537
if TYPE_CHECKING:
3638
import wx
3739

@@ -672,6 +674,10 @@ def main():
672674
Finally, it starts the wx main loop.
673675
"""
674676
log.debug("Core starting")
677+
678+
# Use Windows root certificates for requests rather than certifi.
679+
pip_system_certs.wrapt_requests.inject_truststore()
680+
675681
if NVDAState.isRunningAsSource():
676682
# When running as packaged version, DPI awareness is set via the app manifest.
677683
from winAPI.dpiAwareness import setDPIAwareness
@@ -694,6 +700,7 @@ def main():
694700
WritePaths.configDir = config.getUserDefaultConfigPath(
695701
useInstalledPathIfExists=globalVars.appArgs.launcher,
696702
)
703+
697704
# Initialize the config path (make sure it exists)
698705
config.initConfigPath()
699706
log.info(f"Config dir: {WritePaths.configDir}")

source/utils/networking.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,9 @@ class _CERT_CHAIN_PARA(ctypes.Structure):
4141
)
4242

4343

44-
def _updateWindowsRootCertificates(url: str) -> None:
45-
"""Updates the Windows root certificates by fetching the latest certificate from the server."""
46-
log.debug("Updating Windows root certificates")
47-
crypt = ctypes.windll.crypt32
44+
def _getCertificate(url: str) -> bytes:
45+
"""Gets the certificate from the server."""
46+
log.debug(f"Getting certificate from: {url}")
4847
with requests.get(
4948
url,
5049
timeout=_FETCH_TIMEOUT_S,
@@ -53,7 +52,13 @@ def _updateWindowsRootCertificates(url: str) -> None:
5352
stream=True,
5453
) as response:
5554
# Get the server certificate.
56-
cert = response.raw.connection.sock.getpeercert(True)
55+
return response.raw.connection.sock.getpeercert(True)
56+
57+
58+
def _updateWindowsRootCertificates(cert: bytes) -> None:
59+
"""Adds the certificate to the Windows root certificates."""
60+
log.debug("Updating Windows root certificates")
61+
crypt = ctypes.windll.crypt32
5762
# Convert to a form usable by Windows.
5863
certCont = crypt.CertCreateCertificateContext(
5964
0x00000001, # X509_ASN_ENCODING
@@ -87,6 +92,7 @@ def _is_cert_verification_error(exception: requests.exceptions.SSLError) -> bool
8792
and exception.__context__.__cause__
8893
and exception.__context__.__cause__.__context__
8994
and isinstance(exception.__context__.__cause__.__context__, ssl.SSLCertVerificationError)
95+
and hasattr(exception.__context__.__cause__.__context__, "reason")
9096
and exception.__context__.__cause__.__context__.reason == "CERTIFICATE_VERIFY_FAILED"
9197
)
9298

@@ -106,7 +112,8 @@ def _fetchUrlAndUpdateRootCertificates(url: str, certFetchUrl: str | None = None
106112
if _is_cert_verification_error(e):
107113
# #4803: Windows fetches trusted root certificates on demand.
108114
# Python doesn't trigger this fetch (PythonIssue:20916), so try it ourselves.
109-
_updateWindowsRootCertificates(certFetchUrl or url)
115+
cert = _getCertificate(certFetchUrl or url)
116+
_updateWindowsRootCertificates(cert)
110117
log.debug(f"Retrying fetching data from: {url}")
111118
result = requests.get(url, timeout=_FETCH_TIMEOUT_S)
112119
else:

user_docs/en/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ There have also been a number of other fixes and improvements, including to mous
7070
* In focus mode in web browsers, it is now possible to review and spell the labels of controls when those labels are specifically provided for accessibility; e.g. via `aria-label` or `aria-labelledby`. (#15159, @jcsteh)
7171
* It is now possible to review and spell the labels of controls in Google Chrome menus and dialogs. (#11285, @jcsteh)
7272
* When typing into a cell in Microsoft Excel, the braille display is now correctly updated to show the new content. (#18391)
73+
* Fixed bug when trying to access the Add-on Store from certain environments such as corporates. (#18354)
7374
* Fixed bug where NLS eReader Zoomax driver did not work with all devices. (#18406, @florin-trutiu)
7475

7576
### Changes for Developers

uv.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)