From 5fc0f08140d74b2e2fc4804b1325cc14828d0fb0 Mon Sep 17 00:00:00 2001 From: Erdem Bayrakdar Date: Wed, 12 Aug 2026 12:42:07 +0300 Subject: [PATCH] fix(caip): validate full string in caip2Parts and caip10Parts caip2Parts and caip10Parts parsed input with caip.split(":") and only checked that the destructured segments were non-empty. split() does not cap the number of segments, so malformed input with extra trailing :segments (e.g. "eip155:1:extra") silently parsed as if truncated, instead of being rejected. caip10Parts is reached from @agentcommercekit/did's did:pkh createVerificationMethod via the public createDidPkhDocumentFromCaip10AccountId, which does not itself re-validate its input against the CAIP-10 pattern -- so a malformed account ID passed to that entry point could previously produce a DID document built from silently truncated account data instead of throwing. Both functions now test the input against their full regex (caip2ChainIdRegex / caip10AccountIdRegex) before parsing. Adds regression tests (caip10Parts previously had no test coverage at all) and a changeset. Assisted by Claude (Anthropic). --- .changeset/caip-parts-strict-validation.md | 27 ++++++++++++++ packages/caip/src/caips/caip-10.test.ts | 43 +++++++++++++++++++++- packages/caip/src/caips/caip-10.ts | 9 +++++ packages/caip/src/caips/caip-2.test.ts | 9 +++++ packages/caip/src/caips/caip-2.ts | 9 +++++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .changeset/caip-parts-strict-validation.md diff --git a/.changeset/caip-parts-strict-validation.md b/.changeset/caip-parts-strict-validation.md new file mode 100644 index 00000000..28c119a0 --- /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 7e688ea1..7ca6833c 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 12db7ab3..2e87e8b8 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 ea5ca719..8a21dbbe 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 a943ed78..4a7b295b 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) {