diff --git a/.changeset/caip-parts-strict-validation.md b/.changeset/caip-parts-strict-validation.md new file mode 100644 index 0000000..28c119a --- /dev/null +++ b/.changeset/caip-parts-strict-validation.md @@ -0,0 +1,27 @@ +--- +"@agentcommercekit/caip": patch +--- + +Fix: `caip2Parts` and `caip10Parts` now validate the full input string +instead of silently truncating it. + +Both functions parsed their input with `caip.split(":")` and destructured +the first two (or three) resulting segments, only checking that those +segments were non-empty. `split` does not enforce an upper bound on the +number of segments, so a malformed string with extra trailing `:segments` +(e.g. `"eip155:1:extra"` for `caip2Parts`, or +`"eip155:1:0xabc...:extra"` for `caip10Parts`) would parse "successfully", +silently discarding everything after the expected number of colons instead +of being rejected. + +`caip10Parts` is used by `@agentcommercekit/did`'s `did:pkh` method +(`createVerificationMethod`, reached via +`createDidPkhDocumentFromCaip10AccountId`) to build a DID document's +verification method from a caller-supplied CAIP-10 account ID. That call +path does not otherwise re-validate the account ID against the CAIP-10 +pattern, so a malformed account ID could previously produce a DID document +with silently truncated/incorrect account data instead of throwing. + +Both functions now test the input against their full `RegExp` pattern +(`caip2ChainIdRegex` / `caip10AccountIdRegex`) before parsing, so malformed +input is rejected up front. diff --git a/packages/caip/src/caips/caip-10.test.ts b/packages/caip/src/caips/caip-10.test.ts index 7e688ea..7ca6833 100644 --- a/packages/caip/src/caips/caip-10.test.ts +++ b/packages/caip/src/caips/caip-10.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { createCaip10AccountId } from "./index" +import { caip10Parts, createCaip10AccountId } from "./index" describe("createCaip10AccountId", () => { it("creates a caip 10 account ID for EVM address", () => { @@ -37,3 +37,44 @@ describe("createCaip10AccountId", () => { ) }) }) + +describe("caip10Parts", () => { + it("parses a valid EVM account ID", () => { + const result = caip10Parts( + "eip155:1:0x1234567890123456789012345678901234567890", + ) + expect(result).toEqual({ + namespace: "eip155", + reference: "1", + accountId: "0x1234567890123456789012345678901234567890", + }) + }) + + it("parses a valid Solana account ID", () => { + const result = caip10Parts( + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:FNoGHiv7DKPLXHfuhiEWpJ8qYitawGkuaYwfYkuvFk1P", + ) + expect(result).toEqual({ + namespace: "solana", + reference: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + accountId: "FNoGHiv7DKPLXHfuhiEWpJ8qYitawGkuaYwfYkuvFk1P", + }) + }) + + it("throws for an account ID with a trailing extra colon segment", () => { + // Regression test: a naive `split(":")` would silently drop everything + // after the third colon and happily return a truncated + // { namespace, reference, accountId } for this malformed input instead + // of rejecting it. + expect(() => + caip10Parts("eip155:1:0x1234567890123456789012345678901234567890:extra"), + ).toThrow("Invalid CAIP-10 account ID") + }) + + it("throws for a string missing the account address", () => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- intentionally passing invalid input to exercise runtime validation + expect(() => caip10Parts("eip155:1" as never)).toThrow( + "Invalid CAIP-10 account ID", + ) + }) +}) diff --git a/packages/caip/src/caips/caip-10.ts b/packages/caip/src/caips/caip-10.ts index 12db7ab..2e87e8b 100644 --- a/packages/caip/src/caips/caip-10.ts +++ b/packages/caip/src/caips/caip-10.ts @@ -48,6 +48,15 @@ export function createCaip10AccountId( } export function caip10Parts(caip: Caip10AccountId): Caip10AccountIdParts { + // Validate the full string against the CAIP-10 pattern rather than just + // splitting on ":". A naive split silently drops any segments after the + // third colon (e.g. "eip155:1:0xabc:extra" would parse as if it were + // just "eip155:1:0xabc"), so malformed input could pass through + // undetected instead of being rejected. + if (!caip10AccountIdRegex.test(caip)) { + throw new Error("Invalid CAIP-10 account ID") + } + const [namespace, reference, accountId] = caip.split(":") if (!namespace || !reference || !accountId) { throw new Error("Invalid CAIP-10 account ID") diff --git a/packages/caip/src/caips/caip-2.test.ts b/packages/caip/src/caips/caip-2.test.ts index ea5ca71..8a21dbb 100644 --- a/packages/caip/src/caips/caip-2.test.ts +++ b/packages/caip/src/caips/caip-2.test.ts @@ -41,6 +41,15 @@ describe("caip2Parts", () => { "Invalid CAIP-2 chain ID", ) }) + + it("throws for a chain ID with a trailing extra colon segment", () => { + // Regression test: a naive `split(":")` would silently drop everything + // after the second colon and happily return { namespace: "eip155", + // reference: "1" } for this malformed input instead of rejecting it. + expect(() => caip2Parts("eip155:1:extra" as `${string}:${string}`)).toThrow( + "Invalid CAIP-2 chain ID", + ) + }) }) describe("caip2ChainIdRegex", () => { diff --git a/packages/caip/src/caips/caip-2.ts b/packages/caip/src/caips/caip-2.ts index a943ed7..4a7b295 100644 --- a/packages/caip/src/caips/caip-2.ts +++ b/packages/caip/src/caips/caip-2.ts @@ -38,6 +38,15 @@ export const caip2ChainIds = { } as const export function caip2Parts(caip: Caip2ChainId): Caip2ChainIdParts { + // Validate the full string against the CAIP-2 pattern rather than just + // splitting on ":". A naive split silently drops any segments after the + // second colon (e.g. "eip155:1:extra" would parse as if it were just + // "eip155:1"), so malformed input could pass through undetected instead + // of being rejected. + if (!caip2ChainIdRegex.test(caip)) { + throw new Error("Invalid CAIP-2 chain ID") + } + const [namespace, reference] = caip.split(":") if (!namespace || !reference) {