From 31d90f9391635edd9728c87d6e7f3c7efa79ab55 Mon Sep 17 00:00:00 2001 From: dirtybits Date: Fri, 4 Sep 2026 09:50:22 -0700 Subject: [PATCH] fix(api): validate public agent route pubkeys --- ...alidate-agent-public-route-pubkeys.plan.md | 75 +++++++++++++++++++ .../api/agent-identity-route.test.ts | 21 +++++- web/__tests__/api/agent-trust-route.test.ts | 26 +++++-- web/app/api/agents/[pubkey]/identity/route.ts | 13 ++++ web/app/api/agents/[pubkey]/trust/route.ts | 13 ++++ 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 .agents/plans/validate-agent-public-route-pubkeys.plan.md diff --git a/.agents/plans/validate-agent-public-route-pubkeys.plan.md b/.agents/plans/validate-agent-public-route-pubkeys.plan.md new file mode 100644 index 00000000..a38b019a --- /dev/null +++ b/.agents/plans/validate-agent-public-route-pubkeys.plan.md @@ -0,0 +1,75 @@ +--- +name: validate-agent-public-route-pubkeys +overview: "Reject malformed Solana public agent route parameters before identity or trust lookups can perform RPC or database work." +todos: + - id: confirm-route-boundary-gap + content: Confirm the identity and trust GET routes accept malformed Solana wallet path parameters and identify the shared validation helper + status: completed + - id: validate-public-agent-params + content: Validate the public identity and trust GET route pubkeys before downstream lookups + status: completed + - id: add-no-side-effect-regressions + content: Add route regressions proving malformed pubkeys return 400 without trust, identity, or dispute work + status: completed + - id: verify-focused-change + content: Run the focused tests plus format, lint, typecheck, full web tests, webpack build, and git whitespace checks + status: completed +isProject: false +--- + +# Validate Public Agent Route Public Keys + +## Goal +Make public Solana agent identity and trust reads reject malformed wallet path parameters with a stable client error before they invoke trust, identity, or dispute helpers. + +## Scope +- In scope: `GET /api/agents/[pubkey]/identity`, `GET /api/agents/[pubkey]/trust`, their adjacent tests, and this execution record. +- Out of scope: authenticated identity mutation behavior, GitHub-linking behavior, author-route changes, Base/EVM routing, database schema, RPC configuration, wallet authentication, and chain deployment. + +## Files To Change +- `web/app/api/agents/[pubkey]/identity/route.ts`: validate the public GET path parameter before profile status and identity resolution. +- `web/app/api/agents/[pubkey]/trust/route.ts`: validate the public GET path parameter before trust, identity, or dispute reads. +- `web/__tests__/api/agent-identity-route.test.ts`: use a valid Solana fixture and add the malformed-param/no-side-effect regression. +- `web/__tests__/api/agent-trust-route.test.ts`: use a valid Solana fixture and add the malformed-param/no-side-effect regression. +- `.agents/plans/validate-agent-public-route-pubkeys.plan.md`: retain scoped evidence and exact validation results. + +## Verified Gap (2026-09-04) +- `GET /api/agents/[pubkey]/identity` passes `pubkey` directly to `verifyAuthorTrust` and `resolveAgentIdentityByWallet` (`web/app/api/agents/[pubkey]/identity/route.ts:21-25`). A direct local handler probe with `not-a-wallet` returned `200` with a synthetic fallback identity rather than rejecting the invalid public key. +- `GET /api/agents/[pubkey]/trust` immediately calls `resolveAuthorTrust`, `resolveAgentIdentityByWallet`, and `listAuthorDisputesByAuthor` with its unchecked path parameter (`web/app/api/agents/[pubkey]/trust/route.ts:19-24`). +- `isValidChainAddress` validates a Solana address locally and is the established API-boundary helper (`web/lib/chainAddress.ts:62-72`); the configured Solana context is available from `getConfiguredSolanaChainContext`. +- Open PRs #157–#169 were checked on 2026-09-04. #169 hardens the distinct `/api/author/[pubkey]` route; none changes these public `/api/agents/[pubkey]` GET handlers. + +## Implementation Steps +1. Import `getConfiguredSolanaChainContext` and `isValidChainAddress` in each public GET route. +2. Reject a malformed `pubkey` with `400 { error: "Agent routes require a valid Solana address" }` before calling any downstream helper. +3. Update success fixtures to a valid Solana public key, then assert malformed parameters perform no downstream lookup work. + +## Verification +Run under the repository-required Node 24 environment: +```bash +. "$HOME/.nvm/nvm.sh" --no-use && { nvm use --silent || nvm install; } +npm test --workspace @agentvouch/web -- __tests__/api/agent-identity-route.test.ts __tests__/api/agent-trust-route.test.ts --maxWorkers=1 --no-fileParallelism +npm run format:check +npm run lint:web +npm run typecheck +npm test --workspace @agentvouch/web -- --maxWorkers=1 --no-fileParallelism +npm exec --workspace @agentvouch/web -- next build --webpack +git diff --check +``` + +Acceptance criteria: malformed public-agent path parameters return the documented `400`, make no downstream calls, valid Solana reads retain their responses, and the full local web gate passes. + +### Execution Note (2026-09-04) +- Both public GET handlers now validate against `getConfiguredSolanaChainContext()` via the shared `isValidChainAddress` helper before invoking trust, identity, or dispute work. +- The focused regression suite passed: `__tests__/api/agent-identity-route.test.ts` plus `__tests__/api/agent-trust-route.test.ts` (17 tests). The change was developed test-first: each new malformed-param regression failed with the old `200` response before the handlers were updated. +- `npm run format:check`, `npm run lint:web`, and `npm run typecheck` passed. The full web suite passed (128 files, 929 tests), followed by `npm exec --workspace @agentvouch/web -- next build --webpack` and `git diff --check`. +- The build retained the repository's existing `ox` dynamic-dependency warning and expected static-generation `DATABASE_URL` fallback logs because local database credentials are absent. No live Solana RPC, database, browser, wallet, or deployment flow was run. + +## Rollout +Ship as a focused request-boundary-hardening PR. No environment, schema, money-flow, chain deployment, or authenticated mutation change is included. + +## Rollback +Revert the focused commit. No stored data or deployment state needs rollback. + +## Blockers +- No live Solana RPC, database, browser, wallet, or deployment flow is required for this handler-boundary change; behavior will be verified by route tests. diff --git a/web/__tests__/api/agent-identity-route.test.ts b/web/__tests__/api/agent-identity-route.test.ts index b64df102..ea593e3f 100644 --- a/web/__tests__/api/agent-identity-route.test.ts +++ b/web/__tests__/api/agent-identity-route.test.ts @@ -51,6 +51,7 @@ const mockGithubSession = getGithubSessionFromRequest as unknown as ReturnType< const mockVerifyAuthorTrust = verifyAuthorTrust as unknown as ReturnType< typeof vi.fn >; +const SOLANA_PUBKEY = "AGNtBjLEHFnssPzQjZJnnqiaUgtkaxj4fFaWoKD6yVdg"; function makeRequest( path: string, @@ -74,18 +75,32 @@ describe("/api/agents/[pubkey]/identity", () => { mockResolveIdentity.mockResolvedValue({ username: "wallet-dmt4cd" }); const res = await GET( - makeRequest("/api/agents/Wallet111/identity", "GET"), - { params: Promise.resolve({ pubkey: "Wallet111" }) } + makeRequest(`/api/agents/${SOLANA_PUBKEY}/identity`, "GET"), + { params: Promise.resolve({ pubkey: SOLANA_PUBKEY }) } ); const body = await res.json(); expect(res.status).toBe(200); expect(body.author_identity.username).toBe("wallet-dmt4cd"); - expect(mockResolveIdentity).toHaveBeenCalledWith("Wallet111", { + expect(mockResolveIdentity).toHaveBeenCalledWith(SOLANA_PUBKEY, { hasAgentProfile: true, }); }); + it("rejects malformed public keys before trust or identity lookups", async () => { + const res = await GET( + makeRequest("/api/agents/not-a-solana-address/identity", "GET"), + { params: Promise.resolve({ pubkey: "not-a-solana-address" }) } + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: "Agent routes require a valid Solana address", + }); + expect(mockVerifyAuthorTrust).not.toHaveBeenCalled(); + expect(mockResolveIdentity).not.toHaveBeenCalled(); + }); + it("rejects username updates signed by another wallet", async () => { mockVerifyWalletSignature.mockReturnValue({ valid: true, diff --git a/web/__tests__/api/agent-trust-route.test.ts b/web/__tests__/api/agent-trust-route.test.ts index d5ca2b5d..a0da4d8d 100644 --- a/web/__tests__/api/agent-trust-route.test.ts +++ b/web/__tests__/api/agent-trust-route.test.ts @@ -33,6 +33,7 @@ const mockResolveIdentity = const mockListDisputes = listAuthorDisputesByAuthor as unknown as ReturnType< typeof vi.fn >; +const SOLANA_PUBKEY = "AGNtBjLEHFnssPzQjZJnnqiaUgtkaxj4fFaWoKD6yVdg"; describe("GET /api/agents/[pubkey]/trust", () => { beforeEach(() => { @@ -59,15 +60,15 @@ describe("GET /api/agents/[pubkey]/trust", () => { mockListDisputes.mockResolvedValue([{ publicKey: "Dispute111" }]); const request = new NextRequest( - "http://localhost/api/agents/Author111/trust" + `http://localhost/api/agents/${SOLANA_PUBKEY}/trust` ); const response = await GET(request, { - params: Promise.resolve({ pubkey: "Author111" }), + params: Promise.resolve({ pubkey: SOLANA_PUBKEY }), }); const body = await response.json(); expect(response.status).toBe(200); - expect(body.pubkey).toBe("Author111"); + expect(body.pubkey).toBe(SOLANA_PUBKEY); expect(body.trust.canonical_agent_id).toBe("agent-1"); expect(body.trust.recommended_action).toBe("allow"); expect(body.trust.isRegistered).toBe(true); @@ -95,10 +96,10 @@ describe("GET /api/agents/[pubkey]/trust", () => { mockListDisputes.mockResolvedValue([]); const request = new NextRequest( - "http://localhost/api/agents/Author111/trust" + `http://localhost/api/agents/${SOLANA_PUBKEY}/trust` ); const response = await GET(request, { - params: Promise.resolve({ pubkey: "Author111" }), + params: Promise.resolve({ pubkey: SOLANA_PUBKEY }), }); const body = await response.json(); @@ -106,4 +107,19 @@ describe("GET /api/agents/[pubkey]/trust", () => { expect(body.trust.recommended_action).toBe("avoid"); expect(body.trust.isRegistered).toBe(false); }); + + it("rejects malformed public keys before trust, identity, or dispute lookups", async () => { + const response = await GET( + new NextRequest("http://localhost/api/agents/not-a-solana-address/trust"), + { params: Promise.resolve({ pubkey: "not-a-solana-address" }) } + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Agent routes require a valid Solana address", + }); + expect(mockResolveAuthorTrust).not.toHaveBeenCalled(); + expect(mockResolveIdentity).not.toHaveBeenCalled(); + expect(mockListDisputes).not.toHaveBeenCalled(); + }); }); diff --git a/web/app/api/agents/[pubkey]/identity/route.ts b/web/app/api/agents/[pubkey]/identity/route.ts index a36e506c..51605579 100644 --- a/web/app/api/agents/[pubkey]/identity/route.ts +++ b/web/app/api/agents/[pubkey]/identity/route.ts @@ -5,6 +5,8 @@ import { updateAgentUsername, } from "@/lib/agentIdentity"; import { PRIVATE_NO_STORE_CACHE_CONTROL } from "@/lib/cachePolicy"; +import { isValidChainAddress } from "@/lib/chainAddress"; +import { getConfiguredSolanaChainContext } from "@/lib/chains"; import { getErrorMessage } from "@/lib/errors"; import { verifyAuthorTrust } from "@/lib/trust"; @@ -20,6 +22,17 @@ export async function GET( ) { try { const { pubkey } = await params; + if ( + !isValidChainAddress({ + chainContext: getConfiguredSolanaChainContext(), + value: pubkey, + }) + ) { + return NextResponse.json( + { error: "Agent routes require a valid Solana address" }, + { status: 400 } + ); + } const authorIdentity = await resolveAgentIdentityByWallet(pubkey, { hasAgentProfile: await getHasAgentProfile(pubkey), }); diff --git a/web/app/api/agents/[pubkey]/trust/route.ts b/web/app/api/agents/[pubkey]/trust/route.ts index f356ce8a..79ecb5c1 100644 --- a/web/app/api/agents/[pubkey]/trust/route.ts +++ b/web/app/api/agents/[pubkey]/trust/route.ts @@ -9,6 +9,8 @@ import { PUBLIC_ROUTE_CACHE_SECONDS, PUBLIC_ROUTE_STALE_SECONDS, } from "@/lib/cachePolicy"; +import { isValidChainAddress } from "@/lib/chainAddress"; +import { getConfiguredSolanaChainContext } from "@/lib/chains"; import { getErrorMessage } from "@/lib/errors"; export async function GET( @@ -17,6 +19,17 @@ export async function GET( ) { try { const { pubkey } = await params; + if ( + !isValidChainAddress({ + chainContext: getConfiguredSolanaChainContext(), + value: pubkey, + }) + ) { + return NextResponse.json( + { error: "Agent routes require a valid Solana address" }, + { status: 400 } + ); + } const trust = await resolveAuthorTrust(pubkey); const identity = await resolveAgentIdentityByWallet(pubkey, { hasAgentProfile: trust.isRegistered,