diff --git a/.changeset/reject-malformed-rainbow-heals.md b/.changeset/reject-malformed-rainbow-heals.md new file mode 100644 index 0000000000..c37e6bf3c7 --- /dev/null +++ b/.changeset/reject-malformed-rainbow-heals.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensrainbow-sdk": patch +--- + +`@ensnode/ensrainbow-sdk` now rejects malformed rainbow records: a healed label whose `labelHash` does not match the requested `labelHash` is considered `NotFound`. diff --git a/apps/ensindexer/src/lib/graphnode-helpers.test.ts b/apps/ensindexer/src/lib/graphnode-helpers.test.ts index 8d71a23256..9190952e63 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.test.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.test.ts @@ -1,4 +1,4 @@ -import type { LabelHash } from "enssdk"; +import { asLiteralLabel, type LabelHash, type LiteralLabel, labelhashLiteralLabel } from "enssdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setupConfigMock, setupEnsDbConfigMock } from "@/lib/__test__/mockConfig"; @@ -26,6 +26,12 @@ import { logger } from "@/lib/logger"; import { labelByLabelHash } from "./graphnode-helpers"; +// The client singleton caches healed labels across tests, so any test expecting a fetch must use a +// labelHash no earlier test has healed. getTestLabel() yields a fresh, unique label on each call; +// pair it with labelhashLiteralLabel so the healed label hashes back to the requested labelHash. +let testLabelSequence = 0; +const getTestLabel = (): LiteralLabel => asLiteralLabel(`test-label-${testLabelSequence++}`); + describe("labelByLabelHash", () => { beforeEach(() => { vi.clearAllMocks(); @@ -43,9 +49,9 @@ describe("labelByLabelHash", () => { }), }); - expect( - await labelByLabelHash("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc"), - ).toEqual("vitalik"); + await expect( + labelByLabelHash("0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc"), + ).resolves.toEqual("vitalik"); }); it("returns null for a valid unknown labelHash", async () => { @@ -61,32 +67,36 @@ describe("labelByLabelHash", () => { }), }); - expect( - await labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da06"), - ).toBeNull(); + await expect( + labelByLabelHash("0x00ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da06"), + ).resolves.toBeNull(); }); it("normalizes a 63-char hex labelHash by prepending '0' and heals it", async () => { + // "dan" is chosen because its labelhash begins with a zero (0x0d2095…). That lets us + // build a 63-char input by dropping the leading '0', which the client re-pads back to the full + // hash — a hash the healed label "dan" actually hashes to, so the client's heal-integrity check + // accepts it. A label whose hash doesn't start with '0' (e.g. "vitalik" → 0xaf2caa…) couldn't + // exercise the prepend-'0' path without the re-padded hash diverging from the label's hash. + const DAN_LABEL = asLiteralLabel("dan"); + const DAN_LABELHASH = labelhashLiteralLabel(DAN_LABEL); // 0x0d2095… + // drop the leading '0' to produce the 63-hex-char input the client must re-pad + const labelHash63 = `0x${DAN_LABELHASH.slice(3)}` as LabelHash; + (fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve({ status: "success", - label: "vitalik", + label: DAN_LABEL, }), }); - expect( - await labelByLabelHash( - "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103c" as LabelHash, // 63 hex chars - ), - ).toEqual("vitalik"); + await expect(labelByLabelHash(labelHash63)).resolves.toEqual(DAN_LABEL); const [[calledUrl]] = (fetch as any).mock.calls; // Verify the client prepended a '0' — the normalized 64-char hash is used in the request - expect(calledUrl.toString()).toContain( - "0x0af2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103c", - ); + expect(calledUrl.toString()).toContain(DAN_LABELHASH); }); it("propagates a server 400 error as a thrown exception", async () => { @@ -126,11 +136,11 @@ describe("labelByLabelHash", () => { }); // Use a hash distinct from other tests to avoid LRU cache hits suppressing the fetch call - expect( - await labelByLabelHash( + await expect( + labelByLabelHash( "0x5D5727cb0fb76e4944eafb88ec9a3cf0b3c9025a4b2f947729137c5d7f84f68f" as LabelHash, ), - ).toEqual("nick"); + ).resolves.toEqual("nick"); const [[calledUrl]] = (fetch as any).mock.calls; expect(calledUrl.toString()).toContain( @@ -153,31 +163,26 @@ describe("labelByLabelHash", () => { vi.restoreAllMocks(); }); - // Use unique labelHashes in each test to prevent LRU cache hits from other tests - // carrying over cacheable responses (HealSuccess, HealNotFoundError) and bypassing fetch. - it("retries on network/fetch failure and succeeds on a later attempt", async () => { const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const label = getTestLabel(); (fetch as any) .mockRejectedValueOnce(new Error("network error")) .mockRejectedValueOnce(new Error("network error")) .mockResolvedValue({ ok: true, - json: () => Promise.resolve({ status: "success", label: "nick" }), + json: () => Promise.resolve({ status: "success", label }), }); - const result = await labelByLabelHash( - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as LabelHash, - ); - - expect(result).toEqual("nick"); + await expect(labelByLabelHash(labelhashLiteralLabel(label))).resolves.toEqual(label); expect(fetch).toHaveBeenCalledTimes(3); expect(warnSpy).toHaveBeenCalledTimes(2); }); it("retries on HealServerError and succeeds on a later attempt", async () => { const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const label = getTestLabel(); (fetch as any) .mockResolvedValueOnce({ @@ -187,14 +192,10 @@ describe("labelByLabelHash", () => { }) .mockResolvedValue({ ok: true, - json: () => Promise.resolve({ status: "success", label: "vitalik" }), + json: () => Promise.resolve({ status: "success", label }), }); - const result = await labelByLabelHash( - "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as LabelHash, - ); - - expect(result).toEqual("vitalik"); + await expect(labelByLabelHash(labelhashLiteralLabel(label))).resolves.toEqual(label); expect(fetch).toHaveBeenCalledTimes(2); expect(warnSpy).toHaveBeenCalledTimes(1); }); @@ -205,11 +206,11 @@ describe("labelByLabelHash", () => { json: () => Promise.resolve({ status: "error", error: "Label not found", errorCode: 404 }), }); - const result = await labelByLabelHash( - "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" as LabelHash, - ); - - expect(result).toBeNull(); + await expect( + labelByLabelHash( + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" as LabelHash, + ), + ).resolves.toBeNull(); expect(fetch).toHaveBeenCalledTimes(1); }); diff --git a/packages/ensrainbow-sdk/src/client.test.ts b/packages/ensrainbow-sdk/src/client.test.ts index 6597317560..db25f98cf9 100644 --- a/packages/ensrainbow-sdk/src/client.test.ts +++ b/packages/ensrainbow-sdk/src/client.test.ts @@ -1,3 +1,4 @@ +import { asLiteralLabel, labelhashLiteralLabel } from "enssdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -125,6 +126,29 @@ describe("EnsRainbowApiClient", () => { } satisfies EnsRainbow.HealSuccess); }); + it("should return a not found error for a malformed record whose label does not hash back to the labelHash", async () => { + // Malformed rainbow record: the on-chain label is `"007"` (quotes included), but a CSV-mangled + // label set heals it to `007` (quotes stripped), which hashes to a different labelHash. The + // client must reject it as unhealable rather than returning a label keyed under the wrong hash. + const labelHash = labelhashLiteralLabel(asLiteralLabel('"007"')); + + mockFetch.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + status: StatusCode.Success, + label: "007", // quote-stripped; does not hash back to labelHash + } satisfies EnsRainbow.HealSuccess), + }); + + const response = await client.heal(labelHash); + + expect(response).toEqual({ + status: StatusCode.Error, + error: "Label not found", + errorCode: ErrorCode.NotFound, + } satisfies EnsRainbow.HealNotFoundError); + }); + it("should return a not found error for an unknown labelHash", async () => { mockFetch.mockResolvedValueOnce({ json: () => diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 97b45f7d50..861b9dbeed 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -1,5 +1,5 @@ import type { EncodedLabelHash, Label, LabelHash } from "enssdk"; -import { parseLabelHashOrEncodedLabelHash } from "enssdk"; +import { asLiteralLabel, labelhashLiteralLabel, parseLabelHashOrEncodedLabelHash } from "enssdk"; import { buildEnsRainbowClientLabelSet, @@ -379,7 +379,19 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { }); const response = await fetch(url); - const healResponse = (await response.json()) as EnsRainbow.HealResponse; + let healResponse = (await response.json()) as EnsRainbow.HealResponse; + + // Sanity Check: avoid returning malformed heals to consumers, treating as not-found + if ( + healResponse.status === StatusCode.Success && + labelhashLiteralLabel(asLiteralLabel(healResponse.label)) !== normalizedLabelHash + ) { + healResponse = { + status: StatusCode.Error, + error: "Label not found", + errorCode: ErrorCode.NotFound, + } satisfies EnsRainbow.HealNotFoundError; + } if (isCacheableHealResponse(healResponse)) { this.cache.set(normalizedLabelHash, healResponse);