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: set user credentials in URL as Authorization header #5884

Merged
merged 7 commits into from
Aug 21, 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
23 changes: 19 additions & 4 deletions packages/api/src/utils/client/httpClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ErrorAborted, Logger, TimeoutError} from "@lodestar/utils";
import {ErrorAborted, Logger, TimeoutError, isValidHttpUrl, toBase64} from "@lodestar/utils";
import {ReqGeneric, RouteDef} from "../index.js";
import {ApiClientResponse, ApiClientSuccessResponse} from "../../interfaces.js";
import {fetch, isFetchError} from "./fetch.js";
Expand Down Expand Up @@ -123,8 +123,15 @@ export class HttpClient implements IHttpClient {

// opts.baseUrl is equivalent to `urls: [{baseUrl}]`
// unshift opts.baseUrl to urls, without mutating opts.urls
for (const urlOrOpts of [...(baseUrl ? [baseUrl] : []), ...urls]) {
for (const [i, urlOrOpts] of [...(baseUrl ? [baseUrl] : []), ...urls].entries()) {
const urlOpts: URLOpts = typeof urlOrOpts === "string" ? {baseUrl: urlOrOpts, ...allUrlOpts} : urlOrOpts;

if (!urlOpts.baseUrl) {
throw Error(`HttpClient.urls[${i}] is empty or undefined: ${urlOpts.baseUrl}`);
}
if (!isValidHttpUrl(urlOpts.baseUrl)) {
throw Error(`HttpClient.urls[${i}] must be a valid URL: ${urlOpts.baseUrl}`);
}
// De-duplicate by baseUrl, having two baseUrls with different token or timeouts does not make sense
if (!this.urlsOpts.some((opt) => opt.baseUrl === urlOpts.baseUrl)) {
this.urlsOpts.push(urlOpts);
Expand Down Expand Up @@ -269,7 +276,7 @@ export class HttpClient implements IHttpClient {
const timer = this.metrics?.requestTime.startTimer({routeId});

try {
const url = urlJoin(baseUrl, opts.url) + (opts.query ? "?" + stringifyQuery(opts.query) : "");
const url = new URL(urlJoin(baseUrl, opts.url) + (opts.query ? "?" + stringifyQuery(opts.query) : ""));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function stringifyQuery uses a third-party library. See if we can replace it with the native node:querystring module.

Copy link
Member Author

@nflaig nflaig Aug 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't as it does not support all formats required to be openapi compliant.

Specifically we want the "repeat" format like this topic=topic1&topic=topic2, although I think comma-separated should be supported by all clients as well

* - arrayFormat: repeat `topic=topic1&topic=topic2`
*/
export function stringifyQuery(query: unknown): string {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As another side note, we use the same library for parsing, last time I looked at this, there was no native alternative for it and qs was the best library I could find

qs.parse(str, {
// Array as comma-separated values must be supported to be OpenAPI spec compliant
comma: true,
// Drop support for array query strings like `id[0]=1&id[1]=2&id[2]=3` as those are not required to
// be OpenAPI spec compliant and results are inconsistent, see https://github.com/ljharb/qs/issues/331.
// The schema validation will catch this and throw an error as parsed query string results in an object.
parseArrays: false,
}),


const headers =
extraHeaders && opts.headers ? {...extraHeaders, ...opts.headers} : opts.headers || extraHeaders || {};
Expand All @@ -279,6 +286,14 @@ export class HttpClient implements IHttpClient {
if (bearerToken && headers["Authorization"] === undefined) {
headers["Authorization"] = `Bearer ${bearerToken}`;
}
if (url.username || url.password) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a compound condition for both, we can't generate a valid Authorization header otherwise.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per RFC (https://datatracker.ietf.org/doc/html/rfc2617) those are optional and can be omitted (*, i.e. 0 or more).

     user-pass   = userid ":" password
      userid      = *<TEXT excluding ":">
      password    = *TEXT

In both cases fetch also throws an error as it does not allow credentials in the URL

fetch("http://user@localhost")
TypeError: Request cannot be constructed from a URL that includes credentials: http://user@localhost
fetch("http://:password@localhost")
> Uncaught:
TypeError: Request cannot be constructed from a URL that includes credentials: http://:password@localhost

if (headers["Authorization"] === undefined) {
headers["Authorization"] = `Basic ${toBase64(`${url.username}:${url.password}`)}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can move this logic to a utility function generateAuthHeader(username, password), that will be easy to manage in future.

Copy link
Member Author

@nflaig nflaig Aug 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was considering that as well but opted against it because

  • not reused anywhere else
  • simple enough
  • format of auth header as defined in RFC will never change

Same argument could be made for setting bearer token above but I think the code is clearer as is.

}
Comment on lines +289 to +292
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throw an error if the user sets the Authorization header attribute as well as the username/password as options.

Copy link
Member Author

@nflaig nflaig Aug 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it but after looking at other http client implementations (e.g. native node http) I figured usually they just have a defined order of precedence, and we don't do that for bearer token either.

From node docs:

Sending an Authorization header will override using the auth option to compute basic authentication.

In our case it would be: explicit header > bearer header > basic auth header

As another side note, if we want to throw an error, this can not be implemented here as it would lazily throw the first time a request is done which is not ideal. We have to throw misconfiguration errors on startup. This is why I also added URL validation in the constructor of the http client, these runtime errors are bad, especially if you use fallback URLs which might not be queried for hours / days and when they do you notice that the URL is invalid.

// Remove the username and password from the URL
url.username = "";
url.password = "";
Comment on lines +293 to +295
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer to completely remove these fields.

Suggested change
// Remove the username and password from the URL
url.username = "";
url.password = "";
// Remove the username and password from the URL
delete url.username;
delete url.password;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript won't let me The operand of a 'delete' operator must be optional.

And I think this good because downstream code might assume this is a defined string as type is URL.username: string, likely wouldn't cause issues but I prefer to not mess with the type if I pass it to third party code.

If you create a URL object without credentials you also get empty strings

new URL("https://google.com")
URL {
  href: 'https://google.com/',
  origin: 'https://google.com',
  protocol: 'https:',
  username: '',
  password: '',
  host: 'google.com',
  hostname: 'google.com',
  port: '',
  pathname: '/',
  search: '',
  searchParams: URLSearchParams {},
  hash: ''
}

}

this.logger?.debug("HttpClient request", {routeId});

Expand All @@ -291,7 +306,7 @@ export class HttpClient implements IHttpClient {

if (!res.ok) {
const errBody = await res.text();
throw new HttpError(`${res.statusText}: ${getErrorMessage(errBody)}`, res.status, url);
throw new HttpError(`${res.statusText}: ${getErrorMessage(errBody)}`, res.status, url.toString());
}

const streamTimer = this.metrics?.streamTime.startTimer({routeId});
Expand Down
16 changes: 16 additions & 0 deletions packages/api/test/unit/client/httpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,22 @@ describe("httpClient json client", () => {
}
});

it("should set user credentials in URL as Authorization header", async () => {
const {baseUrl} = await getServer({
...testRoute,
handler: async (req) => {
expect(req.headers.authorization).to.equal("Basic dXNlcjpwYXNzd29yZA==");
return {};
},
});
const url = new URL(baseUrl);
url.username = "user";
url.password = "password";
const httpClient = new HttpClient({baseUrl: url.toString()});

await httpClient.json(testRoute);
});

it("should handle aborting request with timeout", async () => {
const {baseUrl} = await getServer({
...testRoute,
Expand Down
8 changes: 4 additions & 4 deletions packages/api/test/unit/client/httpClientFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("httpClient fallback", () => {

// Using fetchSub instead of actually setting up servers because there are some strange
// race conditions, where the server stub doesn't count the call in time before the test is over.
const fetchStub = Sinon.stub<[string], ReturnType<typeof fetch>>();
const fetchStub = Sinon.stub<[URL], ReturnType<typeof fetch>>();

let httpClient: HttpClient;

Expand All @@ -21,10 +21,10 @@ describe("httpClient fallback", () => {
const serverErrors = new Map<number, boolean>();

// With baseURLs above find the server index associated with that URL
function getServerIndex(url: string): number {
const i = baseUrls.findIndex((baseUrl) => url.startsWith(baseUrl));
function getServerIndex(url: URL): number {
const i = baseUrls.findIndex((baseUrl) => url.toString().startsWith(baseUrl));
if (i < 0) {
throw Error(`fetch called with unknown url ${url}`);
throw Error(`fetch called with unknown url ${url.toString()}`);
}
return i;
}
Expand Down
16 changes: 16 additions & 0 deletions packages/api/test/unit/client/httpClientOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,20 @@ describe("HTTPClient options", () => {
{baseUrl: baseUrl2, bearerToken: bearerToken2},
]);
});

it("Throw if empty baseUrl", () => {
expect(() => new HttpClient({baseUrl: ""})).to.throw(Error);
});

it("Throw if invalid baseUrl", () => {
expect(() => new HttpClient({baseUrl: "invalid"})).to.throw(Error);
});

it("Throw if empty value in urls option", () => {
expect(() => new HttpClient({urls: [""]})).to.throw(Error);
});

it("Throw if invalid value in urls option", () => {
expect(() => new HttpClient({urls: ["invalid"]})).to.throw(Error);
});
});
7 changes: 4 additions & 3 deletions packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {fetch} from "@lodestar/api";
import {ErrorAborted, TimeoutError, retry} from "@lodestar/utils";
import {ErrorAborted, TimeoutError, isValidHttpUrl, retry} from "@lodestar/utils";
import {IGauge, IHistogram} from "../../metrics/interface.js";
import {IJson, RpcPayload} from "../interface.js";
import {encodeJwtToken} from "./jwt.js";
Expand Down Expand Up @@ -92,6 +92,9 @@ export class JsonRpcHttpClient implements IJsonRpcHttpClient {
if (!url) {
throw Error(`JsonRpcHttpClient.urls[${i}] is empty or undefined: ${url}`);
}
if (!isValidHttpUrl(url)) {
throw Error(`JsonRpcHttpClient.urls[${i}] must be a valid URL: ${url}`);
}
}

this.jwtSecret = opts?.jwtSecret;
Expand Down Expand Up @@ -188,8 +191,6 @@ export class JsonRpcHttpClient implements IJsonRpcHttpClient {
* Fetches JSON and throws detailed errors in case the HTTP request is not ok
*/
private async fetchJsonOneUrl<R, T = unknown>(url: string, json: T, opts?: ReqOpts): Promise<R> {
if (!url) throw Error(`Empty or undefined JSON RPC HTTP client url: ${url}`);

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT);

Expand Down
4 changes: 2 additions & 2 deletions packages/beacon-node/src/monitoring/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export class MonitoringService {

private parseMonitoringEndpoint(endpoint: string): URL {
if (!endpoint) {
throw new Error("Monitoring endpoint must be provided");
throw new Error(`Monitoring endpoint is empty or undefined: ${endpoint}`);
}

try {
Expand All @@ -226,7 +226,7 @@ export class MonitoringService {

return url;
} catch {
throw new Error("Monitoring endpoint must be a valid URL");
throw new Error(`Monitoring endpoint must be a valid URL: ${endpoint}`);
}
}
}
10 changes: 6 additions & 4 deletions packages/beacon-node/test/unit/monitoring/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ describe("monitoring / service", () => {
});

it("should throw an error if monitoring endpoint is not provided", () => {
expect(() => new MonitoringService("beacon", {endpoint: ""}, {register, logger})).to.throw(
"Monitoring endpoint must be provided"
const endpoint = "";
expect(() => new MonitoringService("beacon", {endpoint}, {register, logger})).to.throw(
`Monitoring endpoint is empty or undefined: ${endpoint}`
);
});

it("should throw an error if monitoring endpoint is not a valid URL", () => {
expect(() => new MonitoringService("beacon", {endpoint: "invalid"}, {register, logger})).to.throw(
"Monitoring endpoint must be a valid URL"
const endpoint = "invalid";
expect(() => new MonitoringService("beacon", {endpoint}, {register, logger})).to.throw(
`Monitoring endpoint must be a valid URL: ${endpoint}`
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cmds/validator/keymanager/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
} from "@lodestar/api/keymanager";
import {Interchange, SignerType, Validator} from "@lodestar/validator";
import {ServerApi} from "@lodestar/api";
import {getPubkeyHexFromKeystore, isValidatePubkeyHex, isValidHttpUrl} from "../../../util/format.js";
import {isValidHttpUrl} from "@lodestar/utils";
import {getPubkeyHexFromKeystore, isValidatePubkeyHex} from "../../../util/format.js";
import {parseFeeRecipient} from "../../../util/index.js";
import {DecryptKeystoresThreadPool} from "./decryptKeystores/index.js";
import {IPersistedKeysBackend} from "./interface.js";
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cmds/validator/signers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {deriveEth2ValidatorKeys, deriveKeyFromMnemonic} from "@chainsafe/bls-key
import {toHexString} from "@chainsafe/ssz";
import {interopSecretKey} from "@lodestar/state-transition";
import {externalSignerGetKeys, Signer, SignerType} from "@lodestar/validator";
import {LogLevel, Logger} from "@lodestar/utils";
import {LogLevel, Logger, isValidHttpUrl} from "@lodestar/utils";
import {defaultNetwork, GlobalArgs} from "../../../options/index.js";
import {assertValidPubkeysHex, isValidHttpUrl, parseRange, YargsError} from "../../../util/index.js";
import {assertValidPubkeysHex, parseRange, YargsError} from "../../../util/index.js";
import {getAccountPaths} from "../paths.js";
import {IValidatorCliArgs} from "../options.js";
import {PersistedKeysBackend} from "../keymanager/persistedKeys.js";
Expand Down
11 changes: 0 additions & 11 deletions packages/cli/src/util/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,6 @@ export function assertValidPubkeysHex(pubkeysHex: string[]): void {
}
}

export function isValidHttpUrl(urlStr: string): boolean {
let url;
try {
url = new URL(urlStr);
} catch (_) {
return false;
}

return url.protocol === "http:" || url.protocol === "https:";
}

/**
* Parses a file to get a list of bootnodes for a network.
* Bootnodes file is expected to contain bootnode ENR's concatenated by newlines, or commas for
Expand Down
9 changes: 9 additions & 0 deletions packages/utils/src/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const hasBufferFrom = typeof Buffer !== "undefined" && typeof Buffer.from === "function";

export function toBase64(value: string): string {
return hasBufferFrom ? Buffer.from(value).toString("base64") : btoa(value);
}

export function fromBase64(value: string): string {
return hasBufferFrom ? Buffer.from(value, "base64").toString("utf8") : atob(value);
}
2 changes: 2 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./yaml/index.js";
export * from "./assert.js";
export * from "./base64.js";
export * from "./bytes.js";
export * from "./err.js";
export * from "./errors.js";
Expand All @@ -14,6 +15,7 @@ export * from "./sleep.js";
export * from "./sort.js";
export * from "./timeout.js";
export {RecursivePartial, bnToNum} from "./types.js";
export * from "./validation.js";
export * from "./verifyMerkleBranch.js";
export * from "./promise.js";
export * from "./waitFor.js";
10 changes: 10 additions & 0 deletions packages/utils/src/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function isValidHttpUrl(urlStr: string): boolean {
let url;
try {
url = new URL(urlStr);
} catch (_) {
return false;
}

return url.protocol === "http:" || url.protocol === "https:";
}
15 changes: 15 additions & 0 deletions packages/utils/test/unit/base64.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import "../setup.js";
import {expect} from "chai";
import {toBase64, fromBase64} from "../../src/index.js";

describe("toBase64", () => {
it("should encode UTF-8 string as base64 string", () => {
expect(toBase64("user:password")).to.be.equal("dXNlcjpwYXNzd29yZA==");
});
});

describe("fromBase64", () => {
it("should decode UTF-8 string from base64 string", () => {
expect(fromBase64("dXNlcjpwYXNzd29yZA==")).to.be.equal("user:password");
});
});