Skip to content
Open
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
27 changes: 27 additions & 0 deletions .changeset/caip-parts-strict-validation.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion packages/caip/src/caips/caip-10.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
)
})
})
9 changes: 9 additions & 0 deletions packages/caip/src/caips/caip-10.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions packages/caip/src/caips/caip-2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/caip/src/caips/caip-2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down