Files
Netcatty/electron/bridges/cloudSyncBridge.webdavBasicAuth.test.cjs
bincxz ba675fa944 Use UTF-8 for WebDAV Basic Auth credentials
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).

Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.

Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
2026-05-10 21:37:52 +08:00

93 lines
3.0 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const {
buildBasicAuthHeader,
handleWebdavInitialize,
} = require("./cloudSyncBridge.cjs");
// Per RFC 7617, Basic Auth credentials are UTF-8 encoded before base64.
// The upstream `webdav` npm package (via `base-64`) encodes them as Latin1
// instead, which silently corrupts non-ASCII characters like German umlauts
// (ö, ä) — exactly the case reported by Hetzner Storage Box users (#891).
//
// `ö` (U+00F6):
// Latin1 → 1 byte: F6
// UTF-8 → 2 bytes: C3 B6
const PASSWORD_WITH_UMLAUT = "6?G:ö9yZöäMF+H3";
const USERNAME = "uHetzner1";
test("buildBasicAuthHeader UTF-8 encodes credentials (RFC 7617)", () => {
const header = buildBasicAuthHeader("user", "ö");
// UTF-8 base64 of "user:ö" = "user" + ":" + 0xC3 0xB6
assert.equal(header, "Basic dXNlcjrDtg==");
});
test("buildBasicAuthHeader stays compatible with pure-ASCII credentials", () => {
// For ASCII, UTF-8 and Latin1 are byte-identical, so the header is unchanged.
const header = buildBasicAuthHeader("user", "password");
assert.equal(header, "Basic dXNlcjpwYXNzd29yZA==");
});
function startUtf8BasicAuthServer({ username, password }) {
const expected =
"Basic " +
Buffer.from(`${username}:${password}`, "utf8").toString("base64");
const server = http.createServer((req, res) => {
const got = req.headers["authorization"];
if (got !== expected) {
res.writeHead(401, {
"WWW-Authenticate": 'Basic realm="test", charset="UTF-8"',
});
res.end("Unauthorized");
return;
}
// Minimal but parseable PROPFIND response so client.exists() resolves true.
res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" });
res.end(
`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>${req.url}</D:href>
<D:propstat>
<D:prop>
<D:displayname>netcatty-vault.json</D:displayname>
<D:getlastmodified>Sat, 10 May 2026 00:00:00 GMT</D:getlastmodified>
<D:getcontentlength>0</D:getcontentlength>
<D:resourcetype/>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`,
);
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, endpoint: `http://127.0.0.1:${port}` });
});
});
}
test("handleWebdavInitialize sends UTF-8 Basic Auth (Hetzner umlaut password #891)", async () => {
const { server, endpoint } = await startUtf8BasicAuthServer({
username: USERNAME,
password: PASSWORD_WITH_UMLAUT,
});
try {
const result = await handleWebdavInitialize({
endpoint,
authType: "password",
username: USERNAME,
password: PASSWORD_WITH_UMLAUT,
});
assert.equal(result.resourceId, "/netcatty-vault.json");
} finally {
await new Promise((resolve) => server.close(resolve));
}
});