Skip to content

Commit 95403c4

Browse files
committed
Release v0.28.2
Origin-SHA: 2d6d9bbf55d90c197f0b5a12033298ce555a5fdd
1 parent 575a995 commit 95403c4

21 files changed

Lines changed: 1027 additions & 39 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# @opensea/tool-sdk
22

3+
## 0.28.2
4+
5+
### Patch Changes
6+
7+
- 1c2df8f: Fix four lower-severity tool-sdk audit findings:
8+
9+
- Pin EIP-712 domain name/version to canonical USDC for canonical assets.
10+
- Document and surface the default 10,000-block registry lookup window.
11+
- Cache resolved manifests in `createToolHandler` after the first success.
12+
- Decouple shared caller API-key provisioning from each request's abort signal.
13+
14+
- 8e1b0dc: Validate `metadataURI` before fetching in `inspect` (SSRF): require http(s) and reject private/internal hosts. Harden the shared `isPrivateHostname` guard to numerically parse IP literals, closing alternate-encoding bypasses (decimal/octal/hex/partial dotted IPv4 and IPv4-mapped/compatible IPv6), and broaden the rejected ranges (0.0.0.0/8, 169.254.0.0/16 link-local, CGNAT 100.64.0.0/10, IPv6 unique-local fc00::/7). Thanks to @Nexory for reporting this SSRF (tool-sdk#12).
15+
- 83b406a: Security fix: `paidAuthenticatedFetch` now enforces `maxAmount` as a per-invocation spending cap. At most one nonzero payment authorization may be signed per call — a second 402 after a paid authorization is rejected instead of signed — and cumulative authorized value may never exceed `maxAmount`. Previously a malicious tool could return two paid x402 challenges, each individually under `maxAmount`, and obtain two independently-settleable EIP-3009 authorizations (up to 2x the configured cap). The legacy zero-value predicate-gate → paid challenge flow keeps working.
16+
- c7907d4: Security fix: harden `verifyXPaymentAuth` (used by `predicateGate` and `paidPredicateGate`) against replay/misuse of the EIP-3009 X-Payment identity proof:
17+
18+
- The free identity-only `predicateGate` now rejects (401) any X-Payment authorization with a non-zero `value` — a non-zero `TransferWithAuthorization` is a live, executable USDC pull the operator should never receive on a free gate. `paidPredicateGate` still accepts non-zero values (the facilitator settles them).
19+
- `validBefore` is now capped to at most 1 hour (3600s) in the future (401 otherwise), so a captured X-Payment header can't act as a long-lived bearer token. Well-behaved clients sign now+300s/now+600s, so this is non-breaking.
20+
- The authorization's resolved chainId is now pinned to the gate's configured chain (401 on mismatch), so e.g. a base-sepolia-signed authorization can't authenticate to a mainnet gate.
21+
- The `to` (operator recipient) check is now unconditional and fails closed (500) when no operator address is configured, closing an unauthenticated-recipient hole.
22+
323
## 0.28.1
424

525
### Patch Changes

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,12 @@ they need to satisfy.
601601

602602
Preferred auth mechanism: `X-Payment` header (zero-value EIP-3009 `TransferWithAuthorization` via the 402 challenge flow). Also accepts `Authorization: EIP-3009 <base64url(json)>` and deprecated `SIWE <base64url(message)>.<signature>` for backward compatibility.
603603

604-
> **Note:** The gate enforces a short-lived `validBefore` window (the SDK
605-
> defaults to 5 minutes). Each authorization includes a random nonce bound
606-
> into the signature. The gate does not track nonces server-side.
604+
> **Note:** The gate bounds the `validBefore` window server-side: the
605+
> authorization must not be expired and must be at most 1 hour in the future
606+
> (SDK clients sign a much shorter window — 5 min zero-value / 10 min paid).
607+
> Each authorization includes a random nonce bound into the signature, but the
608+
> gate does **not** deduplicate nonces server-side, so an `X-Payment` header is
609+
> replayable within its validity window. See [Replay protection](#replay-protection) if you need single-use semantics.
607610
608611
#### Delegated agent access (delegate.xyz)
609612

@@ -1027,11 +1030,17 @@ Recommended approaches by platform:
10271030

10281031
You can also implement a custom `GateMiddleware` that performs rate-limit checks in the `check()` hook and returns a 429 response when limits are exceeded.
10291032

1033+
### Replay protection
1034+
1035+
The `X-Payment` identity proof is a stateless bearer token: `predicateGate` verifies the EIP-3009 signature and fields but does **not** record which authorizations it has already accepted. The SDK bounds the replay window server-side — an authorization must not be expired and must be at most 1 hour in the future — but a captured header remains replayable until its `validBefore`. On the paid path this is limited further because the facilitator settling the onchain `TransferWithAuthorization` consumes the nonce, so a real payment can't be double-settled; the residual surface is identity/access on the free gate (the onchain predicate still gates who is allowed).
1036+
1037+
The SDK deliberately does not ship server-side nonce tracking — single-use semantics require a shared, TTL'd store, and mandating one (e.g. Redis) would tie tool creators to a specific data store and break serverless/multi-instance deployments. If your tool needs stronger-than-window replay protection, implement it yourself in a custom `GateMiddleware`: on each request, read the authorization's `nonce` (bytes32), reject (401) if it is already present in your store, otherwise record it with a TTL equal to your `validBefore` window. Use whatever store fits your deployment (Cloudflare Durable Objects / KV, Upstash Redis on Vercel, DynamoDB, etc.).
1038+
10301039
### Sensitive Data Handling
10311040

10321041
- **Private keys** are never handled directly by the SDK runtime. They flow through `@opensea/wallet-adapters`, which supports Privy, Turnkey, Fireblocks, and local key signing. The SDK accepts a `WalletAdapter` interface — it never reads or stores raw key material.
10331042
- **Environment variables** — the `deploy` command detects sensitive env vars (names ending in `_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, `_PRIVATE`) and masks their values during interactive prompts.
1034-
- **EIP-3009 auth** — the `predicateGate` middleware verifies EIP-3009 zero-value authorizations via `ecrecover` (no RPC call needed). The SDK defaults to a 5-minute `validBefore` window to limit replay.
1043+
- **EIP-3009 auth** — the `predicateGate` middleware verifies EIP-3009 zero-value authorizations via `ecrecover` (no RPC call needed). It rejects a non-zero `value` on the free identity gate, pins the authorization to the gate's configured chain, and caps `validBefore` to at most 1 hour ahead to limit replay. See [Replay protection](#replay-protection) for nonce-dedupe guidance.
10351044
- **x402 payments** — the `paidFetch` client validates payment parameters before signing: `maxAmount` caps the spend, `allowedRecipients` restricts payees, and `allowedAssets` defaults to the canonical USDC contract for the network. These guard against malicious 402 responses.
10361045

10371046
## Tips

docs/predicate-gating-guide.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ The middleware (`src/lib/middleware/predicate-gate.ts`) does the following on ea
8686
1. Checks for an `X-Payment` header (preferred) or `Authorization: EIP-3009 <token>` header (also accepts deprecated `Authorization: SIWE <token>` for backward compatibility)
8787
2. If no auth is present and `operatorAddress` is configured, returns 402 with `PaymentRequirements` (`payTo`=operator, `maxAmountRequired`=`"0"`, `scheme`=`"exact"`)
8888
3. Decodes and validates the authorization (from `X-Payment` or `Authorization` header)
89-
4. Checks `validBefore` (must be in the future) and `validAfter` (must be in the past)
89+
4. Checks `validBefore` (must be in the future, and at most 1 hour ahead) and `validAfter` (must be in the past)
9090
5. Recovers the signer via `ecrecover` on the EIP-712 typed data — no RPC call needed
9191
6. Calls `registry.tryHasAccess(toolId, recoveredAddress, data)` — a staticcall to the onchain `ToolRegistry`
9292
7. If `(ok=true, granted=true)`, sets `ctx.callerAddress` and `ctx.gates.predicate.granted = true`
@@ -104,7 +104,7 @@ Status code mapping:
104104

105105
The `predicate` field in the 403 body is the registered access predicate's address, so callers can self-diagnose what they need to satisfy.
106106

107-
The gate enforces a short-lived `validBefore` window (the SDK defaults to 5 minutes). Each EIP-3009 authorization includes a random `nonce` bound into the signature the gate does not track nonces server-side, so callers should keep `validBefore` short to limit the replay window.
107+
The gate bounds the `validBefore` window server-side: the authorization must not be expired and must be at most 1 hour in the future (SDK clients sign a much shorter window — 5 min zero-value / 10 min paid). Each EIP-3009 authorization includes a random `nonce` bound into the signature, but the gate does **not** deduplicate nonces server-side, so the `X-Payment` header stays replayable within its validity window. The SDK does not mandate a nonce store (that would tie creators to a specific data store and break serverless deployments); if you need single-use semantics, dedupe the `nonce` in a custom `GateMiddleware` backed by whatever store fits your deployment. See the "Replay protection" section of the [tool-sdk README](../README.md#replay-protection).
108108

109109
## Step 2: Register with `--access-predicate`
110110

@@ -176,11 +176,12 @@ The `X-Payment` header carries a base64-encoded JSON payload containing an EIP-3
176176

177177
Key constraints enforced by the middleware:
178178

179-
- **`validBefore`** must be in the future (the SDK defaults to `now + 5 minutes`)
179+
- **`validBefore`** must be in the future and at most 1 hour ahead (SDK clients default to `now + 5 minutes`)
180180
- **`validAfter`** must be in the past (typically `0`)
181-
- **`value`** must be `"0"` (zero-value transfer — used for identity proof, not payment)
181+
- **`value`** must be exactly the string `"0"` (zero-value transfer — used for identity proof, not payment; the free `predicateGate` rejects any non-`"0"` value, so third-party clients must emit the canonical `"0"`, not e.g. `"0x0"` or `"00"`)
182182
- **`from`** is recovered via `ecrecover` and used as the caller address
183183
- **`to`** must match the gate's `operatorAddress` (the 402 challenge advertises this as `payTo`)
184+
- the authorization's resolved **chainId** (from its declared `network`) must match the gate's configured chain
184185

185186
### Example client code (SDK)
186187

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/tool-sdk",
3-
"version": "0.28.1",
3+
"version": "0.28.2",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

src/__tests__/caller-reporter.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,86 @@ describe("reportCallerX402Usage", () => {
308308
expect(reportOpts.headers["x-api-key"]).toBe("auto-key")
309309
})
310310

311+
it("does not abort shared API-key provisioning when one caller times out", async () => {
312+
vi.useFakeTimers()
313+
try {
314+
const abortError = () => {
315+
const err = new Error("aborted")
316+
err.name = "AbortError"
317+
return err
318+
}
319+
320+
let resolveProvision = (_response: Response) => {}
321+
fetchSpy.mockImplementation((input, init) => {
322+
const url = String(input)
323+
const signal = init?.signal as AbortSignal | undefined
324+
325+
if (url.endsWith("/api/v2/auth/keys")) {
326+
return new Promise<Response>((resolve, reject) => {
327+
if (signal?.aborted) {
328+
reject(abortError())
329+
return
330+
}
331+
const onAbort = () => reject(abortError())
332+
signal?.addEventListener("abort", onAbort, { once: true })
333+
resolveProvision = response => {
334+
signal?.removeEventListener("abort", onAbort)
335+
resolve(response)
336+
}
337+
})
338+
}
339+
340+
if (signal?.aborted) {
341+
return Promise.reject(abortError())
342+
}
343+
344+
return new Promise<Response>((resolve, reject) => {
345+
queueMicrotask(() => {
346+
if (signal?.aborted) {
347+
reject(abortError())
348+
return
349+
}
350+
resolve(
351+
new Response(JSON.stringify({ verified: true }), {
352+
status: 200,
353+
}),
354+
)
355+
})
356+
})
357+
})
358+
359+
const origin = "https://shared-provision.example"
360+
const first = reportCallerX402Usage(
361+
makeX402Event({ toolEndpoint: "https://tool.example/api" }),
362+
makeConfig({
363+
aggregatorUrl: `${origin}/api/v2/tools/usage`,
364+
timeoutMs: 1,
365+
}),
366+
)
367+
const second = reportCallerX402Usage(
368+
makeX402Event({ toolEndpoint: "https://tool.example/api" }),
369+
makeConfig({
370+
aggregatorUrl: `${origin}/api/v2/tools/usage`,
371+
timeoutMs: 1_000,
372+
}),
373+
)
374+
375+
await vi.advanceTimersByTimeAsync(1)
376+
resolveProvision(
377+
new Response(JSON.stringify({ api_key: "shared-key" }), {
378+
status: 200,
379+
}),
380+
)
381+
382+
const [, secondResult] = await Promise.all([first, second])
383+
384+
expect(secondResult.outcome).toBe("reported")
385+
expect(fetchSpy).toHaveBeenCalledTimes(2)
386+
} finally {
387+
vi.useRealTimers()
388+
}
389+
})
390+
311391
it("skips report when auto-provisioning fails", async () => {
312392
fetchSpy.mockResolvedValueOnce(new Response("error", { status: 500 }))
313393
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

src/__tests__/handler.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ function makeHandler(gates?: GateMiddleware[]) {
3939
})
4040
}
4141

42+
function makeDynamicManifest(options: {
43+
callCount: { value: number }
44+
failFirst?: boolean
45+
}): ManifestDefinition {
46+
return {
47+
type: "https://ercs.ethereum.org/ERCS/erc-8257#tool-manifest-v1",
48+
name: "dynamic-tool",
49+
description: "A tool with dynamic manifest fields",
50+
endpoint: env => {
51+
options.callCount.value++
52+
if (options.failFirst && options.callCount.value === 1) {
53+
throw new Error("manifest resolution failed")
54+
}
55+
return env.TOOL_ENDPOINT ?? "https://dynamic.example.com"
56+
},
57+
inputs: {},
58+
outputs: {},
59+
creatorAddress: "0xabcdefabcdef1234567890abcdefabcdef123456",
60+
}
61+
}
62+
4263
describe("createToolHandler", () => {
4364
it("should return 405 for non-POST", async () => {
4465
const handler = makeHandler()
@@ -92,6 +113,60 @@ describe("createToolHandler", () => {
92113
expect(body.result).toBe("Echo: hello")
93114
})
94115

116+
it("should cache a resolved manifest after the first successful request", async () => {
117+
const callCount = { value: 0 }
118+
const handler = createToolHandler({
119+
manifest: makeDynamicManifest({ callCount }),
120+
inputSchema: InputSchema,
121+
outputSchema: OutputSchema,
122+
handler: async input => ({
123+
result: `Echo: ${input.query}`,
124+
}),
125+
})
126+
const makeRequest = () =>
127+
new Request("https://test.example.com/api", {
128+
method: "POST",
129+
headers: {
130+
"Content-Type": "application/json",
131+
},
132+
body: JSON.stringify({ query: "hello" }),
133+
})
134+
135+
const first = await handler(makeRequest())
136+
const second = await handler(makeRequest())
137+
138+
expect(first.status).toBe(200)
139+
expect(second.status).toBe(200)
140+
expect(callCount.value).toBe(1)
141+
})
142+
143+
it("should retry manifest resolution after a failure", async () => {
144+
const callCount = { value: 0 }
145+
const handler = createToolHandler({
146+
manifest: makeDynamicManifest({ callCount, failFirst: true }),
147+
inputSchema: InputSchema,
148+
outputSchema: OutputSchema,
149+
handler: async input => ({
150+
result: `Echo: ${input.query}`,
151+
}),
152+
})
153+
const makeRequest = () =>
154+
new Request("https://test.example.com/api", {
155+
method: "POST",
156+
headers: {
157+
"Content-Type": "application/json",
158+
},
159+
body: JSON.stringify({ query: "hello" }),
160+
})
161+
162+
const first = await handler(makeRequest())
163+
const second = await handler(makeRequest())
164+
165+
expect(first.status).toBe(500)
166+
expect(second.status).toBe(200)
167+
expect(callCount.value).toBe(2)
168+
})
169+
95170
it("should run gates in order and short-circuit on response", async () => {
96171
const order: string[] = []
97172
const gate1: GateMiddleware = {

src/__tests__/inspect.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,48 @@ describe("inspect command", () => {
148148
errorSpy.mockRestore()
149149
})
150150

151+
it("refuses to fetch a metadata URI that points to a private address", async () => {
152+
// metadataURI is read from the permissionlessly-writable onchain registry.
153+
// A hostile entry pointing at the cloud-metadata endpoint must never be
154+
// fetched from the machine/CI runner that runs `inspect`.
155+
mockGetToolConfig.mockResolvedValueOnce({
156+
creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
157+
metadataURI:
158+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
159+
manifestHash: MANIFEST_HASH,
160+
accessPredicate: "0x0000000000000000000000000000000000000000",
161+
})
162+
163+
const fetchMock = vi.fn(
164+
async () => new Response(JSON.stringify(VALID_MANIFEST), { status: 200 }),
165+
)
166+
vi.stubGlobal("fetch", fetchMock)
167+
168+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
169+
throw new Error("process.exit called")
170+
}) as never)
171+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
172+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
173+
174+
const { inspectCommand } = await import("../cli/commands/inspect.js")
175+
176+
try {
177+
await inspectCommand.parseAsync(["node", "inspect", "--tool-id", "1"])
178+
} catch {
179+
// expected process.exit
180+
}
181+
182+
// The link-local metadata address must never be fetched.
183+
expect(fetchMock).not.toHaveBeenCalled()
184+
expect(exitSpy).toHaveBeenCalledWith(1)
185+
const errorOutput = errorSpy.mock.calls.map(c => c[0]).join("\n")
186+
expect(errorOutput).toContain("private/internal address")
187+
188+
exitSpy.mockRestore()
189+
logSpy.mockRestore()
190+
errorSpy.mockRestore()
191+
})
192+
151193
it("shows predicate name and ERC-721 collections for non-zero predicate", async () => {
152194
const predicateAddress = "0x1111111111111111111111111111111111111111"
153195
const collectionA = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

src/__tests__/list-tools-by-creator.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe("listToolsByCreator", () => {
8585
it("defaults fromBlock to latestBlock - 10_000", async () => {
8686
mockGetBlockNumber.mockResolvedValue(50_000n)
8787
mockGetLogs.mockResolvedValue([])
88+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
8889

8990
const { ToolRegistryClient } = await import("../lib/onchain/registry.js")
9091
const client = new ToolRegistryClient({ rpcUrl: "http://localhost:8545" })
@@ -97,6 +98,10 @@ describe("listToolsByCreator", () => {
9798
args: { creator },
9899
}),
99100
)
101+
expect(warnSpy).toHaveBeenCalledWith(
102+
"[tool-sdk] showing registrations from the last 10,000 blocks; pass fromBlock to search further back",
103+
)
104+
warnSpy.mockRestore()
100105
})
101106

102107
it("uses explicit fromBlock and toBlock when provided", async () => {

0 commit comments

Comments
 (0)