Environment
|
|
| gup.exe FileVersion |
5.42 (v5.4.2), as bundled with Notepad++ 8.9.7 x64 |
| Defect verified in |
master @ 80b6e0f (v5.4.3) — still present in latest |
| OS |
<< Windows version, e.g. Windows 11 24H2 x64 >> |
| Network |
Corporate forward proxy (Zscaler 6.2) performing threat inspection |
Summary
downloadBinary() treats any completed HTTP transaction as a successful download, regardless of status code. When an intermediary returns a non-2xx response, the response body is written to the destination .zip path and the function proceeds as though a package had been retrieved.
In the plugin-install path this surfaces to the user as "Plugin package hash mismatched", which points the user at the package or the manifest — when the actual fault is that no package was ever downloaded.
Root cause
src/winmain.cpp, downloadBinary() (line 1006):
- The destination file is opened
wb at line 1008, before the request is issued.
CURLOPT_WRITEDATA is bound to that handle (line 1024), so libcurl writes whatever body arrives — including an error body.
- The only failure test is
if (res != CURLE_OK) at line 1047. A 403 that returns a body is CURLE_OK; libcurl retrieved the response exactly as asked.
CURLOPT_FAILONERROR is never set, and CURLINFO_RESPONSE_CODE is never queried.
Confirmed repo-wide — none of CURLOPT_FAILONERROR, CURLINFO_RESPONSE_CODE, or curl_easy_getinfo appears anywhere under src/.
Control flow then reaches the hash check at lines 1069–1103 with an HTML document in a file named *.zip, and line 1103 reports it as a hash mismatch.
Reproduction
Any intermediary that returns a non-2xx response with a body will reproduce this. Observed with a Zscaler sandbox analysis hold on first encounter of an unseen archive:
- Plugins Admin → install a plugin whose release asset the proxy has not previously seen.
- The proxy answers the request to
release-assets.githubusercontent.com with HTTP/1.1 403 Forbidden, Server: Zscaler/6.2, Content-Type: text/html, ~20 KB body.
- gup.exe writes those ~20 KB of HTML to the destination
.zip.
- Dialog: "Plugin package hash mismatched", reporting the SHA-256 of the HTML page.
Worth stressing what that 403 body actually says, because it is not a block:
We're checking this file for a potential security risk. The file you attempted to download is being analyzed for your protection. It is not blocked. The analysis can take up to 10 minutes, depending on the size and type of the file. If safe, your file downloads automatically.
So the upstream condition is "retry shortly," delivered as a terminal 4xx. The intermediary's own retry mechanism is client-side JavaScript (an escalating refreshTimes schedule backed by localStorage), which requires the hold page to be the top-level document. That excludes not just non-browser clients like this one, but also JavaScript-initiated downloads inside web applications, where the 403 lands in a hidden iframe or an XHR handler and fails silently. In every such case the only usable signal is the status code — which WinGUP discards.
The dialog from the original occurrence, for illustration:
The hash of plugin package "ComparePlus_cp_3.0.0_x64.zip" is not correct.
Expected: ed6256afcddca5715d81c48d3d1d2f35f4533cfa238320cb21d9d69ce86b5572
<> Found: a864f47d774dfdbef89dda52a5c974f33ee52ffd52965c529c9a7f74352690de
This plugin won't be installed.
ed6256af… is correct — it matches the upstream asset. a864f47d… is the hash of the intermediary's HTML page.
Evidence
Reproduced outside Notepad++ with a harness replicating downloadBinary()'s curl configuration (same UA form, FOLLOWLOCATION, no --fail). Seven GitHub release assets, ground-truth sizes and hashes established from an unproxied path. Pass B retests the identical URLs after 240 s.
| Asset |
Expected bytes |
Pass A |
Pass B (+240 s) |
| NX_ASCII_Database_Helper.zip |
782,120 |
403 / 20,988 B HTML |
200 / 782,120 B — match |
| NppRgaIsaPlugin_v1.0.0.0.zip |
223,694 |
403 / 20,973 B HTML |
200 / 223,694 B — match |
| MZC8051_x64.zip |
956,611 |
403 / 20,904 B HTML |
200 / 956,611 B — match |
| BunyanLogViewer_x64.zip |
28,004 |
403 / 20,961 B HTML |
200 / 28,004 B — match |
| HLASMLexer_x64.zip |
55,528 |
403 / 20,913 B HTML |
403 / 20,919 B HTML |
| Dual-Scrollbars-v1.0.0-x64.zip |
52,394 |
403 / 20,991 B HTML |
403 / 20,979 B HTML |
| ComparePlus_cp_3.0.0_x64.zip (control — previously fetched, cached verdict) |
2,653,191 |
200 — match |
200 — match |
Four assets flip 403 → byte-exact 200 at the 240 s retest with no change other than elapsed time. The remaining two (HLASMLexer_x64.zip, Dual-Scrollbars-v1.0.0-x64.zip) also returned byte-exact 200 on a later attempt, having taken longer than the 10-minute maximum the hold page states. All six held assets ultimately downloaded correctly and matched their manifest hashes. The 403 bodies were therefore transient refusals, not corrupt packages, and no package in this set was ever actually blocked. The control confirms transport and manifest hashes are sound.
Every 403 body begins:
<!--# Id: security.html ... --> <!DOCTYPE HTML ...>
<html> <head> <meta name="description" content="... protecting their employees from malware ...">
That is what currently lands on disk as a plugin package. No Retry-After header is present on any response.
One further detail relevant to the error message: the hold page body differs on every request. Repeated fetches of the same URL returned 20,913 / 20,919 / 20,907 bytes with a different SHA-256 each time, presumably from an embedded per-transaction identifier. So the hash reported in the "Found:" field is not stable across attempts — a user comparing it between retries sees it change, reinforcing a "corrupted package" reading of what is actually a non-package.
Impact
- Misdiagnosis. The error text sends users toward the plugin author, the manifest, or
nppPluginList.dll, none of which are at fault. The information needed — an HTTP status — is available and discarded.
- A retryable condition is rendered permanent. The upstream state was "analysis in progress, retry shortly." Because the status is never examined, WinGUP cannot distinguish it from a genuine failure, so it cannot retry and instead reports corruption. This is the practically significant consequence — inspecting proxies are common in corporate environments, and plugin installation is simply broken behind them on first fetch of any given asset.
- The manifest hash is the sole backstop on the plugin path. It works, but it is doing integrity verification's job and transport error detection's job, and can only report failure in integrity terms. (The application-update path additionally performs Authenticode verification via
verifySignedfile.cpp, so this is not a code-execution report.)
- Not proxy-specific. Captive portals, ISP interception, GitHub 5xx, rate limiting, and expired pre-signed asset URLs all produce the same class of failure.
Proposed fix
Set CURLOPT_FAILONERROR so libcurl fails the transfer on 4xx/5xx:
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
Or, to keep the status available for reporting, retrieve it explicitly after curl_easy_perform():
long httpCode = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
if (httpCode < 200 || httpCode >= 300)
{
// report the status; do not treat the body as a package
}
Because the destination file is opened before the request, it should also be closed and removed on this path so a partial or error body is not left behind for a later step to consume.
Two cheap defensive additions, given a manifest hash is already available:
- Reject
Content-Type: text/html when an archive is expected (CURLINFO_CONTENT_TYPE).
- Verify the ZIP local-file-header magic (
50 4B 03 04) before hashing. A 20 KB HTML document in place of a 782 KB archive is detectable without any network knowledge.
Optional, and I'd understand declining it: once the status is known, a bounded retry on the transient classes (202, 429, 503, and in practice 403 from an inspecting intermediary) would let installs succeed unattended behind a scanning proxy. In my testing every held asset eventually became available, so retry would have resolved all of them — though the wait exceeded 10 minutes in two cases, which is a long time to hold an install open. I'd still rank this below the correctness fix: reporting the status accurately is the fix, and retry policy arguably belongs to the caller rather than to downloadBinary().
Secondary: distinguish download failure from integrity failure
Independently of the above, the line 1103 dialog should not describe a failed download as a hash mismatch. Two distinct conditions deserve two messages:
- Download failed — non-2xx status, wrong content type, or non-archive payload. Report URL, HTTP status, content type, and bytes received.
- Integrity mismatch — a genuine archive was retrieved whose hash differs from the manifest. This is the tampering/staleness case the current message is written for.
Reporting the HTTP status in the first case would have made this self-diagnosing.
Environment
master@80b6e0f(v5.4.3) — still present in latest<< Windows version, e.g. Windows 11 24H2 x64 >>Summary
downloadBinary()treats any completed HTTP transaction as a successful download, regardless of status code. When an intermediary returns a non-2xx response, the response body is written to the destination.zippath and the function proceeds as though a package had been retrieved.In the plugin-install path this surfaces to the user as "Plugin package hash mismatched", which points the user at the package or the manifest — when the actual fault is that no package was ever downloaded.
Root cause
src/winmain.cpp,downloadBinary()(line 1006):wbat line 1008, before the request is issued.CURLOPT_WRITEDATAis bound to that handle (line 1024), so libcurl writes whatever body arrives — including an error body.if (res != CURLE_OK)at line 1047. A 403 that returns a body isCURLE_OK; libcurl retrieved the response exactly as asked.CURLOPT_FAILONERRORis never set, andCURLINFO_RESPONSE_CODEis never queried.Confirmed repo-wide — none of
CURLOPT_FAILONERROR,CURLINFO_RESPONSE_CODE, orcurl_easy_getinfoappears anywhere undersrc/.Control flow then reaches the hash check at lines 1069–1103 with an HTML document in a file named
*.zip, and line 1103 reports it as a hash mismatch.Reproduction
Any intermediary that returns a non-2xx response with a body will reproduce this. Observed with a Zscaler sandbox analysis hold on first encounter of an unseen archive:
release-assets.githubusercontent.comwithHTTP/1.1 403 Forbidden,Server: Zscaler/6.2,Content-Type: text/html, ~20 KB body..zip.Worth stressing what that 403 body actually says, because it is not a block:
So the upstream condition is "retry shortly," delivered as a terminal 4xx. The intermediary's own retry mechanism is client-side JavaScript (an escalating
refreshTimesschedule backed bylocalStorage), which requires the hold page to be the top-level document. That excludes not just non-browser clients like this one, but also JavaScript-initiated downloads inside web applications, where the 403 lands in a hidden iframe or an XHR handler and fails silently. In every such case the only usable signal is the status code — which WinGUP discards.The dialog from the original occurrence, for illustration:
ed6256af…is correct — it matches the upstream asset.a864f47d…is the hash of the intermediary's HTML page.Evidence
Reproduced outside Notepad++ with a harness replicating
downloadBinary()'s curl configuration (same UA form,FOLLOWLOCATION, no--fail). Seven GitHub release assets, ground-truth sizes and hashes established from an unproxied path. Pass B retests the identical URLs after 240 s.Four assets flip
403 → byte-exact 200at the 240 s retest with no change other than elapsed time. The remaining two (HLASMLexer_x64.zip,Dual-Scrollbars-v1.0.0-x64.zip) also returned byte-exact 200 on a later attempt, having taken longer than the 10-minute maximum the hold page states. All six held assets ultimately downloaded correctly and matched their manifest hashes. The 403 bodies were therefore transient refusals, not corrupt packages, and no package in this set was ever actually blocked. The control confirms transport and manifest hashes are sound.Every 403 body begins:
That is what currently lands on disk as a plugin package. No
Retry-Afterheader is present on any response.One further detail relevant to the error message: the hold page body differs on every request. Repeated fetches of the same URL returned 20,913 / 20,919 / 20,907 bytes with a different SHA-256 each time, presumably from an embedded per-transaction identifier. So the hash reported in the "Found:" field is not stable across attempts — a user comparing it between retries sees it change, reinforcing a "corrupted package" reading of what is actually a non-package.
Impact
nppPluginList.dll, none of which are at fault. The information needed — an HTTP status — is available and discarded.verifySignedfile.cpp, so this is not a code-execution report.)Proposed fix
Set
CURLOPT_FAILONERRORso libcurl fails the transfer on 4xx/5xx:Or, to keep the status available for reporting, retrieve it explicitly after
curl_easy_perform():Because the destination file is opened before the request, it should also be closed and removed on this path so a partial or error body is not left behind for a later step to consume.
Two cheap defensive additions, given a manifest hash is already available:
Content-Type: text/htmlwhen an archive is expected (CURLINFO_CONTENT_TYPE).50 4B 03 04) before hashing. A 20 KB HTML document in place of a 782 KB archive is detectable without any network knowledge.Optional, and I'd understand declining it: once the status is known, a bounded retry on the transient classes (
202,429,503, and in practice403from an inspecting intermediary) would let installs succeed unattended behind a scanning proxy. In my testing every held asset eventually became available, so retry would have resolved all of them — though the wait exceeded 10 minutes in two cases, which is a long time to hold an install open. I'd still rank this below the correctness fix: reporting the status accurately is the fix, and retry policy arguably belongs to the caller rather than todownloadBinary().Secondary: distinguish download failure from integrity failure
Independently of the above, the line 1103 dialog should not describe a failed download as a hash mismatch. Two distinct conditions deserve two messages:
Reporting the HTTP status in the first case would have made this self-diagnosing.