diff --git a/.env.example b/.env.example index 3cbd6bb..909f42f 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,12 @@ ORACLE_PAYMENT_ADDRESS=GD... # URL of the x402 facilitator (default: https://facilitator.stellar.org) X402_FACILITATOR_URL=https://facilitator.stellar.org +# --- Bazaar catalog integrity --- +# Catalog listings arrive from clients in the payment payload, so writes are +# rate limited per payTo. A rejected listing never fails the payment. +BAZAAR_CATALOG_WRITES_PER_MIN=10 +BAZAAR_CATALOG_WRITES_PER_DAY=200 + # --- Oracle Relay Example --- # Lens API base URL consumed by the relay example. ORACLE_RELAY_API_URL=http://localhost:3002 diff --git a/docs/x402/bazaar-catalog-integrity.md b/docs/x402/bazaar-catalog-integrity.md new file mode 100644 index 0000000..7c3d4a2 --- /dev/null +++ b/docs/x402/bazaar-catalog-integrity.md @@ -0,0 +1,102 @@ +# Bazaar catalog integrity + +The Bazaar catalog is written from the payment payload. Clients echo the +resource block into it, so the facilitator is a trust boundary: **anyone who +can pay can attempt to write to the index.** This note describes what is +enforced at that boundary, and what an attacker who is willing to pay can still +attempt. + +## The two doors into the catalog + +| Path | Trust | Validation | +|---|---|---| +| `registerBazaarResource(input)` | Trusted — Lens listing its own endpoints | None; the caller is us | +| `submitCatalogListing(input, authority)` | Untrusted — a listing carried by a client's payment | Everything below | + +`authority` is `{ payTo, network }` taken from the payment requirements the +payer signed. It is the one fact about a listing that cannot be forged, and +every ownership decision is anchored to it. + +## What is enforced + +**Seller identity.** Every `accepts[].payTo` must equal the payment's own +`payTo`, and the listing's network must equal the payment's network. A listing +can therefore only ever speak for the seller that paid to publish it. + +**First claim wins.** A resource identity — `(network, url, httpMethod)` for +HTTP, `(network, url, toolName)` for MCP — belongs to the `payTo` that +registered it first. Its owner can update it forever; anyone else is refused, +which is what stops a payer from repointing another seller's entry at their own +endpoint or pricing. + +**`routeTemplate`, decoded before it is checked.** The template is +percent-decoded to a fixed point *first*, and the traversal checks run on the +decoded form. `%2e%2e%2f` is `../`; a validator that inspects the raw string and +decodes later waves it straight through, and one that decodes only once is +defeated by `%252e%252e%252f`. A value still changing after four decode passes +is dropped rather than decoded further. The decoded form is what gets stored, so +the value served is the value that was validated. Beyond traversal, a template +must be a path (no scheme, no `//`, no query or fragment, no backslash, no +control characters), must use `:param` names matching +`[A-Za-z_][A-Za-z0-9_]*` without repeats, and must structurally describe the +path of `resource.url` — otherwise a listing could consolidate itself under +another seller's route family. + +**Field limits.** Length, type and character-class limits on every stored +field (see `CATALOG_LIMITS` in `src/bazaar/validation.ts`), a byte ceiling on +`bazaar.info` and `bazaar.schema`, `https` only for `resource.url` and +`iconUrl` with no embedded credentials, atomic-unit amounts, and a bounded +`maxTimeoutSeconds`. An unbounded description is both a storage problem and a +search-ranking problem. + +**Rate limiting per `payTo`.** `BAZAAR_CATALOG_WRITES_PER_MIN` (default 10) and +`BAZAAR_CATALOG_WRITES_PER_DAY` (default 200). The attempt is counted before +the decision, so a rejected write still costs its slot. If Redis is +unreachable the limiter fails **closed** — at a trust boundary, "I cannot tell +whether this payer is flooding" is not a reason to accept the write. The +payment is unaffected either way. + +**Untrusted at read time too.** Control characters are stripped when a row is +served, not only when it is written: rows predate this validation, and a value +that is harmless inside JSON can still be harmful to whatever renders it. + +## Soft drop + +Invalid metadata never fails the payment. The payment is legitimate; only the +listing is bad. `submitCatalogListing` returns +`{ accepted, drops }` and never throws, and `toExtensionResponses(drops)` +builds the `EXTENSION-RESPONSES` body so the seller is told which field was +rejected and why: + +```json +{ + "bazaar": { + "catalog": { + "accepted": false, + "drops": [ + { + "field": "extensions.bazaar.routeTemplate", + "code": "path_traversal", + "message": "routeTemplate contains a path traversal segment." + } + ] + } + } +} +``` + +`code` is stable and machine-readable — an agent branches on it rather than +parsing `message`. `message` never echoes the offending value back. + +## What an attacker who can pay can still attempt + +- **Squatting an unclaimed identity.** First claim wins, so a payer can + register a URL they do not operate, as long as nobody registered it first. + What they cannot do is claim someone else's `payTo`, so the listing is + attributable and revocable. Binding a listing to proof of control over its + domain would close this, and is not in this change. +- **Paying for their slots.** Rate limits bound the flood per `payTo`; funding + many addresses buys proportionally more slots, at the cost of a payment per + write. +- **Truthful but useless metadata.** Nothing here judges whether a description + is accurate. That is a ranking problem, and belongs to search (#129). diff --git a/src/__tests__/bazaarIntegrity.test.ts b/src/__tests__/bazaarIntegrity.test.ts new file mode 100644 index 0000000..89bb038 --- /dev/null +++ b/src/__tests__/bazaarIntegrity.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockFindFirst, mockUpsert, mockFindMany, mockCount, mockCheckRate } = vi.hoisted(() => ({ + mockFindFirst: vi.fn(), + mockUpsert: vi.fn(), + mockFindMany: vi.fn(), + mockCount: vi.fn(), + mockCheckRate: vi.fn(), +})) + +vi.mock('../db', () => ({ + prisma: { + bazaarResource: { + findFirst: mockFindFirst, + upsert: mockUpsert, + findMany: mockFindMany, + count: mockCount, + deleteMany: vi.fn(), + }, + }, +})) + +vi.mock('../bazaar/rateLimit', () => ({ + checkCatalogWriteRate: mockCheckRate, +})) + +import { submitCatalogListing, queryDiscoveryResources } from '../bazaar/catalog' +import { + CATALOG_LIMITS, + fullyPercentDecode, + stripControlChars, + toExtensionResponses, + validateListing, + validateRouteTemplate, + type CatalogAuthority, +} from '../bazaar/validation' +import type { RegisterBazaarResourceInput } from '../bazaar/types' + +/** Obviously synthetic, but shaped like a real strkey so the address check passes. */ +const SELLER = 'G' + 'A'.repeat(55) +const ATTACKER = 'G' + 'B'.repeat(55) + +const authority: CatalogAuthority = { payTo: SELLER, network: 'mainnet' } + +function listing(overrides: Partial = {}): RegisterBazaarResourceInput { + return { + type: 'http', + network: 'mainnet', + resource: { url: 'https://lens.example/price/XLMUSDC' }, + accepts: [ + { + scheme: 'exact', + network: 'stellar:pubnet', + amount: '1000000', + asset: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + payTo: SELLER, + maxTimeoutSeconds: 60, + }, + ], + bazaar: { + info: { input: { type: 'http', method: 'GET' } }, + schema: { type: 'object' }, + }, + ...overrides, + } +} + +function codes(drops: { code: string }[]): string[] { + return drops.map(d => d.code) +} + +beforeEach(() => { + mockFindFirst.mockReset().mockResolvedValue(null) + mockUpsert.mockReset().mockResolvedValue({}) + mockFindMany.mockReset().mockResolvedValue([]) + mockCount.mockReset().mockResolvedValue(0) + mockCheckRate.mockReset().mockResolvedValue({ allowed: true }) +}) + +describe('fullyPercentDecode', () => { + it('decodes to a fixed point rather than once', () => { + expect(fullyPercentDecode('%252e%252e%252f')).toEqual({ ok: true, decoded: '../' }) + }) + + it('leaves an already-decoded value alone', () => { + expect(fullyPercentDecode('/price/:pairId')).toEqual({ ok: true, decoded: '/price/:pairId' }) + }) + + it('reports malformed encoding instead of throwing', () => { + expect(fullyPercentDecode('%zz')).toEqual({ ok: false, code: 'malformed_encoding' }) + }) + + it('refuses a value still changing after the decode budget', () => { + let value = '../' + for (let i = 0; i < CATALOG_LIMITS.percentDecodePasses + 1; i++) { + value = value.replace(/%/g, '%25').replace(/\./g, '%2e').replace(/\//g, '%2f') + } + expect(fullyPercentDecode(value)).toEqual({ ok: false, code: 'excessive_encoding' }) + }) +}) + +describe('validateRouteTemplate', () => { + const url = 'https://lens.example/price/XLMUSDC' + + it('accepts a canonical :param template that describes the resource path', () => { + const result = validateRouteTemplate('/price/:pairId', url) + expect(result).toEqual({ ok: true, value: '/price/:pairId' }) + }) + + it('rejects %2e%2e%2f — percent-decoded BEFORE the traversal check', () => { + const result = validateRouteTemplate('/price/%2e%2e%2fadmin', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') + }) + + it('rejects a plain ../ traversal', () => { + const result = validateRouteTemplate('/price/../admin', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') + }) + + it('rejects double-encoded traversal', () => { + const result = validateRouteTemplate('/price/%252e%252e%252fadmin', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('path_traversal') + }) + + it('rejects a backslash separator', () => { + const result = validateRouteTemplate('/price\\..\\admin', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('backslash') + }) + + it('rejects an absolute URL and a protocol-relative one', () => { + expect(validateRouteTemplate('https://evil.example/price/:pairId', url).ok).toBe(false) + expect(validateRouteTemplate('//evil.example/price', url).ok).toBe(false) + }) + + it('rejects a query string or fragment', () => { + expect(validateRouteTemplate('/price/:pairId?admin=1', url).ok).toBe(false) + expect(validateRouteTemplate('/price/:pairId#x', url).ok).toBe(false) + }) + + it('rejects a NUL byte', () => { + const result = validateRouteTemplate('/price/%00', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('control_characters') + }) + + it('rejects a template that does not describe the resource path', () => { + const result = validateRouteTemplate('/admin/:id', url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('template_url_mismatch') + }) + + it('rejects a malformed parameter name and a repeated one', () => { + expect(validateRouteTemplate('/price/:1bad', url).ok).toBe(false) + expect(validateRouteTemplate('/:pairId/:pairId', 'https://lens.example/a/b').ok).toBe(false) + }) + + it('rejects a template past the length limit', () => { + const long = '/' + 'a'.repeat(CATALOG_LIMITS.routeTemplate + 1) + const result = validateRouteTemplate(long, url) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('too_long') + }) +}) + +describe('validateListing — seller identity', () => { + it('accepts a listing whose accepts[] matches the payment payTo', () => { + const result = validateListing(listing(), authority) + expect(result.ok).toBe(true) + }) + + it('refuses a listing that claims another seller payTo', () => { + const forged = listing({ + accepts: [{ ...listing().accepts[0], payTo: ATTACKER }], + }) + const result = validateListing(forged, authority) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('pay_to_mismatch') + }) + + it('refuses a listing registered against a different network than the payment', () => { + const result = validateListing(listing({ network: 'testnet' }), authority) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('network_mismatch') + }) + + it('refuses accepts[] whose CAIP-2 network contradicts the payment', () => { + const mismatched = listing({ + accepts: [{ ...listing().accepts[0], network: 'stellar:testnet' }], + }) + const result = validateListing(mismatched, authority) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('network_mismatch') + }) + + it('refuses a payTo that is not a Stellar address, even when it matches the payment', () => { + const result = validateListing( + listing({ accepts: [{ ...listing().accepts[0], payTo: 'not-an-address' }] }), + { payTo: 'not-an-address', network: 'mainnet' }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('invalid_address') + }) +}) + +describe('validateListing — field limits', () => { + it('refuses a description past the limit', () => { + const result = validateListing( + listing({ resource: { url: 'https://lens.example/price/XLMUSDC', description: 'x'.repeat(CATALOG_LIMITS.description + 1) } }), + authority, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(codes(result.drops)).toContain('too_long') + }) + + it('refuses more tags than the limit, and a tag with illegal characters', () => { + const tooMany = validateListing( + listing({ + resource: { + url: 'https://lens.example/price/XLMUSDC', + tags: Array.from({ length: CATALOG_LIMITS.tags + 1 }, (_, i) => `tag${i}`), + }, + }), + authority, + ) + expect(tooMany.ok).toBe(false) + + const illegal = validateListing( + listing({ resource: { url: 'https://lens.example/price/XLMUSDC', tags: ['