Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions __tests__/unit/api/v1-pay-l402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ jest.mock('@/domain/payments/l402', () => ({
jest.mock('@/lib/supabase/public', () => ({ createPublicClient: () => ({ _kind: 'public' }) }));
jest.mock('@/lib/rate-limit', () => ({
rateLimitWriteAsync: jest.fn().mockResolvedValue({ success: true }),
rateLimitPaymentRecipient: jest.fn().mockResolvedValue({ success: true }),
retryAfterSeconds: () => 1,
}));

import { GET } from '@/app/api/v1/pay/[entity_type]/[entity_id]/route';
import { rateLimitPaymentRecipient } from '@/lib/rate-limit';

const ENTITY_ID = '11111111-2222-3333-4444-555555555555';
const params = Promise.resolve({ entity_type: 'product', entity_id: ENTITY_ID });
Expand Down Expand Up @@ -225,3 +227,26 @@ describe('GET /api/v1/pay/{type}/{id}', () => {
expect(body.error.message).toContain('has not connected a Bitcoin wallet');
});
});

/**
* Finding 1 of bitbaum/orangecat#563: every challenge mints a REAL invoice
* through the recipient's own LNURL/NWC relay. A per-IP limit does not protect
* the seller from an attacker who rotates addresses — the victim's wallet
* provider is what gets hammered, and it is the victim who gets banned.
*/
describe('per-recipient invoice-spam limit', () => {
beforeEach(() => {
(rateLimitPaymentRecipient as jest.Mock).mockResolvedValue({ success: true });
});

it('keys the limit on the recipient, not only the caller', async () => {
await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID), { params });
expect(rateLimitPaymentRecipient).toHaveBeenCalledWith('product', ENTITY_ID);
});

it('refuses with 429 when that recipient is already being hammered', async () => {
(rateLimitPaymentRecipient as jest.Mock).mockResolvedValue({ success: false });
const res = await GET(makeRequest('https://x.test/api/v1/pay/product/' + ENTITY_ID), { params });
expect(res.status).toBe(429);
});
});
1 change: 1 addition & 0 deletions __tests__/unit/api/v1-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jest.mock('@/domain/payments', () => ({

jest.mock('@/lib/rate-limit', () => ({
rateLimitWriteAsync: jest.fn().mockResolvedValue({ success: true }),
rateLimitPaymentRecipient: jest.fn().mockResolvedValue({ success: true }),
rateLimitIntegrationKeyWrite: jest.fn().mockResolvedValue({ success: true }),
rateLimitIntegrationKeyRead: jest.fn().mockResolvedValue({ success: true }),
retryAfterSeconds: jest.fn().mockReturnValue(30),
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/v1/pay/[entity_type]/[entity_id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createL402Challenge, verifyL402Payment } from '@/domain/payments/l402';
import { parseL402Authorization } from '@/domain/payments/l402-codec';
import { publicSupportCreateSchema } from '@/lib/validation/finance';
import { createPublicClient } from '@/lib/supabase/public';
import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit';
import { rateLimitPaymentRecipient, rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit';
import { logger } from '@/utils/logger';
import { clientIpKey } from '@/lib/client-ip';

Expand Down Expand Up @@ -86,6 +86,18 @@ export async function GET(
);
}

// Per-IP is not enough here. Every challenge mints a REAL invoice through the
// recipient's own LNURL/NWC relay, so an attacker rotating IPs can still get
// a seller rate-limited or banned by their wallet provider. This bounds the
// damage to one recipient however many addresses it arrives from.
const recipientLimit = await rateLimitPaymentRecipient(entity_type, entity_id);
if (!recipientLimit.success) {
return apiRateLimited(
'This page is receiving too many payment requests right now. Try again shortly.',
retryAfterSeconds(recipientLimit)
);
}

const url = new URL(request.url);
const parsed = publicSupportCreateSchema.safeParse({
entity_type,
Expand Down
17 changes: 16 additions & 1 deletion src/app/api/v1/payments/public/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { initiatePublicSupport } from '@/domain/payments';
import { publicSupportCreateSchema } from '@/lib/validation/finance';
import { createPublicClient } from '@/lib/supabase/public';
import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit';
import { rateLimitPaymentRecipient, rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit';
import { logger } from '@/utils/logger';
import { clientIpKey } from '@/lib/client-ip';

Expand All @@ -30,6 +30,21 @@ export async function POST(request: Request) {
return apiBadRequest('Invalid support request', parsed.error.errors);
}

// After parsing, because the recipient is in the body — and BEFORE
// initiatePublicSupport, which is the call that mints a real invoice
// through the recipient's wallet. Per-IP alone leaves a seller exposed to
// an attacker who rotates addresses.
const recipientLimit = await rateLimitPaymentRecipient(
parsed.data.entity_type,
parsed.data.entity_id
);
if (!recipientLimit.success) {
return apiRateLimited(
'This page is receiving too many payment requests right now. Please try again shortly.',
retryAfterSeconds(recipientLimit)
);
}

const result = await initiatePublicSupport(createPublicClient(), parsed.data);
return apiSuccess(result, { status: 201, cache: 'NONE' });
} catch (error) {
Expand Down
28 changes: 28 additions & 0 deletions src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,34 @@ export async function rateLimitTipRecipient(username: string): Promise<RateLimit
return fallbackTipRecipientLimiter.check(key);
}

/**
* Rate limit invoice generation PER RECIPIENT for an entity-addressed payment.
*
* The per-IP limiter answers "is one caller hammering us". This answers the
* question that actually protects a seller: "is one RECIPIENT's wallet being
* hammered", however many IPs it arrives from. Both `GET /api/v1/pay/...` and
* `POST /api/v1/payments/public` mint a REAL Lightning invoice through the
* recipient's own LNURL/NWC relay on every request, so unbounded calls are how
* an attacker gets a seller rate-limited or banned by their wallet provider and
* litters their queue with orphan intents. bitbaum/orangecat#563 finding 1.
*
* Shares the tip limiter's budget and window deliberately — it is the same
* victim's wallet being protected either way.
*
* Residual, stated rather than hidden: a profile reachable BOTH by username
* (tips/lnurlp) and by entity id (here) has two buckets, so a determined
* attacker splitting across both paths gets twice the budget. Collapsing them
* would mean a username→entity lookup on the rate-limit path, i.e. a database
* round trip before we have decided whether to serve the request at all. Twice
* a bounded number is still bounded; unbounded was the bug.
*/
export async function rateLimitPaymentRecipient(
entityType: string,
entityId: string
): Promise<RateLimitResult> {
return rateLimitTipRecipient(`${entityType}:${entityId}`);
}

/**
* Rate limit public Ask-Cat / feedback submissions per IP.
* 8 per 5 minutes — each one is a platform LLM call from a possibly anonymous
Expand Down
Loading