Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: do not uri-encode Basic Auth header contents #6155

Merged
merged 2 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/api/src/utils/client/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export class HttpClient implements IHttpClient {
private readonly urlsScore: number[];

get baseUrl(): string {
return this.urlsOpts[0].baseUrl;
// Don't leak username/password to caller
return new URL(this.urlsOpts[0].baseUrl).origin;
}

/**
Expand Down Expand Up @@ -292,7 +293,7 @@ export class HttpClient implements IHttpClient {
}
if (url.username || url.password) {
if (headers["Authorization"] === undefined) {
headers["Authorization"] = `Basic ${toBase64(`${url.username}:${url.password}`)}`;
headers["Authorization"] = `Basic ${toBase64(decodeURIComponent(`${url.username}:${url.password}`))}`;
}
// Remove the username and password from the URL
url.username = "";
Expand Down
35 changes: 34 additions & 1 deletion packages/api/test/unit/client/httpClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {IncomingMessage} from "node:http";
import {expect} from "chai";
import fastify, {RouteOptions} from "fastify";
import {ErrorAborted, TimeoutError} from "@lodestar/utils";
import {ErrorAborted, TimeoutError, toBase64} from "@lodestar/utils";
import {HttpClient, HttpError} from "../../../src/utils/client/index.js";
import {HttpStatusCode} from "../../../src/utils/client/httpStatusCode.js";

Expand Down Expand Up @@ -151,6 +151,39 @@ describe("httpClient json client", () => {
await httpClient.json(testRoute);
});

it("should not URI-encode user credentials in Authorization header", async () => {
// Semi exhaustive set of characters that RFC-3986 allows in the userinfo portion of a URI
// Notably absent is `%`. See comment on isValidHttpUrl().
const username = "A1-._~!$'&\"()*+,;=";
const password = "b2-._~!$'&\"()*+,;=";
let {baseUrl} = await getServer({
...testRoute,
handler: async (req) => {
expect(req.headers.authorization).to.equal(`Basic ${toBase64(`${username}:${password}`)}`);
return {};
},
});
// Since `new URL()` is what URI-encodes, we have to do string manipulation to set the username/password
// First validate the assumption that the URL starts with http://
expect(baseUrl.indexOf("http://")).to.equal(0);
// We avoid using baseUrl.replace() because it treats $ as a special character
baseUrl = `http://${username}:${password}@${baseUrl.substring("http://".length)}`;

const httpClient = new HttpClient({baseUrl: baseUrl});

await httpClient.json(testRoute);
});

it("should not leak user credentials in baseUrl getter", () => {
const url = new URL("http://localhost");
url.username = "user";
url.password = "password";
const httpClient = new HttpClient({baseUrl: url.toString()});

expect(httpClient.baseUrl.includes(url.username)).to.be.false;
expect(httpClient.baseUrl.includes(url.password)).to.be.false;
});

it("should handle aborting request with timeout", async () => {
const {baseUrl} = await getServer({
...testRoute,
Expand Down
4 changes: 4 additions & 0 deletions packages/api/test/unit/client/httpClientOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,8 @@ describe("HTTPClient options", () => {
it("Throw if invalid value in urls option", () => {
expect(() => new HttpClient({urls: ["invalid"]})).to.throw(Error);
});

it("Throw if invalid username/password", () => {
expect(() => new HttpClient({baseUrl: "http://hasa%:%can'tbedecoded@localhost"})).to.throw(Error);
});
});
10 changes: 10 additions & 0 deletions packages/utils/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ export function isValidHttpUrl(urlStr: string): boolean {
let url;
try {
url = new URL(urlStr);

// `new URL` encodes the username/password with the userinfo percent-encode set.
// This means the `%` character is not encoded, but others are (such as `=`).
// If a username/password contain a `%`, they will not be able to be decoded.
//
// Make sure that we can successfully decode the username and password here.
//
// Unfortunately this means we don't accept every character supported by RFC-3986.
decodeURIComponent(url.username);
decodeURIComponent(url.password);
} catch (_) {
return false;
}
Expand Down