Skip to content

Commit 7daaa21

Browse files
Adeyemi-cmdkrkshw
andcommitted
fix: wire per-wallet throttling and add TOCTOU concurrency/cleanup proofs
Per-wallet throttling was DI-broken: AuthWalletThrottlerGuard and WalletThrottlerGuard extended ThrottlerGuard but were used via @UseGuards (instantiated with `new` without ThrottlerStorageService), so the 5 req/60s limit on POST /auth/verify was never enforced. Re-implement both guards as DI-free CanActivate with static in-memory sliding window (limit 5/10, ttl 60s), wallet-prefixed tracker with IP fallback, throwing ThrottlerException → 429. Keeps legacy constructor signature for existing unit tests. Add missing NonceCleanupService unit coverage (hourly cron deletes expires_at < now-1h, including burned rows, idempotent, error swallow). Add e2e proof for audit gaps: parallel double-verify yields exactly one 200 (atomic UPDATE ... WHERE used_at IS NULL via InMemoryStore), burn-on-failure, and per-wallet 429 on 6th request with isolation. Uses jest.requireActual("stellar-sdk") to bypass test/__mocks__ deterministic mock. Co-authored-by: Muse Spark <muse-spark@opencode.ai>
1 parent 674dca2 commit 7daaa21

6 files changed

Lines changed: 408 additions & 11 deletions

File tree

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
1-
import { Injectable } from '@nestjs/common';
2-
import { ThrottlerGuard } from '@nestjs/throttler';
1+
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2+
import { ThrottlerException } from '@nestjs/throttler';
33

44
/**
5-
* ThrottlerGuard variant for POST /auth/verify that keys rate limits on the
5+
* Throttler guard variant for POST /auth/verify that keys rate limits on the
66
* wallet address supplied in the request body (unauthenticated) or on the
77
* authenticated wallet (if present). Prefers body wallet because verify is
88
* unauthenticated — the wallet is not yet in req.user.
99
*
10-
* Falls back to the default IP-based tracker when no wallet is present so
10+
* Falls back to IP-based tracking when no wallet is present so
1111
* anonymous/probe traffic is still bounded per IP.
1212
*
1313
* Used alongside the global IP-based ThrottlerGuard so POST /auth/verify is
1414
* bounded per wallet AND per IP — preventing brute-force of the SEP-0043
1515
* fallback space at network speed and limiting stolen-nonce replay attempts.
16+
*
17+
* Implements its own in-memory sliding window so it works with
18+
* `@UseGuards(AuthWalletThrottlerGuard)` without requiring Nest DI for
19+
* ThrottlerGuard's storage service (which is not injected when a guard is
20+
* instantiated via `@UseGuards`). Mirrors the semantics of the legacy
21+
* `ThrottlerGuard extends` version but is DI-free and therefore testable
22+
* with a plain `new` and usable in e2e without additional module wiring.
1623
*/
1724
@Injectable()
18-
export class AuthWalletThrottlerGuard extends ThrottlerGuard {
25+
export class AuthWalletThrottlerGuard implements CanActivate {
26+
private static readonly hits = new Map<string, { count: number; expiresAt: number }>();
27+
private readonly limit = 5;
28+
private readonly ttl = 60000;
29+
30+
constructor() {}
31+
1932
protected async getTracker(req: Record<string, unknown>): Promise<string> {
2033
const body = (req as { body?: { wallet?: unknown } }).body;
2134
const user = (req as { user?: { wallet?: unknown } }).user;
@@ -26,6 +39,31 @@ export class AuthWalletThrottlerGuard extends ThrottlerGuard {
2639
if (wallet) {
2740
return `wallet:${wallet}`;
2841
}
29-
return super.getTracker(req);
42+
const ip = (req as unknown as { ip?: string }).ip;
43+
if (typeof ip === 'string' && ip.length > 0) return ip;
44+
const forwarded = (req as unknown as { headers?: Record<string, string> }).headers?.['x-forwarded-for'];
45+
if (typeof forwarded === 'string' && forwarded.length > 0) return forwarded.split(',')[0].trim();
46+
return 'unknown';
47+
}
48+
49+
async canActivate(context: ExecutionContext): Promise<boolean> {
50+
const req = context.switchToHttp().getRequest<Record<string, unknown>>();
51+
const tracker = await this.getTracker(req);
52+
const now = Date.now();
53+
const entry = AuthWalletThrottlerGuard.hits.get(tracker);
54+
if (!entry || now > entry.expiresAt) {
55+
AuthWalletThrottlerGuard.hits.set(tracker, { count: 1, expiresAt: now + this.ttl });
56+
return true;
57+
}
58+
entry.count += 1;
59+
if (entry.count > this.limit) {
60+
throw new ThrottlerException();
61+
}
62+
return true;
63+
}
64+
65+
/** Test helper: reset in-memory throttle state between isolated tests. */
66+
static clearStorage(): void {
67+
AuthWalletThrottlerGuard.hits.clear();
3068
}
3169
}
Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,59 @@
1-
import { Injectable } from '@nestjs/common';
2-
import { ThrottlerGuard } from '@nestjs/throttler';
1+
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2+
import { ThrottlerException } from '@nestjs/throttler';
33

44
/**
5-
* ThrottlerGuard variant that keys rate limits on the authenticated wallet
5+
* Throttler guard variant that keys rate limits on the authenticated wallet
66
* (from the JWT payload) instead of the client IP. Used alongside the global
77
* IP-based guard so POST /transactions/submit is bounded per wallet AND per
88
* IP, preventing a single wallet from being used as an open relay to Horizon.
99
*
1010
* Also checks req.body.wallet so the same guard can be reused for
1111
* unauthenticated routes like POST /auth/verify where the wallet is in the
1212
* request body.
13+
*
14+
* DI-free implementation (see AuthWalletThrottlerGuard for rationale) so
15+
* `@UseGuards(WalletThrottlerGuard)` works without Nest injecting
16+
* `ThrottlerStorageService`.
1317
*/
1418
@Injectable()
15-
export class WalletThrottlerGuard extends ThrottlerGuard {
19+
export class WalletThrottlerGuard implements CanActivate {
20+
private static readonly hits = new Map<string, { count: number; expiresAt: number }>();
21+
private readonly limit = 10;
22+
private readonly ttl = 60000;
23+
24+
constructor() {}
25+
1626
protected async getTracker(req: Record<string, unknown>): Promise<string> {
1727
const user = (req as { user?: { wallet?: unknown } }).user;
1828
const body = (req as { body?: { wallet?: unknown } }).body;
1929
const userWallet = typeof user?.wallet === 'string' ? user.wallet : undefined;
2030
const bodyWallet = typeof body?.wallet === 'string' ? body.wallet : undefined;
2131
const wallet = userWallet ?? bodyWallet;
22-
return wallet ? `wallet:${wallet}` : super.getTracker(req as Record<string, unknown>);
32+
if (wallet) return `wallet:${wallet}`;
33+
const ip = (req as unknown as { ip?: string }).ip;
34+
if (typeof ip === 'string' && ip.length > 0) return ip;
35+
const forwarded = (req as unknown as { headers?: Record<string, string> }).headers?.['x-forwarded-for'];
36+
if (typeof forwarded === 'string' && forwarded.length > 0) return forwarded.split(',')[0].trim();
37+
return 'unknown';
38+
}
39+
40+
async canActivate(context: ExecutionContext): Promise<boolean> {
41+
const req = context.switchToHttp().getRequest<Record<string, unknown>>();
42+
const tracker = await this.getTracker(req);
43+
const now = Date.now();
44+
const entry = WalletThrottlerGuard.hits.get(tracker);
45+
if (!entry || now > entry.expiresAt) {
46+
WalletThrottlerGuard.hits.set(tracker, { count: 1, expiresAt: now + this.ttl });
47+
return true;
48+
}
49+
entry.count += 1;
50+
if (entry.count > this.limit) {
51+
throw new ThrottlerException();
52+
}
53+
return true;
54+
}
55+
56+
static clearStorage(): void {
57+
WalletThrottlerGuard.hits.clear();
2358
}
2459
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import * as request from 'supertest';
2+
import { INestApplication } from '@nestjs/common';
3+
import { AuthWalletThrottlerGuard } from '../../../../src/modules/auth/auth-throttler.guard';
4+
import { buildTestApp, InMemoryStore } from '../../helpers/test-setup';
5+
6+
// Use the real stellar-sdk implementation even when a manual mock exists at
7+
// test/__mocks__/stellar-sdk.js (which is auto-applied for `jest.mock` in
8+
// unit tests). `jest.requireActual` bypasses the mock and gives us a
9+
// Keypair that generates distinct random wallets per call.
10+
const { Keypair: RealKeypair } = jest.requireActual('stellar-sdk') as typeof import('stellar-sdk');
11+
type RealKeypairType = InstanceType<typeof RealKeypair>;
12+
function createTestKeypair(): RealKeypairType {
13+
return RealKeypair.random() as unknown as RealKeypairType;
14+
}
15+
function signMessage(keypair: RealKeypairType, message: string): string {
16+
return (keypair as unknown as { sign: (b: Buffer) => Buffer }).sign(Buffer.from(message)).toString('base64');
17+
}
18+
19+
/**
20+
* E2E coverage for the two gaps identified by the audit bot:
21+
* - atomic nonce consumption under genuine concurrency (parallel identical
22+
* POST /auth/verify must yield exactly one 200, the other 401)
23+
* - per-wallet throttling on POST /auth/verify (6 rapid requests from the
24+
* same wallet must yield 429 on the 6th)
25+
*
26+
* Uses the InMemoryStore mock (via buildTestApp) rather than a real Postgres
27+
* instance. The store's UPDATE ... is('used_at', null) predicate is evaluated
28+
* in-memory, so the second concurrent claim correctly sees count 0 / empty
29+
* data and is rejected as AUTH_NONCE_NOT_FOUND. This is the user-visible
30+
* contract even though the underlying atomicity is ultimately provided by
31+
* Postgres `UPDATE ... WHERE used_at IS NULL` in production.
32+
*/
33+
describe('Auth verify — atomic claim & per-wallet throttling (e2e)', () => {
34+
let app: INestApplication;
35+
let mockDb: InMemoryStore;
36+
37+
beforeAll(async () => {
38+
const built = await buildTestApp();
39+
app = built.app;
40+
mockDb = built.mockDb;
41+
});
42+
43+
afterAll(async () => {
44+
await app.close();
45+
});
46+
47+
beforeEach(() => {
48+
mockDb.clear();
49+
AuthWalletThrottlerGuard.clearStorage();
50+
});
51+
52+
it('parallel double-verify with same (wallet, nonce, signature) yields exactly one success (atomic claim)', async () => {
53+
const keypair = createTestKeypair();
54+
const wallet = keypair.publicKey();
55+
56+
const nonceRes = await request(app.getHttpServer())
57+
.post('/auth/nonce')
58+
.send({ wallet })
59+
.expect(201);
60+
61+
const nonce: string = nonceRes.body.nonce;
62+
expect(nonce).toHaveLength(64);
63+
64+
// Legacy raw scheme: signature over the nonce hex bytes.
65+
const signature = signMessage(keypair, nonce);
66+
67+
const results = await Promise.allSettled([
68+
request(app.getHttpServer()).post('/auth/verify').send({ wallet, nonce, signature }),
69+
request(app.getHttpServer()).post('/auth/verify').send({ wallet, nonce, signature }),
70+
]);
71+
72+
// supertest always fulfills; inspect HTTP status directly
73+
const statuses = results.map((r) =>
74+
r.status === 'fulfilled' ? (r.value as request.Response).status : 0,
75+
);
76+
77+
const successes = statuses.filter((s) => s === 200);
78+
const notFounds = statuses.filter((s) => s === 401);
79+
80+
expect(successes).toHaveLength(1);
81+
expect(notFounds).toHaveLength(1);
82+
83+
// A third sequential replay must also fail with 401 (nonce stays burned)
84+
await request(app.getHttpServer())
85+
.post('/auth/verify')
86+
.send({ wallet, nonce, signature })
87+
.expect(401);
88+
});
89+
90+
it('expired nonce is rejected and stays burned — second attempt is NOT_FOUND, not success', async () => {
91+
const keypair = createTestKeypair();
92+
const wallet = keypair.publicKey();
93+
94+
const nonceRes = await request(app.getHttpServer())
95+
.post('/auth/nonce')
96+
.send({ wallet })
97+
.expect(201);
98+
99+
const nonce: string = nonceRes.body.nonce;
100+
const signature = signMessage(keypair, nonce);
101+
102+
// Manually expire the nonce row in the mock store (bulk update via store)
103+
// The mock store holds rows in memory; find and mutate.
104+
const rows = mockDb.dump('nonces');
105+
const row = rows.find((r) => r.nonce === nonce);
106+
if (row) {
107+
row.expires_at = new Date(Date.now() - 1000).toISOString();
108+
}
109+
110+
// First verify: claim succeeds but expiry check fails -> 401 AUTH_NONCE_EXPIRED
111+
await request(app.getHttpServer())
112+
.post('/auth/verify')
113+
.send({ wallet, nonce, signature })
114+
.expect(401);
115+
116+
// Second verify: nonce already claimed/burned -> 401 AUTH_NONCE_NOT_FOUND
117+
// (burn-on-failure semantics)
118+
await request(app.getHttpServer())
119+
.post('/auth/verify')
120+
.send({ wallet, nonce, signature })
121+
.expect(401);
122+
});
123+
124+
it('per-wallet throttling: 6 rapid POST /auth/verify from the same wallet yields 429 on the 6th', async () => {
125+
const wallet = createTestKeypair().publicKey();
126+
const fakeNonce = 'a'.repeat(64);
127+
const fakeSig = Buffer.alloc(64).toString('base64');
128+
129+
// First 5 requests: throttler allows them (service returns 401 AUTH_NONCE_NOT_FOUND,
130+
// but throttler does not block)
131+
for (let i = 0; i < 5; i++) {
132+
await request(app.getHttpServer())
133+
.post('/auth/verify')
134+
.send({ wallet, nonce: fakeNonce, signature: fakeSig })
135+
.expect(401);
136+
}
137+
138+
// 6th request from same wallet: per-wallet guard must reject with 429
139+
await request(app.getHttpServer())
140+
.post('/auth/verify')
141+
.send({ wallet, nonce: fakeNonce, signature: fakeSig })
142+
.expect(429);
143+
});
144+
145+
it('per-wallet throttling is isolated — a different wallet is not throttled by the first wallet’s quota', async () => {
146+
const walletA = createTestKeypair().publicKey();
147+
const walletB = createTestKeypair().publicKey();
148+
const fakeNonce = 'b'.repeat(64);
149+
const fakeSig = Buffer.alloc(64).toString('base64');
150+
151+
for (let i = 0; i < 5; i++) {
152+
await request(app.getHttpServer())
153+
.post('/auth/verify')
154+
.send({ wallet: walletA, nonce: fakeNonce, signature: fakeSig })
155+
.expect(401);
156+
}
157+
// walletA exhausted
158+
await request(app.getHttpServer())
159+
.post('/auth/verify')
160+
.send({ wallet: walletA, nonce: fakeNonce, signature: fakeSig })
161+
.expect(429);
162+
163+
// walletB should still be allowed (gets 401, not 429)
164+
await request(app.getHttpServer())
165+
.post('/auth/verify')
166+
.send({ wallet: walletB, nonce: fakeNonce, signature: fakeSig })
167+
.expect(401);
168+
});
169+
});

0 commit comments

Comments
 (0)