diff --git a/app/app/api/a2a/route.ts b/app/app/api/a2a/route.ts index d31874a..c4b3969 100644 --- a/app/app/api/a2a/route.ts +++ b/app/app/api/a2a/route.ts @@ -2,8 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { buildA2AResponse, buildA2AError } from "@/lib/aip/a2a"; import type { A2ARequest } from "@/lib/aip/a2a"; +import { enforceIpLimit } from "@/lib/rateLimit"; export async function POST(request: NextRequest) { + // Throttle: this JSON-RPC endpoint can create Job rows; cap unauthenticated + // abuse (spam job creation / resource exhaustion). + const limited = await enforceIpLimit(request, "a2a"); + if (limited) return limited; try { const body: A2ARequest = await request.json(); diff --git a/app/app/api/agents/stake/route.ts b/app/app/api/agents/stake/route.ts index 2f1f732..c6f4c90 100644 --- a/app/app/api/agents/stake/route.ts +++ b/app/app/api/agents/stake/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { enforceIpLimit } from "@/lib/rateLimit"; -import { requireAuth } from "@/lib/require-auth"; +import { requireAuth, requireWalletMatch } from "@/lib/require-auth"; /** * POST /api/agents/stake @@ -31,6 +31,11 @@ export async function POST(req: NextRequest) { ); } + // IDOR guard: only the wallet owner may stake under their address. + const __guard = requireWalletMatch(__auth, walletAddress); + if (!__guard.ok) + return NextResponse.json({ error: __guard.reason }, { status: __guard.status }); + if (typeof amount !== "number" || amount < 10) { return NextResponse.json( { error: "Minimum stake is 10 USDC" }, diff --git a/app/app/api/agents/unstake/route.ts b/app/app/api/agents/unstake/route.ts index 2771cff..e7f3932 100644 --- a/app/app/api/agents/unstake/route.ts +++ b/app/app/api/agents/unstake/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { enforceIpLimit } from "@/lib/rateLimit"; +import { requireAuth, requireWalletMatch } from "@/lib/require-auth"; /** * POST /api/agents/unstake @@ -19,7 +20,11 @@ export async function POST(req: NextRequest) { const limited = await enforceIpLimit(req, "unstake"); if (limited) return limited; - const body = await req.json(); + const __raw = await req.text(); + const __auth = await requireAuth(req, { rawBody: __raw }); + if (!__auth.ok) + return NextResponse.json({ error: __auth.reason }, { status: __auth.status }); + const body = __raw ? JSON.parse(__raw) : {}; const { walletAddress } = body as { walletAddress?: string }; if (!walletAddress || typeof walletAddress !== "string") { @@ -29,6 +34,11 @@ export async function POST(req: NextRequest) { ); } + // IDOR guard: unstake/slash/bonus only affects the owner's own stake. + const __guard = requireWalletMatch(__auth, walletAddress); + if (!__guard.ok) + return NextResponse.json({ error: __guard.reason }, { status: __guard.status }); + // Check active stake const stake = await prisma.agentStake.findUnique({ where: { walletAddress }, diff --git a/app/app/api/delivery/upload/route.ts b/app/app/api/delivery/upload/route.ts index ace785f..26cfd39 100644 --- a/app/app/api/delivery/upload/route.ts +++ b/app/app/api/delivery/upload/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; +import { enforceIpLimit } from "@/lib/rateLimit"; /** * POST /api/delivery/upload @@ -15,6 +16,10 @@ import crypto from "crypto"; * Requires: BLOB_READ_WRITE_TOKEN env var. */ export async function POST(req: NextRequest) { + // Throttle: each call writes to Vercel Blob (storage + cost). Cap abuse. + const limited = await enforceIpLimit(req, "delivery_upload"); + if (limited) return limited; + const token = process.env.BLOB_READ_WRITE_TOKEN; if (!token) { return NextResponse.json( diff --git a/app/app/api/generate/image/route.ts b/app/app/api/generate/image/route.ts index bf32a48..5282395 100644 --- a/app/app/api/generate/image/route.ts +++ b/app/app/api/generate/image/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; +import { enforceIpLimit } from "@/lib/rateLimit"; /** * POST /api/generate/image @@ -33,6 +34,11 @@ const SIZE_MAP: Record = { }; export async function POST(req: NextRequest) { + // Throttle: each call hits the paid fal.ai API + Vercel Blob. Without a + // limit an unauthenticated caller can run up the bill / exhaust quota. + const limited = await enforceIpLimit(req, "generate_image"); + if (limited) return limited; + if (!FAL_KEY) { return NextResponse.json( { diff --git a/app/app/api/profile/[wallet]/avatar/route.ts b/app/app/api/profile/[wallet]/avatar/route.ts index 7121e1c..990caf6 100644 --- a/app/app/api/profile/[wallet]/avatar/route.ts +++ b/app/app/api/profile/[wallet]/avatar/route.ts @@ -1,10 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { requireAuth, requireWalletMatch } from "@/lib/require-auth"; +import { enforceIpLimit } from "@/lib/rateLimit"; const AGENT_ALPHA_WALLET = process.env.AGENT_ALPHA_WALLET || ""; const AGENT_OMEGA_WALLET = process.env.AGENT_OMEGA_WALLET || ""; const MAX_SIZE_BYTES = 500 * 1024; // 500KB +// Raster image types only. SVG (data:image/svg+xml) is intentionally excluded: +// SVG is an active document that can carry . 2. The server stores it and returns a public deliveryUri serving it as text/html. 3. Distribute the URL for phishing/malware hosting under the project's storage domain. Repeat to abuse storage quota anonymously. +- **Fix:** Require authentication (wallet signature / session) and tie uploads to a job the caller owns. Enforce an allowlist of content types and validate by sniffing magic bytes rather than trusting file.type. Force a safe stored content-type (e.g. application/octet-stream or text/plain) and Content-Disposition: attachment so blobs are never served as active HTML. Sanitize filename. + +### Unauthenticated hosted-agent avatar upload with no real size guard ordering and SVG acceptance +- **File:** `app/app/api/hosted-agents/avatar/route.ts:5-18` +- **Category:** Missing authentication / Stored content injection · **Confidence:** high +- **What:** POST /api/hosted-agents/avatar accepts a multipart 'avatar' file with no authentication and no rate limiting. The type check `file.type.startsWith("image/")` is satisfied by `image/svg+xml`, and file.type is fully attacker-controlled (it is echoed back into the returned `data:${file.type};base64,...` URL). The result is a base64 data URL the client stores/renders. Combined with the unauthenticated profile/hosted-agent surfaces, this lets anyone mint arbitrary image/SVG data URLs; SVG markup is attacker-controlled and only safe as long as it is rendered exclusively via . +- **Exploit:** `curl -F 'avatar=@payload.svg;type=image/svg+xml' https://app/api/hosted-agents/avatar` returns `{"url":"data:image/svg+xml;base64,..."}` with attacker SVG markup, no auth required. The MIME label is whatever the attacker sends. Also unthrottled, so usable for memory/CPU pressure with 2MB bodies. +- **Fix:** Add requireAuth() + rate limiting. Restrict accepted types to a raster allowlist (png/jpeg/webp/gif) and reject svg+xml. Do not trust file.type — verify magic bytes and emit a normalized MIME. + +### POST /api/referral/claim — create referral edges for arbitrary wallets, no auth +- **File:** `app/app/api/referral/claim/route.ts:12-66` +- **Category:** Missing authentication / abuse · **Confidence:** high +- **What:** Takes referredWallet + referrerWallet from the body, only checks self-referral and a one-time 'already referred' constraint. No authentication and no proof that the caller controls either wallet. XP is awarded later on first job completion based on this referral edge. +- **Exploit:** Attacker binds any victim wallet as 'referred' by an attacker-controlled referrerWallet (referredWallet=VICTIM, referrerWallet=ATTACKER). This permanently consumes the victim's one-time referral slot (denying them a legitimate referrer) and sets the attacker up to harvest referral XP/rewards when the victim completes a job. Mass-creatable across wallets. +- **Fix:** Require the referred party to prove control of referredWallet via requireAuth (__auth.wallet === referredWallet). Add per-IP rate limiting. + +### POST /api/reviews — taker reputation/avgRating writable by impersonating the poster +- **File:** `app/app/api/reviews/route.ts:9-104` +- **Category:** IDOR / missing authentication · **Confidence:** high +- **What:** Enforces job.posterWallet === posterWallet (so only the 'poster' can review) but performs NO authentication that the caller actually controls posterWallet. posterWallet is purely body-supplied. The review then recomputes and writes the taker's Reputation.avgRating/reviewCount. +- **Exploit:** Anyone who knows a finalized jobId and its posterWallet (both exposed via GET /api/jobs and GET /api/disputes includes) submits a review on the poster's behalf — e.g. a 1-star review to tank a competing taker's avgRating, or a 5-star to inflate an ally — without controlling the poster wallet. One-review-per-job is the only limiter. +- **Fix:** Require requireAuth and enforce __auth.wallet === posterWallet === job.posterWallet before writing the review and reputation aggregate. + +### POST /api/disputes and /api/jobs/[id]/dispute — 'only poster' check is unauthenticated; default config has no on-chain guard on the generic route +- **File:** `app/app/api/disputes/route.ts:57-187 and app/app/api/jobs/[id]/dispute/route.ts:26-202` +- **Category:** IDOR / missing authentication · **Confidence:** medium +- **What:** Both require job.posterWallet === posterWallet but posterWallet is body-supplied and requireAuth is a no-op by default and never bound to it. /api/jobs/[id]/dispute additionally requires a real on-chain raise_dispute txHash and verifies the PDA, which blocks fund-moving abuse for on-chain jobs; but /api/disputes (the generic fallback) accepts txHash as optional and writes the Dispute row + flips job.status to 'Disputed' without mandatory on-chain verification. +- **Exploit:** For a Delivered job whose poster wallet is known (exposed in job listings), an attacker calls POST /api/disputes with that posterWallet and a fabricated reason, flipping the job to 'Disputed' and blocking finalize (/finalize returns 409 while a dispute exists). This is a denial-of-settlement / griefing vector against the legitimate taker, requiring no poster credentials. For jobs without a pda the on-chain branch in /jobs/[id]/dispute is also skipped. +- **Fix:** Bind requireAuth: __auth.wallet === posterWallet === job.posterWallet. Make on-chain txHash verification mandatory (not optional) on /api/disputes for any job that has a pda, matching the per-job route. + +### POST /api/jobs/[id]/accept & /submit — taker identity body-supplied; bot/agent jobs skip on-chain proof entirely +- **File:** `app/app/api/jobs/[id]/accept/route.ts:42-150 and app/app/api/jobs/[id]/submit/route.ts:42-264` +- **Category:** IDOR / impersonation · **Confidence:** medium +- **What:** takerWallet is taken from the body; requireAuth is unbound/no-op. On accept, the on-chain check only runs if acceptTxHash is supplied (optional). On submit, on-chain verification is required only when isHumanFlow (poster wallet not starting with 'covenant-agent-') OR commitmentTxHash present; for agent/bot-posted jobs the route writes Delivery + flips job to Delivered + withdraws competitors + writes a Submission and reputation with NO signature and NO on-chain proof. +- **Exploit:** Attacker accepts a job as an arbitrary takerWallet (no tx), then for any agent-posted job submits work as an arbitrary takerWallet without commitmentTxHash — forging a delivery, knocking out competing interests (set to 'withdrawn'), and becoming the official taker. On human jobs lacking a pda the submit on-chain block is also bypassable. This corrupts the competitive multi-taker race and lets an attacker steal the 'winner' slot. +- **Fix:** Bind __auth.wallet === takerWallet. Require a verified on-chain commitment for every settle-eligible job (do not special-case agent jobs into a no-proof path on a route that mutates winner assignment and reputation). + +### POST /api/hosted-agents — create hosted agents under any wallet, no auth +- **File:** `app/app/api/hosted-agents/route.ts:43-140` +- **Category:** Missing authentication / impersonation · **Confidence:** high +- **What:** Creates a HostedAgent with walletAddress from the body and awards 50 XP to that wallet, with no requireAuth and no rate limit. (avatarUrl is SSRF-checked, the only guard.) +- **Exploit:** Attacker mass-creates hosted agents attributed to victim wallets (or floods the marketplace), each awarding 50 XP to an attacker-chosen walletAddress — an unauthenticated XP/reputation inflation and spam primitive. +- **Fix:** Require requireAuth with __auth.wallet === walletAddress and add per-wallet/per-IP rate limiting. + +### POST /api/disputes/[id]/resolve — arbitrator identity is body-supplied, not signature-verified +- **File:** `app/app/api/disputes/[id]/resolve/route.ts:33-152` +- **Category:** Broken access control / authorization · **Confidence:** high +- **What:** The route checks ARBITRATORS.includes(arbitratorWallet) where arbitratorWallet is taken from the body — there is no proof the caller controls that whitelisted arbitrator wallet. requireAuth is not used here at all. The fund-moving threshold path (line 168+) does require a real on-chain resolve_dispute txHash, which prevents direct theft; but the pre-threshold votes mutate dispute.resolution/approvedBy/approvalCount with only a spoofed arbitrator name. +- **Exploit:** Knowing the public arbitrator addresses (COVENANT_ARBITRATORS), an attacker submits a vote as arbitratorWallet= for a chosen resolution (e.g. FavorPoster). This records a fraudulent approval and LOCKS the pending resolution: once currentResolution !== 'Pending', any genuine arbitrator wanting a different outcome is rejected with 409 ('cannot approve X without a new dispute'). One spoofed vote can therefore censor/steer the real arbitration outcome and grief the resolution flow. +- **Fix:** Verify the caller controls arbitratorWallet via a signature (requireAuth bound to arbitratorWallet, or a per-arbitrator API key), before recording any vote — not just at the on-chain settlement step. + +### Helius webhook iterates an unbounded payload array, issuing 2-3 DB round-trips per element +- **File:** `app/app/api/helius/webhook/route.ts:154` +- **Category:** unbounded-loop-dos · **Confidence:** medium +- **What:** After auth, POST /api/helius/webhook parses the JSON body into `payload` (an array; a single object is wrapped to length 1) and loops over every element with no cap on `payload.length`. For each element it runs a `jobEvent.findUnique` (idempotency), then up to two `job.findFirst` lookups, then a `jobEvent.create` and possibly a `job.update` — i.e. 2-5 sequential awaited Prisma queries per array entry, all in series. There is no limit on the number of transactions per request and no overall request body-size limit (no `bodyParser.sizeLimit` configured anywhere; confirmed absent from next.config). +- **Exploit:** An attacker who learns/obtains HELIUS_WEBHOOK_SECRET (or any party able to replay a captured webhook auth header) POSTs a single request with a JSON array of, say, 200k tx objects. The handler walks all of them sequentially, doing hundreds of thousands of DB queries in one request, holding a serverless function open until it times out while saturating the Postgres connection/pool and starving every other request. Even without the secret, the unbounded `await req.json()` lets a large body consume memory before auth checks complete on the JSON parse path. +- **Fix:** Cap `payload.length` (e.g. reject > 1000 entries with 413), enforce a request body-size limit, batch the idempotency check (`findMany({ where:{ txSignature:{ in: sigs } } })`) and the job lookups instead of per-element awaits, and process in bounded chunks. Helius batches are small in practice, so a few-hundred cap is safe. + +### Unbounded findMany on home-stats and per-wallet dashboard (no take) +- **File:** `app/app/api/stats/dashboard/route.ts:69` +- **Category:** unbounded-query · **Confidence:** high +- **What:** Several public GET endpoints run `prisma.job.findMany` with a where clause but no `take`/limit, so the result set grows with table size. stats/dashboard/route.ts:21 (recentJobs, 7-day window but unbounded count) and :69 (allUserJobs — ALL jobs ever for a wallet, no time bound, no take) load every matching row into memory just to compute category counts. stats/route.ts:26 (lockedJobs — every Open/Accepted job) does the same to sum amounts. As the Job table grows, a single request transfers and materializes the entire matching set. +- **Exploit:** An attacker first inflates the Job table cheaply (e.g. via the unauthenticated /api/a2a task/create, or arena/autonomous self-posting), concentrating many jobs on one wallet or in Open/Accepted status. Then they repeatedly hit `GET /api/stats/dashboard?wallet=` and `GET /api/stats`. Each call pulls the full unbounded row set into the Node process, spiking memory and DB egress; concurrent calls amplify it into an OOM / DB-transfer DoS. stats/dashboard has no caching, so every request re-runs the scan. +- **Fix:** Replace the in-memory aggregation with DB-side `groupBy`/`aggregate` (count per category, sum of amount) so only aggregates cross the wire, or add a bounded `take` plus a time window. Add the same `memoize()` wrapper stats/route.ts uses to stats/dashboard. + +### Reputation upsert in reviews wipes job/earning history; review route has no auth, rate limit, or finalize-time binding +- **File:** `app/app/api/reviews/route.ts:9-104` +- **Category:** review-manipulation · **Confidence:** medium +- **What:** POST /api/reviews has no requireAuth and no IP rate limit (unlike most other mutating routes). It trusts body posterWallet/takerWallet. The only guard is that body.posterWallet must equal job.posterWallet, but with no auth the caller can read the poster wallet from the public jobs API and submit on their behalf. Because there is exactly one review per job (unique on jobId) it cannot be spammed per-job, but an attacker controlling job creation (self-posted demo jobs) can manufacture unlimited finalized jobs and leave unlimited 5-star reviews for a target taker to inflate avgRating, or 1-star reviews to defame a competitor taker. Separately, the reputation upsert at lines 80-91 uses `create` with only avgRating/reviewCount — fine for create — but on `update` it sets avgRating/reviewCount; that part is OK. The real issue is the avg is computed over ALL reviews for the takerWallet with no weighting and no proof the reviewer transacted, enabling rating farming. +- **Exploit:** Attacker self-posts demo jobs (free), self-finalizes them (reputation farm above), then POSTs /api/reviews with rating:5 for their own taker wallet on each, driving avgRating to 5.0 and reviewCount high — boosting their claim-marketplace credibility. Or targets a competitor: spin up jobs naming the competitor as taker is not possible directly, but any job they legitimately took can be 1-starred since the route only checks the body poster matches the job poster (readable publicly) with no signer binding. +- **Fix:** Add requireAuth + bind auth.wallet === posterWallet, add IP/wallet rate limiting, and only allow reviews on jobs with a verified on-chain settlement (escrowLocked) so fabricated demo jobs cannot seed ratings. Consider weighting avgRating by settled value. + +### Faucet drainable across unlimited wallets from one origin (per-IP cap on a free 100-USDC mint) +- **File:** `app/app/api/faucet/route.ts:39-60` +- **Category:** faucet-draining · **Confidence:** medium +- **What:** The faucet mints 100 test USDC per call, rate-limited 1/hour/wallet and 10/hour/IP. There is no global/day cap and no proof-of-work or captcha. Wallets are unlimited and free to generate, so the only real limiter is the per-IP 10/hour. An attacker rotating IPs (proxies, IPv6 /64 rotation, serverless egress) mints unbounded test USDC. While this is devnet 'test' USDC, the same USDC mint backs escrow amounts and totalEarned analytics; a flood of faucet mints lets an attacker fund large self-dealing jobs and inflate TVL/reputation metrics cheaply. ipFromRequest relies on x-forwarded-for which is client-spoofable if not behind a trusted proxy. +- **Exploit:** Script: for i in N: generate fresh keypair; POST /api/faucet {walletAddress}. Per-IP cap of 10/hr is bypassed by rotating X-Forwarded-For or source IP. Each mint = 100 USDC to a fresh wallet. Use the proceeds to fund escrow on self-posted jobs and farm reputation/claims. +- **Fix:** Add a global daily mint ceiling and a per-IP cap that uses a trusted proxy header only (validate X-Forwarded-For against known proxy, or use the platform-provided client IP). Consider requiring a small PoW or a minimum wallet age/activity. Cap total faucet mints per wallet lifetime, not just per hour. + +### Referral farming: no auth, no wallet validation, self-referral check trivially bypassed via address variants +- **File:** `app/app/api/referral/claim/route.ts:12-58` +- **Category:** referral-farming · **Confidence:** medium +- **What:** POST /api/referral/claim has no requireAuth, no rate limit, and no validation that referredWallet is a real/distinct human. The self-referral guard is a raw string equality `referredWallet === referrerWallet` (line 28). Solana base58 addresses are case-sensitive so simple casing won't bypass, but the attacker simply uses two different wallets they both control (Sybil). The unique constraint on referredWallet only stops referring the same wallet twice. XP is later awarded on first job completion in agents/fulfill (30 to referrer, 15 to referred, lines 179-180), and that fulfill path can be triggered by the attacker's own self-posted demo jobs. So an attacker farms referral XP at will: generate wallet B, claim referral (A refers B), then have B post+fulfill a demo job to trigger the 30/15 XP payout to A and B. +- **Exploit:** 1. Attacker controls wallets A and B. 2. POST /api/referral/claim {referredWallet:B, referrerWallet:A}. 3. As B, POST a demo-mode job and call /api/agents/fulfill; fulfill finds the referral and awards 30 XP to A + 15 XP to B. 4. Repeat with fresh B wallets to farm unlimited referral XP and climb leaderboards. +- **Fix:** Require auth and bind referredWallet to the authenticated signer. Rate-limit per IP. Only award referral XP when the referred wallet completes a real, on-chain-settled job (escrowLocked, poster != taker). Add velocity/Sybil heuristics (e.g. cap referrals per referrer per day). + +### agents/stake records arbitrary unbacked stake amounts; no on-chain transfer verification +- **File:** `app/app/api/agents/stake/route.ts:12-54` +- **Category:** reputation-gaming / integer-abuse · **Confidence:** medium +- **What:** POST /api/agents/stake upserts an AgentStake row crediting `amount` (with `increment`) purely from the request body. There is no on-chain USDC transfer verification — the comment claims it records a 'USDC stake' but nothing proves funds were locked. The only checks are amount is a number >= 10 and walletAddress is a string. With auth unbound (see auth finding), an attacker stakes any amount for any wallet. Stake is commonly used as a Sybil/anti-spam signal or to grant agent privileges; a fabricated large stake can unlock gated behavior or boost trust scoring at zero cost. Also, because amount is a Float with only a lower bound and uses `increment`, repeated calls inflate stake unboundedly, and a non-integer/huge float (e.g. 1e308) can be supplied. +- **Exploit:** POST /api/agents/stake {walletAddress:, amount:1000000} repeatedly. Each call increments the recorded stake with no funds moved. The wallet now appears heavily staked to any feature that reads AgentStake. +- **Fix:** Require an on-chain stake/transfer tx signature and verify it (like claims do via verifyTxInvokedCovenant + account read) before recording. Bind walletAddress to the authenticated signer. Add an upper sanity bound and store atomic integer units, not Float. + +### finalize Path B marks job Finalized + books reputation/revenue/fees from an unrelated program tx when job.pda is null +- **File:** `app/app/api/jobs/[id]/finalize/route.ts:118-141` +- **Category:** finalize-crank-abuse · **Confidence:** medium +- **What:** On the client-cranked path, the only on-chain checks are verifyTxInvokedCovenant(callerTxSig) (tx landed + touched the Covenant program + didn't revert) and a JobEscrow status==Finalized check that is gated behind `if (job.pda)`. For any job whose DB row has a null pda (demo / legacy / record-only jobs created via the demoMode bypass in /api/jobs), the status check is skipped completely. The callerTxSig is never bound to THIS job's escrow PDA, beneficiary, or amount — any confirmed transaction that invoked the program at all satisfies the check. The route then flips status to Finalized, upserts taker Reputation (jobsCompleted/totalEarned += job.amount), writes a ProtocolFee row, awards XP, and records a finalize Transaction, all off-chain, with no real settlement having occurred for this job. +- **Exploit:** 1. Create a record-only job (demoMode path) so job.pda is null but job.amount is large and status Delivered, takerWallet = attacker. 2. Wait out the challenge window. 3. POST /api/jobs/{id}/finalize with txSignature = any real signature of any tiny Covenant-program instruction the attacker made (e.g. a 1-lamport list_claim on an unrelated job). verifyTxInvokedCovenant passes; the pda-gated Finalized check is skipped. 4. Job is marked Finalized and attacker's Reputation.totalEarned / jobsCompleted inflate and a finalize Transaction is recorded — reputation farming and fabricated settlement records with no escrow release. +- **Fix:** Require job.pda to be present for Path B and make the JobEscrow status==Finalized check unconditional. Additionally bind the verification to the job: re-derive the JobEscrow PDA from poster+specHash and assert the callerTxSig's account keys include that PDA (or that fetchJobEscrow(job.pda) transitioned to Finalized as a result of this tx by checking deliveredAt/finalize markers), so an arbitrary program tx for a different job cannot satisfy finalize for this one. + +### Escrow token account is never re-derived from its canonical PDA seed; cancel_job can be tricked into closing the job while orphaning real escrow funds +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/cancel_job.rs:35-40 (also finalize_payment.rs:45-50, resolve_dispute.rs:44-49)` +- **Category:** missing PDA/account constraint · **Confidence:** high +- **What:** The escrow token account is created in create_job with seeds=[b"escrow_token", job_escrow.key()] (create_job.rs:44). Every downstream instruction (cancel_job, finalize_payment, resolve_dispute) re-accepts it as a plain TokenAccount validated ONLY by `escrow_token_account.owner == job_escrow.key()` and `mint == job_escrow.token_mint`. The canonical PDA seed is never re-derived or enforced. The token-account `owner` (authority) field is attacker-settable: anyone can create a brand-new SPL token account and set its authority to the job_escrow PDA. cancel_job is the dangerous case because it moves the *runtime balance* of whatever account is passed (`escrow_balance = escrow_token_account.amount`, cancel_job.rs:97) and then closes that account, while closing the JobEscrow PDA via `close = poster`. +- **Exploit:** 1. Poster (or any signer on an Open job they own, or poster/taker on an Accepted-past-deadline job) creates a fresh empty SPL token account ATK with mint = job's mint and authority = job_escrow PDA (authority is just a data field, no signature needed). 2. They call cancel_job passing ATK as escrow_token_account instead of the real escrow PDA token account. All constraints pass (owner==job_escrow, mint matches). 3. escrow_balance is 0, the transfer is skipped, ATK is closed, the JobEscrow PDA is closed to poster, status set Cancelled. 4. The real escrow token account (still holding `amount`) is now orphaned: its authority was the job_escrow PDA, whose state account no longer exists, so no instruction can ever sign for it again. Funds are permanently locked. For a poster cancelling their own Open job this is self-harm, but for the Accepted-past-deadline path either poster OR taker can cancel, so one party can grief the other by locking the counterparty's refund/payment. +- **Fix:** Constrain the escrow token account to its canonical PDA in every instruction that touches it, e.g. `#[account(mut, seeds = [b"escrow_token", job_escrow.key().as_ref()], bump, ...)]`, or store the escrow token account pubkey in JobEscrow at create_job and add `address = job_escrow.escrow_token_account`. Also prefer transferring `job.amount` rather than the live balance in cancel_job. + +### buy_claim does not prevent the poster from buying the claim, enabling self-dealing payout redirection +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/buy_claim.rs:60-86` +- **Category:** authority confusion / self-dealing · **Confidence:** high +- **What:** buy_claim only forbids buyer == seller (BuyerIsSeller, buy_claim.rs:64-67). It does not forbid buyer == job_escrow.poster. Once a claim is Bought, finalize_payment and resolve_dispute route the full escrow `amount` to listing.buyer (finalize_payment.rs:133, resolve_dispute.rs:219). The poster is the party who funded the escrow; letting them become the claim buyer means the escrow they funded can be routed straight back to them. +- **Exploit:** 1. Taker delivers work and lists the claim at a discounted `price < amount`. 2. The poster calls buy_claim as the buyer, paying the taker only `price`. 3. The challenge window lapses with no dispute and anyone calls finalize_payment; the escrow `amount` is routed to the buyer ATA, i.e. back to the poster. Net effect: the poster reclaims the full escrow they originally locked while the taker is paid only the discounted `price` instead of the agreed `amount` — the poster cra m-downs the worker to the listing discount with no arbitration. The poster can also buy then raise a FavorPoster dispute to recover the escrow through the bond path while having paid only `price`. +- **Fix:** Add `require!(ctx.accounts.buyer.key() != ctx.accounts.job_escrow.poster, CovError::Unauthorized)` in buy_claim, mirroring the existing BuyerIsSeller guard. + + +## LOW + +### x402.ts uses non-USDC SOL transfer and reads raw keypairs from env JSON (mismatch with verifier; key-handling hazard) +- **File:** `app/lib/x402.ts:23-61` +- **Category:** key handling / protocol mismatch · **Confidence:** medium +- **What:** sendX402Payment transfers native SOL via SystemProgram.transfer (lines 33-39), but the x402 verifier (x402-server.ts verifyTransfer) only validates SPL-token (USDC) transfers via pre/postTokenBalances. A SOL transfer produced here would never satisfy verifyPayment, and amounts are handled as floating-point SOL (line 37) rather than atomic units. getAgentKeypair (lines 54-61) loads full secret keys from AGENT_ALPHA_KEYPAIR/AGENT_OMEGA_KEYPAIR env vars and JSON.parses them into Keypair.fromSecretKey with no validation; any log of fromKeypairBytes or error containing it would leak a signing key. This is a hot-wallet secret-handling and protocol-consistency hazard rather than a verification bypass. +- **Exploit:** Not a direct external bypass. Operational risk: (a) payments sent via this path cannot be verified by the app's own x402 verifier, and (b) if AGENT_*_KEYPAIR is misconfigured or the byte array is logged/serialized in an error path, the agent's full Solana signing key is exposed, allowing an attacker to drain that wallet. +- **Fix:** If this path is meant for x402 USDC payments, use SPL-token transfers of the canonical USDC mint in atomic units so it matches the verifier; otherwise clearly scope it as the demo SOL-battle path. Never JSON-embed raw secret keys; load from a KMS/secret manager, validate length (64 bytes), and ensure keypair bytes are never included in logs or thrown error messages. + +### x402 payment is bound to (agentId, message) but not to the paying caller's identity +- **File:** `app/lib/x402-payments.ts:35-40 and app/app/api/hosted-agents/[id]/chat/route.ts:142-148` +- **Category:** signature/payment binding · **Confidence:** low +- **What:** claimPayment binds a consumed payment to requestHash = sha256(agentId\nmessage) (x402-payments.ts:35-40). verifyPayment confirms the on-chain transfer went to the agent's walletAddress and meets amount/mint (x402-server.ts), which is correct. However the served request is not bound to the on-chain payer: any party who learns a valid, not-yet-consumed transaction signature for an agent can present it with the SAME message and obtain the paid response. The first-presenter-wins model plus the public nature of Solana tx signatures means a third party who sees the signature in a mempool/explorer before the legitimate payer submits can claim the response (and burn the payer's one-shot payment for that exact prompt). +- **Exploit:** 1. Payer Alice broadcasts a USDC payment tx to the agent wallet and intends to POST {message, Payment-Signature: }. 2. Attacker Mallory, watching the chain, extracts once confirmed and POSTs it first with the SAME message (the quoted/known prompt). 3. claimPayment marks it fresh for Mallory; Mallory receives the paid response. 4. Alice's later POST with a different prompt is rejected as 'consumed', and her identical prompt only replays Mallory-triggered cached output. +- **Fix:** Bind the served request to the on-chain payer: require the caller to also prove control of the payer wallet (sign the request with the payer key, reusing verifyWalletSignature), and include the payer in requestHash. Reject when verification.payer does not match the authenticated caller. This ties the one-shot payment to the actual payer rather than to whoever presents the public signature first. + +### hosted-agents/avatar trusts client-declared MIME type for image validation +- **File:** `/Users/baturalpguvenc/covenant/app/app/api/hosted-agents/avatar/route.ts:5-17` +- **Category:** Unrestricted file upload · **Confidence:** medium +- **What:** POST /api/hosted-agents/avatar validates only file.type.startsWith('image/') (line 10), which is the client-controlled MIME label, and then base64-encodes the raw bytes into a data:;base64 URL returned to the caller. No magic-byte check is performed, so non-image content (e.g. an SVG with script, or an HTML payload labelled image/svg+xml) passes. The resulting data: URL is later used as an , where SVG can carry script in some rendering contexts. There is also no authentication on the route. +- **Exploit:** 1. POST multipart with a file whose type is set to image/svg+xml containing an . 2. file.type.startsWith('image/') passes. 3. The endpoint returns data:image/svg+xml;base64,; if that URL is rendered in an SVG-script-capable context it can execute. At minimum it bypasses the intended image-only restriction. +- **Fix:** Validate the actual file content with magic-byte sniffing and an allowlist of raster image types (png/jpeg/webp/gif). Explicitly reject SVG (or sanitize it with DOMPurify before storing). Add authentication and a size/type allowlist, and prefer Blob storage over base64 data URLs. + +### Markdown image src in chat rendered without scheme validation +- **File:** `/Users/baturalpguvenc/covenant/app/app/chat/[id]/page.tsx:379-433` +- **Category:** XSS / unsanitized content · **Confidence:** medium +- **What:** Chat messages are parsed with MD_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/ and the captured URL (inlineImg[2]) is placed directly into with no scheme allowlist. ChatMessage.content is stored from model output and from user/design-path input, so an attacker who can influence a stored agent/user message (e.g. via the a2a or chat APIs) can embed an arbitrary src. While `javascript:`/`data:text/html` in an does not execute script in modern browsers, the unrestricted src still enables tracking-pixel / SSRF-via-browser / content-spoofing and is a latent XSS sink if the renderer is ever changed to dangerouslySetInnerHTML or an /iframe. No DOMPurify or scheme check is applied anywhere in the render path. +- **Exploit:** 1. Cause a stored chat message to contain ![x](http://attacker/track?cookieless-beacon) (e.g. through a controllable agent response or message content). 2. When any user views the conversation, their browser fetches the attacker URL, leaking IP/timing and acting as a forced outbound request. The same sink would become script-executing XSS if the image markdown is later rendered into raw HTML. +- **Fix:** Validate inlineImg[2] against an http(s) (and optional data:image/) allowlist before rendering; reject other schemes. Centralize a URL sanitizer for all user/model-derived src values and apply it to avatar src as well. + +### Unbounded findMany returns entire PublishedAgent table on public GET +- **File:** `/Users/baturalpguvenc/covenant/app/app/api/agents/register/route.ts:153-173` +- **Category:** Unbounded query / DoS · **Confidence:** medium +- **What:** GET /api/agents/register calls prisma.publishedAgent.findMany({ orderBy }) with no take/pagination and then builds an in-memory walletCounts map over every row. As the table grows (registration is unauthenticated and only soft-capped at 5 per wallet, with wallets being free to mint), this returns the full table on every request and does O(n) work, enabling memory/latency amplification. Several other API routes share this unbounded-findMany pattern (e.g. claims/leaderboard, elo/leaderboard, stats) and should be paginated as a class. +- **Exploit:** 1. Register many agents across many wallets (registration is anonymous). 2. Repeatedly GET /api/agents/register. 3. Each request serializes the entire table and recomputes counts, amplifying memory and DB load and degrading availability. +- **Fix:** Add take + cursor/offset pagination and a server-side cap; compute per-wallet counts with a groupBy aggregate instead of loading all rows. Apply the same bound to the other unbounded findMany routes. + +### Access-Control-Allow-Origin: * on /api/version leaks deployment topology cross-origin +- **File:** `app/app/api/version/route.ts:40-45` +- **Category:** Information disclosure / CORS · **Confidence:** medium +- **What:** GET /api/version sets `Access-Control-Allow-Origin: *` and returns VERCEL_URL (internal deploy hostname), VERCEL_REGION, VERCEL_ENV, VERCEL_GIT_COMMIT_REF (branch), VERCEL_GIT_REPO_SLUG and commit SHA. The wildcard CORS lets any malicious website read this build/topology metadata from a victim's browser. The endpoint is read-only and the values are low-sensitivity, but exact commit + branch + internal deploy URL + region are reconnaissance aids (e.g. pinning a known-vulnerable commit, finding the *.vercel.app preview host). This is the only place outside middleware's /api/openapi that emits wildcard CORS, and unlike openapi it discloses environment-derived internals. +- **Exploit:** From attacker.com JS: `fetch('https://app/api/version').then(r=>r.json()).then(...)` succeeds cross-origin (ACAO *), exfiltrating branch name, internal VERCEL_URL preview host, region, and commit to the attacker. +- **Fix:** Drop the `Access-Control-Allow-Origin: *` header on /api/version (it is not consumed cross-origin), or restrict it to a known origin allowlist. Consider removing branch/VERCEL_URL/repo-slug from the public body and keeping only a short commit hash. + +### Unauthenticated /api/metrics is open-by-default and exposes internal business + infra state +- **File:** `app/app/api/metrics/route.ts:58-62` +- **Category:** Information disclosure · **Confidence:** medium +- **What:** authorized() returns true when neither METRICS_SECRET nor CRON_SECRET is set (`if (!secret) return true; // open by default`). In any deployment that hasn't explicitly set one of those secrets, /api/metrics is fully public and emits per-table row counts, jobs/claims by status, total + settled USDC volume, accrued protocol fees, TVL, dispute counts and dispute rate, RPC slot, cache/query internals, and the deployed commit/region. This hands an attacker a live business dashboard (volume, fee revenue, dispute rate, user/job growth) and infra signals. db-stats/cache-stats/error-buffer are correctly admin-guarded via guardAdmin (fail-closed), so this open-by-default fallback is the inconsistent, weaker surface. +- **Exploit:** If METRICS_SECRET and CRON_SECRET are unset in the environment, `curl https://app/api/metrics` returns the full Prometheus dump (covenant_settlement_volume_usdc, covenant_fees_accrued_usdc, covenant_db_table_rows{...}, covenant_dispute_rate, commit/region) to anyone. +- **Fix:** Fail closed: require a secret rather than defaulting open (mirror admin-auth's `if (!secret) deny`). Use constant-time comparison (constantTimeEqual) instead of `===` on line 61 to avoid timing leakage of the secret. + +### /api/health returns raw dependency error messages (DB/RPC) to unauthenticated callers +- **File:** `app/app/api/health/route.ts:73-78,107-113,138-141` +- **Category:** Information disclosure · **Confidence:** medium +- **What:** GET /api/health is unauthenticated and always returns 200 with a JSON body. On failures it surfaces truncated raw error strings from Prisma/Postgres (up to 300 chars, line 77) and from the Solana RPC client (errDetail, 200 chars), plus the env-var presence list including the names ANTHROPIC_API_KEY and DATABASE_URL (lines 107-111), commit, and region. Raw driver error text can disclose hostnames, schema/relation names, driver versions, or connection details depending on the failure mode. The endpoint also reveals which required env vars are missing by name. +- **Exploit:** An attacker polls `curl https://app/api/health` (especially during an incident/cold start) and reads checks.database.detail / checks.rpc.detail, which can include Postgres/driver error text (host, relation, version) and checks.env.detail naming missing secrets — useful recon with no credentials. +- **Fix:** Return only coarse status booleans to unauthenticated callers; move detailed error strings behind guardAdmin (as db-stats already is) or log them server-side only. Do not echo env-var names in the public body. + +### Admin routes fall back to CRON_SECRET as the admin bearer — broad credential reuse +- **File:** `app/lib/admin-auth.ts:14-16,28-34 (used by admin/error-buffer, cache-stats, db-stats)` +- **Category:** Privilege boundary / credential reuse · **Confidence:** medium +- **What:** adminSecret() returns ADMIN_SECRET || CRON_SECRET. CRON_SECRET is the token Vercel Cron presents on scheduled job endpoints (e.g. /api/cron/*). Reusing it as the admin console secret means any component or log that legitimately sees the cron bearer can also read admin/db-stats, drain the error buffer, and clear caches. The admin routes themselves are correctly fail-closed (deny when no secret) and constant-time compared — this is about secret scope, not a bypass. +- **Exploit:** If CRON_SECRET leaks via a cron misconfiguration, a third-party scheduler, or request logs (it travels on every cron invocation), the holder gains full admin read access (db-stats exposes wallet volumes, table counts, postgres version) and can clear the error buffer to hide incident traces. The two roles should not share one secret. +- **Fix:** Require a dedicated ADMIN_SECRET and remove the CRON_SECRET fallback for admin routes (keep CRON_SECRET only for cron endpoints). Document that ADMIN_SECRET must be set or admin routes stay disabled. + +### GET/POST /api/keys signed message is replayable for ~the timestamp window (no per-request body binding on POST create) +- **File:** `app/app/api/keys/route.ts:49-107 (create) and 6-47 (list)` +- **Category:** Replay / authentication weakness · **Confidence:** medium +- **What:** The keys route is the one place that correctly binds wallet+ts into the expected message (cvn:keys:create::). However the message does not include a nonce or the request body, and verifyWalletSignature only enforces a 5-minute freshness window (MAX_TIMESTAMP_SKEW_MS) with no replay cache. A captured create/list signature can be replayed by anyone within that 5-minute window to mint another API key (POST) for the same wallet or re-list its keys (GET). +- **Exploit:** An on-path observer or a malicious frontend that captures the wallet's signed 'cvn:keys:create::' value can replay it within 5 minutes to create additional API keys bound to the victim's wallet (each key being an act-as-that-wallet credential elsewhere), since there is no single-use enforcement. +- **Fix:** Include a server-issued nonce (single-use, stored) in the signed message, or persist used (wallet,ts,signature) tuples within the freshness window to reject replays. Shorten the window for key creation. + +### battle/predict GET loads all predictions for a user-controlled battleId with no limit +- **File:** `app/app/api/battle/predict/route.ts:56` +- **Category:** unbounded-query · **Confidence:** medium +- **What:** GET /api/battle/predict?battleId=xxx runs `prisma.battlePrediction.findMany({ where: { battleId } })` with no `take`. It then returns every prediction row mapped into the response body (wallet, prediction, correct). battleId is fully attacker-controlled and the route is public/unauthenticated. The number of predictions per battle is bounded only by how many were inserted. +- **Exploit:** If an attacker can drive many prediction rows onto a single battleId (predictions are created by spectators; the id is a client-supplied `battle-` string), then `GET /api/battle/predict?battleId=` returns and serializes the entire set on every poll. Repeated polling of a hot battleId amplifies DB read + response-serialization cost. Lower impact than the stats routes because per-battle volume is naturally smaller, but it is still an unbounded user-controlled fan-out. +- **Fix:** Use `prisma.battlePrediction.groupBy`/`count` to compute alpha/omega tallies DB-side instead of pulling every row, and cap the `predictions` array returned to the client with a `take`. + +### In-memory rate-limit Map is per-serverless-instance and trivially bypassed under load +- **File:** `app/lib/rateLimit.ts:6` +- **Category:** rate-limit-bypass · **Confidence:** medium +- **What:** The synchronous `rateLimit()` / `store` Map limiter is per-process. On Vercel each warm container has its own Map, so the same caller hitting different containers gets a fresh window — the cap is effectively multiplied by the number of live instances. The file itself documents this and provides `rateLimitDurable` (Postgres-backed) for sensitive routes, but any route still calling the in-memory `rateLimit()` for an expensive operation is bypassable by simply sending requests in parallel so they fan out across instances. +- **Exploit:** For any endpoint guarded only by the in-memory `rateLimit()`, an attacker sends a burst of concurrent requests; Vercel load-balances them across N containers, and each container independently allows up to `limit` before tripping, yielding ~N×limit effective throughput. Combined with the spoofable-IP issue this removes the throttle on expensive work. +- **Fix:** Audit all call sites and ensure every financial / LLM / external-API route uses `rateLimitDurable` (already done for most). Keep the in-memory limiter only for non-sensitive, best-effort throttling, and document that it must never be the sole guard on an expensive route. + +### Admin data-dump endpoint runs four fully unbounded findMany (no take) +- **File:** `app/app/api/admin/route.ts:15` +- **Category:** unbounded-query · **Confidence:** high +- **What:** GET /api/admin loads the ENTIRE Job, Profile, Reputation, and Submission tables in parallel with `findMany` and no `take`/pagination, then serializes all of it into one JSON response. The route is protected by `guardAdmin`, so it is not anonymously reachable, but it is still an unbounded full-table materialization that scales with DB size and can OOM the function or time out once tables are large. +- **Exploit:** A compromised or careless admin token hitting GET /api/admin on a populated production DB pulls every row of four tables into memory and serializes them in a single response, spiking memory and potentially crashing the instance. Because Submission includes `outputText` (full deliverable bodies), the payload can be very large. Lower severity due to the admin auth gate. +- **Fix:** Add bounded `take` + cursor/offset pagination to all four queries, and exclude large columns (e.g. Submission.outputText) from the dump unless explicitly requested. + +### Dispute bond uses non-atomic Float math with a tolerance window; tiny escrows allow under-bonding and rounding abuse +- **File:** `app/app/api/jobs/[id]/dispute/route.ts:99-110` +- **Category:** decimal-abuse · **Confidence:** low +- **What:** minBond is computed as `(job.amount * 1000) / 10000` in JS floating point (line 99) and the on-chain bond match uses `Math.abs(onchain.dispute.bond - providedBond) > 1e-6` (line 153). job.amount is a Float (USDC human units). For very small or fractional job.amount the 10% bond can round to a value whose float representation differs from the on-chain atomic bond by more than 1e-6 in edge cases, or be undercut by submitting a providedBond just under minBond that still passes due to float error. More importantly the DEFAULT_MIN_BOND_ABSOLUTE of 1 USDC means disputes on sub-10-USDC jobs are cheap relative to the value they block, and the bond is the only economic deterrent to griefing. +- **Exploit:** Post/locate a low-value job; raise a dispute with the minimum 1 USDC bond to lock a delivered job worth nearly as much, or exploit float rounding so providedBond < trueMinBond passes the `<` comparison. Combined with the no-pda griefing finding, the bond may not be escrowed at all. +- **Fix:** Do all bond math in integer atomic units (USDC * 1e6) and compare exactly against the on-chain bond. Raise the minimum absolute bond and make it scale so griefing is never cheaper than the blocked payout. Reject providedBond unless it exactly equals the on-chain bond. + +### Split dispute resolution lets a malicious arbitrator-quorum overpay taker up to full escrow with no poster signature +- **File:** `app/app/api/disputes/[id]/resolve/route.ts:95-108,179-184` +- **Category:** claim-economic-exploit · **Confidence:** low +- **What:** For a Split resolution, takerAmount is taken from the body and only bounded by `takerAmount >= 0` and `takerAmount <= dispute.job.amount`. The actual fund movement is supposed to be enforced on-chain (the route verifies the resolve_dispute tx and JobEscrow status==Resolved), but the DB mirror records payoutToTaker and credits taker reputation/totalEarned from the body value, which is only loosely tied to the on-chain split. If a claim was Bought before the dispute, resolveClaimBeneficiary (credit-server.ts:316) routes finalize proceeds to the buyer — but a Resolved job never reaches finalize, so a buyer who factored a receivable that then loses/splits a dispute silently eats the loss while the DB may still show the claim as Bought (no Cancelled/loss state written on resolve). This is the 'buy a claim then force a favorable dispute' adjacency: the seller (taker) can collude with the poster to split, getting paid on-chain while the buyer's factored claim is stranded. +- **Exploit:** Taker lists and sells a claim (buyer pays seller). Taker then colludes with poster (or the poster is the same Sybil) to raise a dispute and have the arbitrator quorum resolve Split/FavorPoster. Escrow is refunded/split away from the buyer; the buyer's purchase price is lost and the ClaimListing is never marked Settled/Cancelled, leaving stale Bought state. +- **Fix:** On dispute resolution, if a ClaimListing for the job is Bought, explicitly transition it to a Cancelled/Defaulted state and record the buyer's loss; surface this in the marketplace. Validate that off-chain payoutToTaker exactly matches the on-chain split before crediting reputation/totalEarned. + +### Revenue amount derived by float division can drift from the verified atomic amount +- **File:** `app/app/api/hosted-agents/[id]/chat/route.ts:177` +- **Category:** rounding-decimal-confusion · **Confidence:** high +- **What:** paidAmountUsdc is computed as Number(verification.amountAtomic ?? '0') / 1_000_000 — a JS float divide of a u64 atomic value. This float is then stored as AgentRevenue.amount and incremented into HostedAgent.totalRevenue. reconcileRevenue (x402-payments.ts:194) later re-multiplies AgentRevenue.amount by 1e6 and Math.round()s it to compare against the stored atomic X402Payment.amountAtomic. For payments whose atomic value is not exactly representable as a 6-decimal float (or for large sums), the round-trip atomic->float->atomic can drift by 1 atomic unit, making reconciliation report false drift; and totalRevenue accumulates float error over many payments. +- **Exploit:** Not a direct theft path: an overpaying caller (amountAtomic larger than quoted, allowed because verifyTransfer only enforces >= required) has the exact overpaid atomic amount stored in X402Payment.amountAtomic but a lossy float in AgentRevenue.amount. Over many such payments the dashboard totalRevenue and reconcileAgentRevenue drift apart, and an attacker could deliberately submit awkward atomic amounts to make reconciliation perpetually report non-zero drift, masking a real discrepancy. +- **Fix:** Carry the atomic amount end-to-end. Store AgentRevenue in atomic units (or a Decimal column) and only convert to human units at display time; have reconcileRevenue compare atomic-to-atomic without a float round-trip. Use lib/token-math atomicToUsdc/usdcToAtomic string-based conversion instead of Number()/1_000_000. + +### x402 payer attribution uses 'largest debit' heuristic, allowing payer spoofing in the consumed-payment record +- **File:** `app/lib/x402-server.ts:268-307` +- **Category:** payment-attribution · **Confidence:** low +- **What:** summarizeCreditsToRecipient picks the payer as the owner with the single largest debit of the paid mint across the whole transaction (biggestDebit). In a transaction that moves the required USDC to the agent wallet but also contains an unrelated larger USDC debit from a different account (a batched/CPI tx the attacker crafts), the recorded payer is the unrelated big-debit owner, not the actual funder of the agent payment. The payer value is persisted as X402Payment.payer and used as the AgentRevenue.userWallet fallback and in logs. +- **Exploit:** An attacker constructs one transaction that (a) transfers the required amount of the correct USDC mint to the agent wallet and (b) includes a larger USDC transfer between two attacker-controlled accounts. verifyTransfer still passes (recipient got >= required of the right mint), but the recorded payer/userWallet is attributed to the wrong account, corrupting revenue attribution and any per-payer rate/abuse accounting keyed on payer. +- **Fix:** Attribute the payer to the account whose debit of the paid mint is matched to the credit to the recipient (e.g. the source in the specific transfer instruction to payTo), not the global largest debit. Parse the actual transfer/transferChecked instruction sourced to the recipient ATA, or at minimum scope biggestDebit to debits that plausibly fund the recipient credit. + +### init_config accepts unbounded/unsafe parameters (no upper bound on bond bps, no max_challenge_period ceiling, no min_bond sanity) +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/init_config.rs:32-49` +- **Category:** missing validation · **Confidence:** medium +- **What:** init_config validates only threshold (2..=ARBITRATOR_COUNT) and `min_challenge_period > 0 && <= max_challenge_period`. There is no upper bound on min_bond_bps (can exceed 10_000 = 100%), no ceiling on max_challenge_period (can be set so large that funds are effectively locked for years), and update_arbitrators (update_arbitrators.rs:21-37) lets the admin rotate the entire arbitrator set and threshold with no timelock and no requirement that arbitrators be distinct. Duplicate arbitrator pubkeys are silently accepted; combined with resolve_dispute's `position()` lookup this does not bypass the 2-of-3 count (a duplicate maps to one index) but it shrinks the effective arbitrator set and is a foot-gun. +- **Exploit:** A malicious/compromised admin sets min_bond_bps far above 10_000 (raise_dispute then demands a bond larger than the escrow, making disputes economically impossible and effectively disabling poster protection), or sets max_challenge_period to an enormous value so a job created with the max challenge period locks escrow indefinitely. Admin can also rotate arbitrators to a single key duplicated across slots, reducing the security of the 2-of-3 to effectively 1 real signer with 2 distinct keys it controls. +- **Fix:** Bound min_bond_bps <= 10_000, cap max_challenge_period to a protocol maximum, and in init_config/update_arbitrators reject duplicate arbitrator pubkeys and Pubkey::default() slots. Consider a timelock or multisig on admin parameter changes. + +### accept_job allows the poster to accept their own job as taker +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/accept_job.rs:26-39` +- **Category:** self-dealing / authority confusion · **Confidence:** medium +- **What:** accept_job sets job.taker = signer with no check that taker != poster. The poster can therefore become the taker of their own escrow. Downstream this lets a single party occupy both sides of the escrow (poster and taker), which interacts with the claim/reputation system: the poster-as-taker can deliver, list a claim, and finalize, inflating their own AgentReputation (jobs_completed/total_earned) using their own funds, and muddying the dispute model (poster disputing a job they also delivered). +- **Exploit:** 1. Attacker creates a job funding `amount` of their own tokens. 2. Same wallet calls accept_job, then submit_work, then after the challenge window finalize_payment routes `amount` back to themselves. 3. Their AgentReputation is incremented (jobs_completed += 1, total_earned += amount) at zero real cost (minus rent/fees), enabling reputation farming that other parts of the system or off-chain consumers may trust. +- **Fix:** Add `require!(ctx.accounts.taker.key() != job.poster, CovError::Unauthorized)` in accept_job. + +### Bond token account in resolve_dispute is not mint-validated +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/resolve_dispute.rs:51-56` +- **Category:** missing mint constraint · **Confidence:** medium +- **What:** The bond_token_account is accepted via `seeds = [b"bond", job_escrow.key()], bump` with no mint constraint, unlike escrow_token_account which checks `mint == job_escrow.token_mint`. In practice the canonical bond PDA was created in raise_dispute with token::mint = token_mint (validated there against job_escrow.token_mint, raise_dispute.rs:44-47), so the on-chain account is correct. The missing redundant check is defense-in-depth: if the bond PDA derivation or the escrow_token_account constraints were ever loosened, a mismatched bond mint could be transferred to the taker/poster. +- **Exploit:** Not independently exploitable today because raise_dispute is the only creator of the bond PDA and binds the correct mint. Reported as a hardening gap: the resolve path transfers bond to beneficiaries (resolve_dispute.rs:256-277) without re-asserting the mint equals the escrow mint, so it relies entirely on the raise_dispute invariant holding forever. +- **Fix:** Add `constraint = bond_token_account.mint == job_escrow.token_mint @ CovError::MintMismatch` to the bond_token_account account in ResolveDispute. + + +## INFO + +### Raw SQL queries reviewed — no SQL injection found (parameterized / static) +- **File:** `/Users/baturalpguvenc/covenant/app/app/api/claims/route.ts:149; /Users/baturalpguvenc/covenant/app/app/api/settlement/stats/route.ts:226-269; /Users/baturalpguvenc/covenant/app/lib/prisma.ts:190` +- **Category:** SQL injection (negative finding) · **Confidence:** high +- **What:** All raw query usage was audited. claims/route.ts uses $queryRawUnsafe but with a fully static string literal (no interpolation). settlement/stats uses $queryRaw with Prisma.sql tagged templates and no user-interpolated values. lib/prisma.ts $executeRawUnsafe and lib/db-quota / lib/rateLimit raw queries operate on hardcoded migration DDL or Prisma.sql-parameterized values (the only dynamic value, a Date, is bound as a parameter). No user input flows into a raw SQL string via interpolation, so no SQL injection is present in the audited surface. JSON-path filters and a2a task lookups use Prisma's safe query builder. +- **Exploit:** N/A — no injectable raw query path. Documented so the negative result is explicit: $queryRawUnsafe at claims/route.ts:149 is safe only because its argument is a constant; if a future edit interpolates a variable there it becomes injectable, so flag it for a lint guard. +- **Fix:** Replace the lone $queryRawUnsafe at claims/route.ts:149 with $queryRaw + Prisma.sql to remove the foot-gun, and add a lint rule forbidding template-literal interpolation into $queryRawUnsafe/$executeRawUnsafe. + +### escrow/build advertises demoMode unconditionally with no onchain guard (relies entirely on /api/jobs to fail closed) +- **File:** `app/app/api/escrow/build/route.ts:17-28` +- **Category:** settlement-mode-bypass · **Confidence:** high +- **What:** POST /api/escrow/build always returns {demoMode:true}, instructing the client to skip signing and post a record-only job. It has no blockSimulatedRouteIfOnchain / assertSimulatedAllowed guard of its own. The only thing preventing a fake settlement under SETTLEMENT_MODE=onchain is that the downstream /api/jobs route guards itself (jobs/route.ts:135) and that fakes-gate.ts only scans for sendMarkerTransaction. The demoMode contract itself is not gated here, so the fail-closed guarantee depends entirely on every current and future consumer of this signal also being guarded. This is a defense-in-depth gap rather than a live bypass today. +- **Exploit:** No direct exploit while /api/jobs remains guarded: in onchain mode /api/jobs returns 501 for the demoMode branch. The risk is regression — a new route that trusts escrow/build's demoMode flag (or a relaxed guard on /api/jobs) would silently re-enable record-only 'settled' jobs in onchain mode, because the instruction to fake originates from this unguarded endpoint. +- **Fix:** Have escrow/build itself return blockSimulatedRouteIfOnchain('POST /api/escrow/build') (501) when isOnchainMode(), so the demoMode instruction can never be emitted in onchain mode regardless of what downstream consumers do. + +### finalize_payment / resolve_dispute manually deserialize claim_listing without asserting its stored bump or seller identity +- **File:** `/Users/baturalpguvenc/covenant/programs/covenant/src/instructions/finalize_payment.rs:111-130 (and resolve_dispute.rs:203-215)` +- **Category:** unchecked account data · **Confidence:** high +- **What:** The claim_listing is an UncheckedAccount validated by Anchor's seeds=[b"claim", job_escrow.key()] + bump on the address, then manually deserialized. The handler checks owner == program_id and listing.job == job_key before trusting listing.buyer for payout routing. This is correct, because the address is PDA-pinned to the job and a Bought listing can only have been created/bought through list_claim/buy_claim for this exact job. No exploit found: an attacker cannot substitute a different listing (address is fixed by seeds) nor forge listing.buyer (set only in buy_claim after a real token transfer). Reported only to confirm the routing was reviewed and is sound. +- **Exploit:** No exploit. The PDA address binding plus owner==program_id plus listing.job==job_key prevents substitution or forgery of the payout destination. The seller-crank-bypass vector described in the code comments is genuinely closed because the claim_listing is a mandatory, deterministically-addressed account that the crank cannot omit or swap. +- **Fix:** No change required. Optionally assert listing.bump for completeness, but it is not security-relevant given the seeds+bump address check.