diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index ff4148fa7af8..7aa588da778f 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -7,7 +7,7 @@ import { MAX_BUILDER_URL_SIZE, } from "@lodestar/params"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; -import {isValidAsciiHttpUrl} from "@lodestar/utils"; +import {isValidAsciiHttpUrl, toAsciiHttpUrl} from "@lodestar/utils"; import { EmptyArgs, EmptyMeta, @@ -194,7 +194,11 @@ export function builderConfigDataFromJson(json: unknown): BuilderConfigData { throw Error(`builders[${i}].url must not exceed ${MAX_BUILDER_URL_SIZE} bytes`); } if (!isValidAsciiHttpUrl(url)) { - throw Error(`builders[${i}].url must be a valid HTTP or HTTPS URL using only ASCII characters`); + const asciiUrl = toAsciiHttpUrl(url); + throw Error( + `builders[${i}].url must be a valid HTTP or HTTPS URL using only ASCII characters` + + (asciiUrl !== null ? `, use ${asciiUrl} instead` : "") + ); } if (auth_data !== undefined && (typeof auth_data !== "string" || !BUILDER_AUTH_DATA_PATTERN.test(auth_data))) { throw Error( diff --git a/packages/api/test/unit/keymanager/builderConfig.test.ts b/packages/api/test/unit/keymanager/builderConfig.test.ts index 32a1bec58131..3f8c8a20b2ab 100644 --- a/packages/api/test/unit/keymanager/builderConfig.test.ts +++ b/packages/api/test/unit/keymanager/builderConfig.test.ts @@ -19,4 +19,10 @@ describe("builderConfigDataFromJson", () => { ); } }); + + it("suggests the ASCII form of a builder url with an internationalized hostname", () => { + expect(() => builderConfigDataFromJson({builders: [{url: "https://platåberget.dev"}]})).toThrow( + "use https://xn--platberget-45a.dev/ instead" + ); + }); }); diff --git a/packages/cli/src/util/proposerConfig.ts b/packages/cli/src/util/proposerConfig.ts index c38d9f8de1dd..27e348b9d110 100644 --- a/packages/cli/src/util/proposerConfig.ts +++ b/packages/cli/src/util/proposerConfig.ts @@ -3,7 +3,7 @@ import path from "node:path"; import {routes} from "@lodestar/api"; import {BuilderEntryConfig, builderConfigDataFromJson} from "@lodestar/api/keymanager"; import {MAX_BUILDER_AUTH_DATA_SIZE, MAX_BUILDER_ENTRIES, MAX_BUILDER_URL_SIZE} from "@lodestar/params"; -import {fromHex, isValidAsciiHttpUrl, toHex} from "@lodestar/utils"; +import {fromHex, isValidAsciiHttpUrl, toAsciiHttpUrl, toHex} from "@lodestar/utils"; import {ValidatorProposerConfig} from "@lodestar/validator"; import {parseFeeRecipient} from "./feeRecipient.js"; import {readFile} from "./file.js"; @@ -252,7 +252,8 @@ export function parseBuilderUrls(urls?: string[]): BuilderEntryConfig[] | undefi const url = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); const authData = fragmentIndex === -1 ? undefined : value.slice(fragmentIndex + 1); if (!isValidAsciiHttpUrl(url)) { - throw Error(`Invalid builder url: ${url}`); + const asciiUrl = toAsciiHttpUrl(url); + throw Error(`Invalid builder url: ${url}` + (asciiUrl !== null ? `, use ${asciiUrl} instead` : "")); } if (Buffer.byteLength(url, "utf8") > MAX_BUILDER_URL_SIZE) { throw Error(`Invalid builder url, must not exceed ${MAX_BUILDER_URL_SIZE} bytes: ${url}`); diff --git a/packages/cli/test/unit/validator/parseBuilderUrls.test.ts b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts index ac7305160f52..c77d33f8cdcf 100644 --- a/packages/cli/test/unit/validator/parseBuilderUrls.test.ts +++ b/packages/cli/test/unit/validator/parseBuilderUrls.test.ts @@ -25,6 +25,7 @@ describe("validator / parseBuilderUrls", () => { it("rejects invalid urls and auth data", () => { expect(() => parseBuilderUrls(["builder.example.com"])).toThrow(/Invalid builder url/); expect(() => parseBuilderUrls(["https://builder.example.com/é"])).toThrow(/Invalid builder url/); + expect(() => parseBuilderUrls(["https://platåberget.dev"])).toThrow("use https://xn--platberget-45a.dev/ instead"); expect(() => parseBuilderUrls(["https://builder.example.com#"])).toThrow(/auth data/); expect(() => parseBuilderUrls(["https://builder.example.com#0x"])).toThrow(/auth data/); expect(() => parseBuilderUrls(["https://builder.example.com#secret"])).toThrow(/auth data/); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts index e53f44019d83..6acc898abe35 100644 --- a/packages/utils/src/url.ts +++ b/packages/utils/src/url.ts @@ -31,6 +31,16 @@ export function isValidAsciiHttpUrl(urlStr: string): boolean { return isValidHttpUrl(urlStr); } +/** Return the ASCII form of an HTTP(S) URL with its hostname in punycode, or null if it has none. */ +export function toAsciiHttpUrl(urlStr: string): string | null { + if (!isValidHttpUrl(urlStr)) { + return null; + } + + const asciiUrl = new URL(urlStr).href; + return isValidAsciiHttpUrl(asciiUrl) ? asciiUrl : null; +} + /** * Sanitize URL to prevent leaking user credentials in logs or metrics * diff --git a/packages/utils/test/unit/url.test.ts b/packages/utils/test/unit/url.test.ts new file mode 100644 index 000000000000..eacb0653dbf3 --- /dev/null +++ b/packages/utils/test/unit/url.test.ts @@ -0,0 +1,18 @@ +import {describe, expect, it} from "vitest"; +import {toAsciiHttpUrl} from "../../src/url.js"; + +describe("toAsciiHttpUrl", () => { + const testCases: {url: string; expected: string | null}[] = [ + {url: "https://platåberget.dev", expected: "https://xn--platberget-45a.dev/"}, + {url: "https://builder.example.com/é?x=1", expected: "https://builder.example.com/%C3%A9?x=1"}, + {url: "https://builder.example.com:8080/path", expected: "https://builder.example.com:8080/path"}, + {url: "ftp://builder.example.com", expected: null}, + {url: "builder.example.com", expected: null}, + ]; + + for (const {url, expected} of testCases) { + it(url, () => { + expect(toAsciiHttpUrl(url)).toBe(expected); + }); + } +});