Skip to content

Commit

Permalink
fix: do not uri-encode Basic Auth header contents (#6155)
Browse files Browse the repository at this point in the history
* fix: do not URI-encode Basic Auth header contents

* fix: do not expose username/password outside httpClient class
  • Loading branch information
jshufro authored Dec 7, 2023
1 parent 5270ef2 commit fa702df
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 3 deletions.
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

0 comments on commit fa702df

Please sign in to comment.