Fix WebDAV download auth for usernames with special characters

HTTP.download() was rewritten in 0fe31b0f04 to use fetch() and build the
Basic auth header itself via btoa(username + ':' + password), but the
username and password come from nsIURI.username/password, which are
percent-encoded. As a result, a username like "user@example.com" was
sent as "user%40example.com", causing a 401 on every WebDAV download for
any user with @, :, space, etc. in their username. Other request types
were unaffected because they go through xmlhttp.open(method, url, true,
username, password), which decodes internally.

Decode username and password in _parseURI() so the values returned can
be used directly for Basic auth (and as a side effect, fix the other
display/use sites that were getting the percent-encoded form).

https://forums.zotero.org/discussion/131174/zotero-10-betas-1-2-3-cant-download-from-webdav-http-401
This commit is contained in:
Dan Stillman 2026-04-28 12:57:17 -04:00
parent e13e85b2e5
commit fd812070b6
2 changed files with 34 additions and 6 deletions

View file

@ -15,8 +15,8 @@ Zotero.HTTP = new function () {
* Parse a URI (nsIURI or string), extract any embedded credentials, and
* return the URL as a credential-free string
*
* Mozilla percent-encodes periods in the username component of nsIURIs (%2E), which is
* technically valid but breaks Basic auth against most servers, so we undo that here.
* nsIURI returns the username and password percent-encoded, so we decode them so
* callers can use them directly (e.g., when building a Basic auth header).
*
* @param {nsIURI|String} uri
* @return {{ url: String, username: String|null, password: String|null }}
@ -30,11 +30,9 @@ Zotero.HTTP = new function () {
return { url: uri, username: null, password: null };
}
}
let username = uri.username || null;
let password = null;
let username = uri.username ? decodeURIComponent(uri.username) : null;
let password = uri.password ? decodeURIComponent(uri.password) : null;
if (username) {
username = username.replace(/%2E/, '.');
password = uri.password || null;
uri = uri.mutate().setUserPass('').finalize();
}
return { url: uri.spec, username, password };

View file

@ -421,6 +421,23 @@ describe("Zotero.HTTP", function () {
}
}
);
httpd.registerPathHandler(
'/download/auth',
{
handle: function (request, response) {
let val;
try {
val = request.getHeader("Authorization");
}
catch (e) {
val = "";
}
response.setStatusLine(null, 200, "OK");
response.setHeader("X-Echo-Auth", val, false);
response.write("ok");
}
}
);
});
beforeEach(async function () {
@ -551,6 +568,19 @@ describe("Zotero.HTTP", function () {
let stat = await IOUtils.stat(dest);
assert.equal(stat.size, 3 * 1024);
});
it("should decode percent-encoded credentials from URL for Basic auth", async function () {
let username = "user@example.com";
let password = "secret";
let expected = "Basic " + btoa(username + ":" + password);
let url = `http://${encodeURIComponent(username)}:${password}`
+ `@127.0.0.1:${port}/download/auth`;
let dest = PathUtils.join(tmpDir, "auth.bin");
let nsUri = Services.io.newURI(url);
let req = await Zotero.HTTP.download(nsUri, dest);
assert.equal(req.status, 200);
assert.equal(req.headers.get("X-Echo-Auth"), expected);
});
});