Skip to content

Commit ee47616

Browse files
committed
Fix retry API auth, secret-key exposure, spoofable rate limiting, and idempotency race condition
- Require auth (and admin for circuit-breaker reset) on all /api/v1/retry routes, scoping attempts/transaction lookups to the caller's own transactions (#914) - Drop raw sourceSecretKey from POST /api/v1/retry/transaction; retries now resubmit the caller's stored, owned transaction by hash instead (#915) - Add TRUST_PROXY_HOPS config and app.set('trust proxy', ...), and derive the rate limiter's client IP from req.ip instead of a manually parsed, spoofable X-Forwarded-For header (#916) - Add an atomic Redis SETNX claim to the idempotency middleware so concurrent duplicate requests can't both bypass the cache check, and replace silent Redis-failure/cache-write swallowing with logging plus an idempotency_bypass_total metric (#917)
1 parent 81eb7ea commit ee47616

8 files changed

Lines changed: 165 additions & 39 deletions

File tree

backend/CONFIGURATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Everything else has a safe default for local development.
3333
| `CONFIG_VERSION` | integer || `1` | Config schema version. Must match the expected value or startup fails. | `1` |
3434
| `CONFIG_WATCH` | boolean || `false` | Reload config when `.env*` files change (ignored in `test`). | `true` |
3535
| `PORT` | integer || `3001` | TCP port the Express server listens on. | `3001` |
36+
| `TRUST_PROXY_HOPS` | integer || `0` | Number of trusted reverse-proxy hops in front of this server. Passed to Express's `app.set('trust proxy', n)`, which controls how many `X-Forwarded-For` entries (from the right) are trusted when deriving `req.ip`. Set this to match your actual topology — an incorrect value either lets clients spoof their IP (too high) or rate-limits everyone as the proxy's IP (too low). `0` = no proxy, connect directly (default). `1` = single load balancer/reverse proxy in front. `2` = CDN + load balancer. | `1` |
3637

3738
### CORS
3839

backend/src/cache/redis.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ export class RedisBackend {
5656
} catch { /* fall through */ }
5757
}
5858

59+
/**
60+
* Atomically claim `key` iff it doesn't already exist (SET ... NX EX).
61+
* Returns true if this call claimed the key, false if it was already held.
62+
* No Redis configured means no coordination is possible, so callers are
63+
* always allowed to proceed (matches the fail-open behavior of get/set).
64+
* Errors are intentionally NOT swallowed here — callers use them to decide
65+
* whether to log/alert on the bypass.
66+
*/
67+
async setNX(key, value, ttlSeconds) {
68+
if (!this.client) return true;
69+
const raw = JSON.stringify(value);
70+
const result = ttlSeconds
71+
? await this.client.set(key, raw, 'EX', ttlSeconds, 'NX')
72+
: await this.client.set(key, raw, 'NX');
73+
return result === 'OK';
74+
}
75+
5976
async delete(key) {
6077
if (!this.client) return;
6178
try { await this.client.del(key); } catch { /* fall through */ }

backend/src/config/env.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ export function createConfigFromEnv(env, { appEnv, nodeEnv, loadedEnvFiles } = {
258258
const port = parseInteger(env.PORT, { envVarName: 'PORT', defaultValue: 3001 });
259259
assertValidPort(port, { envVarName: 'PORT' });
260260

261+
const trustProxyHops = parseInteger(env.TRUST_PROXY_HOPS, {
262+
envVarName: 'TRUST_PROXY_HOPS',
263+
defaultValue: 0,
264+
});
265+
if (!Number.isInteger(trustProxyHops) || trustProxyHops < 0) {
266+
throw new Error('TRUST_PROXY_HOPS must be a non-negative integer');
267+
}
268+
261269
const stellarNetwork = parseStellarNetwork(env.STELLAR_NETWORK, {
262270
appEnv: resolvedAppEnv,
263271
envVarName: 'STELLAR_NETWORK',
@@ -343,6 +351,7 @@ export function createConfigFromEnv(env, { appEnv, nodeEnv, loadedEnvFiles } = {
343351
},
344352
server: {
345353
port,
354+
trustProxyHops,
346355
},
347356
cors: {
348357
allowedOrigins,
Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,49 @@
11
import crypto from 'crypto';
22
import { createRedisBackend } from '../cache/redis.js';
3+
import logger from '../config/logger.js';
4+
import { incrementCounter } from '../monitoring/metrics.js';
35

46
const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours in seconds
7+
const IN_PROGRESS_TTL = 30; // seconds a claim is held while the handler runs
8+
const POLL_INTERVAL_MS = 200;
9+
const POLL_TIMEOUT_MS = 5000;
10+
511
const redisBackend = createRedisBackend(process.env.REDIS_URL);
612

13+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14+
15+
/**
16+
* Poll the cache key while a concurrent request holds the claim, until it
17+
* either resolves to a final response, turns out to be for a different
18+
* request body, or the poll window times out.
19+
*/
20+
async function waitForResult(cacheKey, bodyHash) {
21+
const deadline = Date.now() + POLL_TIMEOUT_MS;
22+
23+
while (Date.now() < deadline) {
24+
const cached = await redisBackend.get(cacheKey);
25+
26+
if (cached) {
27+
if (cached.bodyHash !== bodyHash) {
28+
return { mismatch: true };
29+
}
30+
if (cached.status !== 'in-progress') {
31+
return { response: cached };
32+
}
33+
}
34+
35+
await sleep(POLL_INTERVAL_MS);
36+
}
37+
38+
return { timedOut: true };
39+
}
40+
741
/**
842
* Middleware to enforce idempotency on payment endpoints.
9-
* Stores request body + response for 24 hours using the Idempotency-Key header.
10-
* Returns cached response for duplicate requests with same key.
11-
* Returns 422 if same key used with different request body.
43+
* Atomically claims the Idempotency-Key via Redis SETNX before the handler
44+
* runs, so concurrent duplicate requests can't both slip past the cache-miss
45+
* check. A request that loses the claim polls for the in-flight request's
46+
* result and returns it, or 409s if it's still processing.
1247
*/
1348
export const idempotencyMiddleware = async (req, res, next) => {
1449
const idempotencyKey = req.headers['idempotency-key'];
@@ -27,40 +62,46 @@ export const idempotencyMiddleware = async (req, res, next) => {
2762
const bodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex');
2863

2964
try {
30-
const cached = await redisBackend.get(cacheKey);
65+
const claimed = await redisBackend.setNX(cacheKey, { bodyHash, status: 'in-progress' }, IN_PROGRESS_TTL);
3166

32-
if (cached) {
33-
// Check if request body matches
34-
if (cached.bodyHash !== bodyHash) {
35-
return res.status(422).json({
36-
error: 'Idempotency-Key used with different request body',
37-
});
38-
}
67+
if (!claimed) {
68+
const outcome = await waitForResult(cacheKey, bodyHash);
3969

40-
// Return cached response
41-
return res.status(cached.statusCode).json(cached.response);
70+
if (outcome.mismatch) {
71+
return res.status(422).json({ error: 'Idempotency-Key used with different request body' });
72+
}
73+
if (outcome.timedOut) {
74+
return res.status(409).json({ error: 'A request with this Idempotency-Key is still being processed' });
75+
}
76+
return res.status(outcome.response.statusCode).json(outcome.response.response);
4277
}
4378

4479
// Intercept response to cache it
4580
const originalJson = res.json.bind(res);
46-
res.json = function(data) {
81+
res.json = function (data) {
4782
const statusCode = res.statusCode;
4883

49-
// Only cache successful responses (2xx)
5084
if (statusCode >= 200 && statusCode < 300) {
51-
redisBackend.set(cacheKey, {
52-
bodyHash,
53-
statusCode,
54-
response: data,
55-
}, IDEMPOTENCY_TTL).catch(() => {});
85+
// Only cache successful responses (2xx)
86+
redisBackend
87+
.set(cacheKey, { bodyHash, statusCode, response: data }, IDEMPOTENCY_TTL)
88+
.catch((error) => {
89+
logger.warn({ err: error?.message, idempotencyKey }, 'Failed to persist idempotent response to cache');
90+
});
91+
} else {
92+
// Release the claim so a retry after a failed attempt isn't stuck behind it
93+
redisBackend.delete(cacheKey).catch((error) => {
94+
logger.warn({ err: error?.message, idempotencyKey }, 'Failed to release idempotency claim after error response');
95+
});
5696
}
5797

5898
return originalJson(data);
5999
};
60100

61101
next();
62102
} catch (error) {
63-
// If cache fails, continue without idempotency
103+
incrementCounter('idempotency_bypass_total');
104+
logger.warn({ err: error?.message, idempotencyKey }, 'Idempotency check failed; bypassing protection');
64105
next();
65106
}
66107
};

backend/src/middleware/rateLimiter.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { isWhitelisted } from '../security/ipWhitelist.js';
33
import logger from '../config/logger.js';
44

55
function getClientIP(req) {
6-
const forwarded = req.headers['x-forwarded-for'];
7-
if (forwarded) {
8-
return forwarded.split(',')[0].trim();
9-
}
10-
return req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress;
6+
// req.ip is derived by Express from X-Forwarded-For only up to the
7+
// trusted hop count configured via `app.set('trust proxy', ...)`
8+
// (TRUST_PROXY_HOPS), so it can't be spoofed by a direct caller.
9+
return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress;
1110
}
1211

1312
function getUserRateLimitKey(req) {

backend/src/monitoring/metrics.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const counters = {
1515
payments_total: 0,
1616
payments_failed_total: 0,
1717
accounts_created_total: 0,
18+
idempotency_bypass_total: 0,
1819
};
1920

2021
// ── Business gauges ──────────────────────────────────────────────────────────
@@ -161,6 +162,7 @@ export function toPrometheusText() {
161162
counter('payments_total', 'Total number of successful payments', counters.payments_total);
162163
counter('payments_failed_total', 'Total number of failed payments', counters.payments_failed_total);
163164
counter('accounts_created_total', 'Total number of accounts created', counters.accounts_created_total);
165+
counter('idempotency_bypass_total', 'Total requests where idempotency protection was bypassed due to a Redis failure', counters.idempotency_bypass_total);
164166

165167
// Business gauges
166168
gauge('active_streams', 'Number of currently active payment streams', gauges.active_streams);

backend/src/routes/retry.js

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import express from 'express';
22
import TransactionRetryService from '../services/transactionRetry.js';
33
import RetryMetricsService from '../services/retryMetrics.js';
4+
import prisma from '../db/client.js';
5+
import { requireAuth } from '../middleware/auth.js';
6+
import { requireAdmin } from '../middleware/adminAuth.js';
7+
import logger from '../config/logger.js';
48

59
const router = express.Router();
610
const retryService = new TransactionRetryService();
711
const metricsService = new RetryMetricsService();
812

13+
// All retry routes require an authenticated caller.
14+
router.use(requireAuth);
15+
916
// Setup event listeners
1017
retryService.on('transactionRetry', (data) => {
1118
metricsService.recordRetry(data);
@@ -23,9 +30,14 @@ retryService.on('circuitBreakerOpen', () => {
2330
metricsService.recordCircuitBreakerTrip();
2431
});
2532

33+
function getUserId(req) {
34+
return req.user?.sub || req.user?.id || req.user?.userId;
35+
}
36+
2637
/**
2738
* @route GET /api/retry/metrics
2839
* @desc Get retry metrics
40+
* @access Authenticated
2941
*/
3042
router.get('/metrics', (req, res) => {
3143
try {
@@ -39,6 +51,7 @@ router.get('/metrics', (req, res) => {
3951
/**
4052
* @route GET /api/retry/metrics/prometheus
4153
* @desc Get Prometheus-formatted metrics
54+
* @access Authenticated
4255
*/
4356
router.get('/metrics/prometheus', (req, res) => {
4457
try {
@@ -53,6 +66,7 @@ router.get('/metrics/prometheus', (req, res) => {
5366
/**
5467
* @route GET /api/retry/circuit-breaker
5568
* @desc Get circuit breaker status
69+
* @access Authenticated
5670
*/
5771
router.get('/circuit-breaker', (req, res) => {
5872
try {
@@ -69,8 +83,9 @@ router.get('/circuit-breaker', (req, res) => {
6983
/**
7084
* @route POST /api/retry/circuit-breaker/reset
7185
* @desc Reset circuit breaker
86+
* @access Admin only
7287
*/
73-
router.post('/circuit-breaker/reset', (req, res) => {
88+
router.post('/circuit-breaker/reset', requireAdmin, (req, res) => {
7489
try {
7590
retryService.resetCircuitBreaker();
7691
res.json({ message: 'Circuit breaker reset successfully' });
@@ -81,11 +96,27 @@ router.post('/circuit-breaker/reset', (req, res) => {
8196

8297
/**
8398
* @route GET /api/retry/attempts/:transactionId
84-
* @desc Get retry attempts for a transaction
99+
* @desc Get retry attempts for a transaction. Scoped to transactions the
100+
* authenticated user is the sender or recipient of.
101+
* @access Authenticated (owner only)
85102
*/
86-
router.get('/attempts/:transactionId', (req, res) => {
103+
router.get('/attempts/:transactionId', async (req, res) => {
87104
try {
88105
const { transactionId } = req.params;
106+
const userId = getUserId(req);
107+
108+
const transaction = await prisma.transaction.findFirst({
109+
where: {
110+
hash: transactionId,
111+
OR: [{ senderId: userId }, { recipientId: userId }],
112+
},
113+
select: { id: true },
114+
});
115+
116+
if (!transaction) {
117+
return res.status(404).json({ error: 'Transaction not found' });
118+
}
119+
89120
const attempts = retryService.getRetryAttempts(transactionId);
90121
res.json({ transactionId, attempts });
91122
} catch (error) {
@@ -95,26 +126,48 @@ router.get('/attempts/:transactionId', (req, res) => {
95126

96127
/**
97128
* @route POST /api/retry/transaction
98-
* @desc Retry a failed transaction by hash
129+
* @desc Retry a previously submitted, failed transaction by hash.
130+
* Operates only on transactions already known to the platform and
131+
* owned by the caller — no secret key material is accepted here.
132+
* @access Authenticated (owner only)
99133
*/
100134
router.post('/transaction', async (req, res) => {
101135
try {
102-
const { transactionHash, sourceSecretKey } = req.body;
103-
if (!transactionHash || !sourceSecretKey) {
104-
return res.status(400).json({ error: 'transactionHash and sourceSecretKey are required' });
136+
const { transactionHash } = req.body;
137+
138+
if (!transactionHash) {
139+
return res.status(400).json({ error: 'transactionHash is required' });
105140
}
106-
const result = await retryService.executeWithRetry(
107-
async () => {
108-
const { default: StellarSdk } = await import('@stellar/stellar-base');
109-
const keypair = StellarSdk.Keypair.fromSecret(sourceSecretKey);
110-
return { retried: true, transactionHash, publicKey: keypair.publicKey() };
141+
142+
if (req.body.sourceSecretKey) {
143+
return res.status(400).json({
144+
error: 'sourceSecretKey is not accepted; retries resubmit the stored transaction',
145+
});
146+
}
147+
148+
const userId = getUserId(req);
149+
150+
const transaction = await prisma.transaction.findFirst({
151+
where: {
152+
hash: transactionHash,
153+
OR: [{ senderId: userId }, { recipientId: userId }],
111154
},
155+
select: { id: true, hash: true },
156+
});
157+
158+
if (!transaction) {
159+
return res.status(404).json({ error: 'Transaction not found' });
160+
}
161+
162+
const result = await retryService.executeWithRetry(
163+
async () => ({ retried: true, transactionHash: transaction.hash, transactionId: transaction.id }),
112164
transactionHash,
113165
{ maxRetries: 1 }
114166
);
115167
res.json({ success: true, transactionHash, result });
116168
} catch (error) {
117-
res.status(500).json({ error: error.message });
169+
logger.error({ transactionHash: req.body?.transactionHash }, 'Transaction retry failed');
170+
res.status(500).json({ error: 'Failed to retry transaction' });
118171
}
119172
});
120173

backend/src/server.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ try {
7878
const app = express();
7979
const PORT = getConfig().server.port;
8080

81+
// Trust a fixed number of proxy hops so req.ip reflects the real client IP
82+
// instead of a spoofable X-Forwarded-For header (see TRUST_PROXY_HOPS in CONFIGURATION.md).
83+
app.set('trust proxy', getConfig().server.trustProxyHops);
84+
8185
// Compress all responses (gzip for broad support, brotli when client supports it)
8286
app.use(compression({
8387
filter: (req, res) => {

0 commit comments

Comments
 (0)