diff --git a/backend/logs/audit_fallback.log b/backend/logs/audit_fallback.log index c654debd..ac0eeb2a 100644 --- a/backend/logs/audit_fallback.log +++ b/backend/logs/audit_fallback.log @@ -6,3 +6,24 @@ 2026-04-26T12:10:52.741Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: DB connection lost 2026-04-26T12:11:43.372Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: DB connection lost 2026-04-26T13:07:19.913Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: DB connection lost +2026-06-28T08:01:32.927Z | {"merchant_id":"merchant-uuid-001","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:32.993Z | {"merchant_id":"merchant-uuid-002","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:32.995Z | {"merchant_id":null,"action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:33.003Z | {"merchant_id":"merchant-uuid-003","action":"login","status":"success","ip_address":null,"user_agent":null,"event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:33.005Z | {"merchant_id":"merchant-uuid-005","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:33.007Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:01:33.312Z | {"merchant_id":"merchant-uuid-006","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.968Z | {"merchant_id":"merchant-uuid-001","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.983Z | {"merchant_id":"merchant-uuid-002","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.985Z | {"merchant_id":null,"action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.988Z | {"merchant_id":"merchant-uuid-003","action":"login","status":"success","ip_address":null,"user_agent":null,"event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.990Z | {"merchant_id":"merchant-uuid-005","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:14.991Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:02:15.295Z | {"merchant_id":"merchant-uuid-006","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.927Z | {"merchant_id":"merchant-uuid-001","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.977Z | {"merchant_id":"merchant-uuid-002","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.979Z | {"merchant_id":null,"action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.980Z | {"merchant_id":"merchant-uuid-003","action":"login","status":"success","ip_address":null,"user_agent":null,"event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.982Z | {"merchant_id":"merchant-uuid-005","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:05.984Z | {"merchant_id":"merchant-uuid-004","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined +2026-06-28T08:03:06.289Z | {"merchant_id":"merchant-uuid-006","action":"login","event_type":"login_attempt"} | error: optimizedWrite is not defined diff --git a/backend/package-lock.json b/backend/package-lock.json index e43aec28..1b6f4a98 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,7 +21,6 @@ "express": "^4.19.2", "express-rate-limit": "^8.3.1", "express-validator": "^7.3.2", - "framer-motion": "^12.38.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "knex": "^3.2.6", @@ -50,7 +49,7 @@ "nock": "^14.0.11", "nodemon": "^3.1.14", "supertest": "^7.0.0", - "vitest": "^2.0.0" + "vitest": "^2.1.9" } }, "node_modules/@adraffy/ens-normalize": { @@ -6902,33 +6901,6 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, - "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -8852,21 +8824,6 @@ "node": ">= 0.8" } }, - "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.36.0" - } - }, - "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 41af4619..4f1f5911 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,7 @@ "nock": "^14.0.11", "nodemon": "^3.1.14", "supertest": "^7.0.0", - "vitest": "^2.0.0" + "vitest": "^2.1.9" }, "dependencies": { "@sentry/node": "^10.46.0", diff --git a/backend/src/lib/trustline-manager.js b/backend/src/lib/trustline-manager.js index f2ca8913..2b3f6713 100644 --- a/backend/src/lib/trustline-manager.js +++ b/backend/src/lib/trustline-manager.js @@ -27,6 +27,8 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; export const TRUSTLINE_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes export const TRUSTLINE_RATE_LIMIT_MAX = 20; // 20 operations per window export const TRUSTLINE_VERIFICATION_RATE_LIMIT_MAX = 50; // 50 verifications per window +export const TRUSTLINE_BURST_WINDOW_MS = 10 * 1000; // 10 seconds burst window +export const TRUSTLINE_BURST_MAX = 5; // 5 requests per burst window const TRUSTLINE_STATS_TIMEFRAMES = new Set([ "1 hour", "24 hours", @@ -45,6 +47,12 @@ const OPERATION_TIMEOUT_MS = 15 * 1000; // default per-operation timeout const DLQ_MAX_SIZE = 100; // maximum dead-letter queue entries const ERROR_RECOVERY_METRICS_WINDOW_MS = 60 * 1000; // 1 minute window for recovery metrics +/** + * Per-key rate limit violation tracking. + * Provides metrics for security monitoring and alerting on abusive actors. + */ +const rateLimitViolations = new Map(); + /** * Per-context circuit breaker states. * Key: context string (or 'default'). Value: CircuitBreakerState. @@ -299,10 +307,14 @@ export class TrustlineSignatureVerifier { * - Per-merchant trustline operation limits * - Per-IP verification limits * - Adaptive rate limiting based on account type + * - Burst protection (secondary tight window) + * - Internal service bypass via trusted header + * - Violation metrics for security monitoring */ export class TrustlineRateLimiter { /** - * Generate rate limit key for trustline operations + * Generate rate limit key for trustline operations. + * Priority: merchant ID > hashed API key > IP address. */ static getTrustlineOperationKey(req) { const merchantId = req?.merchant?.id; @@ -325,7 +337,8 @@ export class TrustlineRateLimiter { } /** - * Generate rate limit key for trustline verifications + * Generate rate limit key for trustline verifications. + * Priority: merchant ID > IP address. */ static getTrustlineVerificationKey(req) { const merchantId = req?.merchant?.id; @@ -338,7 +351,88 @@ export class TrustlineRateLimiter { } /** - * Create rate limiter for trustline operations + * Check whether the request comes from a trusted internal service. + * Internal services present a shared secret via x-internal-service-token. + * The server-side secret is configured via the INTERNAL_SERVICE_TOKEN env var. + */ + static isInternalService(req) { + const internalToken = req?.headers?.["x-internal-service-token"]; + const configuredToken = process.env.INTERNAL_SERVICE_TOKEN; + return Boolean( + configuredToken && internalToken && internalToken === configuredToken, + ); + } + + /** + * Record a rate limit violation for a given key. + * Call this from rate limiter handlers to build abuse metrics. + */ + static recordViolation(key) { + const current = rateLimitViolations.get(key) || { + count: 0, + lastSeen: null, + }; + rateLimitViolations.set(key, { + count: current.count + 1, + lastSeen: new Date().toISOString(), + }); + } + + /** + * Return a snapshot of all recorded rate limit violations. + * Useful for security dashboards and alerting pipelines. + */ + static getRateLimitViolationMetrics() { + const snapshot = {}; + for (const [key, data] of rateLimitViolations.entries()) { + snapshot[key] = { ...data }; + } + return snapshot; + } + + /** + * Clear all recorded violation metrics. + * Used in tests and administrative reset endpoints. + */ + static resetViolationMetrics() { + rateLimitViolations.clear(); + } + + /** + * Create burst protection rate limiter. + * Enforces a tight secondary window (default 5 req / 10 s) to stop + * rapid-fire bursts that stay under the primary per-window quota. + */ + static createTrustlineBurstRateLimit({ + store, + rateLimitFactory = rateLimit, + } = {}) { + return rateLimitFactory({ + windowMs: TRUSTLINE_BURST_WINDOW_MS, + max: TRUSTLINE_BURST_MAX, + message: { + error: "Burst limit exceeded. Slow down your trustline requests.", + code: "TRUSTLINE_BURST_LIMIT", + retryAfter: Math.ceil(TRUSTLINE_BURST_WINDOW_MS / 1000), + }, + standardHeaders: true, + legacyHeaders: false, + validate: { ip: false }, + keyGenerator: this.getTrustlineOperationKey, + handler: (req, res, _next, options) => { + const key = this.getTrustlineOperationKey(req); + this.recordViolation(key); + res.status(options.statusCode).json(options.message); + }, + store, + passOnStoreError: true, + skip: (req) => this.isInternalService(req), + }); + } + + /** + * Create rate limiter for trustline operations. + * Skips enterprise/premium merchants and trusted internal services. */ static createTrustlineOperationRateLimit({ store, @@ -350,17 +444,23 @@ export class TrustlineRateLimiter { message: { error: "Too many trustline operations. Please wait before creating more trustlines.", + code: "TRUSTLINE_RATE_LIMIT", retryAfter: Math.ceil(TRUSTLINE_RATE_LIMIT_WINDOW_MS / 1000), }, standardHeaders: true, legacyHeaders: false, validate: { ip: false }, keyGenerator: this.getTrustlineOperationKey, + handler: (req, res, _next, options) => { + const key = this.getTrustlineOperationKey(req); + this.recordViolation(key); + res.status(options.statusCode).json(options.message); + }, requestWasSuccessful: (_req, res) => res.statusCode < 400, store, passOnStoreError: true, - // Skip rate limiting for high-tier merchants skip: (req) => { + if (this.isInternalService(req)) return true; const merchantTier = req?.merchant?.metadata?.tier; return merchantTier === "enterprise" || merchantTier === "premium"; }, @@ -368,7 +468,9 @@ export class TrustlineRateLimiter { } /** - * Create rate limiter for trustline verifications + * Create rate limiter for trustline verifications. + * Higher quota than operations since verification is read-only. + * Skips trusted internal services. */ static createTrustlineVerificationRateLimit({ store, @@ -379,15 +481,22 @@ export class TrustlineRateLimiter { max: TRUSTLINE_VERIFICATION_RATE_LIMIT_MAX, message: { error: "Too many trustline verification requests. Please slow down.", + code: "TRUSTLINE_VERIFY_RATE_LIMIT", retryAfter: Math.ceil(TRUSTLINE_RATE_LIMIT_WINDOW_MS / 1000), }, standardHeaders: true, legacyHeaders: false, validate: { ip: false }, keyGenerator: this.getTrustlineVerificationKey, + handler: (req, res, _next, options) => { + const key = this.getTrustlineVerificationKey(req); + this.recordViolation(key); + res.status(options.statusCode).json(options.message); + }, requestWasSuccessful: (_req, res) => res.statusCode < 400, store, passOnStoreError: true, + skip: (req) => this.isInternalService(req), }); } } @@ -1405,5 +1514,6 @@ export const createTrustlineRateLimits = (redisClient) => { verifications: TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store, }), + burst: TrustlineRateLimiter.createTrustlineBurstRateLimit({ store }), }; }; diff --git a/backend/src/lib/trustline-manager.test.js b/backend/src/lib/trustline-manager.test.js index 1aa38401..99fd6d1e 100644 --- a/backend/src/lib/trustline-manager.test.js +++ b/backend/src/lib/trustline-manager.test.js @@ -3,7 +3,7 @@ * Tests all four optimization tasks: signature verification, rate limiting, error recovery, and SQL optimization */ -import { vi, describe, test, expect, beforeEach } from 'vitest'; +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; // Mock dependencies first const { @@ -62,7 +62,12 @@ import { TrustlineErrorRecovery, TrustlineQueryOptimizer, TrustlineManager, - trustlineManager + trustlineManager, + TRUSTLINE_RATE_LIMIT_WINDOW_MS, + TRUSTLINE_RATE_LIMIT_MAX, + TRUSTLINE_VERIFICATION_RATE_LIMIT_MAX, + TRUSTLINE_BURST_WINDOW_MS, + TRUSTLINE_BURST_MAX, } from './trustline-manager.js'; import { queryWithRetry } from './db.js'; import { @@ -876,9 +881,9 @@ describe('Trustline Manager - Task #596: SQL Query Optimization', () => { const result = await TrustlineQueryOptimizer.createOptimizedIndexes(); - expect(result).toHaveLength(4); // Four indexes + expect(result).toHaveLength(7); // 4 original + 3 added in Task #879 expect(result.every(r => r.success)).toBe(true); - expect(mockQueryWithRetry).toHaveBeenCalledTimes(4); + expect(mockQueryWithRetry).toHaveBeenCalledTimes(7); }); test('should handle index creation errors gracefully', async () => { @@ -886,11 +891,14 @@ describe('Trustline Manager - Task #596: SQL Query Optimization', () => { .mockResolvedValueOnce({ rows: [] }) // First index succeeds .mockRejectedValueOnce(new Error('Index already exists')) // Second fails .mockResolvedValueOnce({ rows: [] }) // Third succeeds - .mockResolvedValueOnce({ rows: [] }); // Fourth succeeds + .mockResolvedValueOnce({ rows: [] }) // Fourth succeeds + .mockResolvedValueOnce({ rows: [] }) // Fifth succeeds + .mockResolvedValueOnce({ rows: [] }) // Sixth succeeds + .mockResolvedValueOnce({ rows: [] }); // Seventh succeeds const result = await TrustlineQueryOptimizer.createOptimizedIndexes(); - expect(result).toHaveLength(4); + expect(result).toHaveLength(7); expect(result[0].success).toBe(true); expect(result[1].success).toBe(false); expect(result[1].error).toContain('Index already exists'); @@ -1001,3 +1009,386 @@ describe('Trustline Manager - Singleton Instance', () => { expect(trustlineManager.queryOptimizer).toBe(TrustlineQueryOptimizer); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Rate Limiting – Enhanced Coverage (Task #594) +// ───────────────────────────────────────────────────────────────────────────── + +describe('TrustlineRateLimiter – Internal Service Bypass', () => { + const TOKEN = 'super-secret-internal-token'; + + afterEach(() => { + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('returns true when header matches configured token', () => { + process.env.INTERNAL_SERVICE_TOKEN = TOKEN; + const req = { headers: { 'x-internal-service-token': TOKEN } }; + expect(TrustlineRateLimiter.isInternalService(req)).toBe(true); + }); + + test('returns false when header token is wrong', () => { + process.env.INTERNAL_SERVICE_TOKEN = TOKEN; + const req = { headers: { 'x-internal-service-token': 'wrong' } }; + expect(TrustlineRateLimiter.isInternalService(req)).toBe(false); + }); + + test('returns false when header is absent', () => { + process.env.INTERNAL_SERVICE_TOKEN = TOKEN; + const req = { headers: {} }; + expect(TrustlineRateLimiter.isInternalService(req)).toBe(false); + }); + + test('returns false when INTERNAL_SERVICE_TOKEN env var is not set', () => { + const req = { headers: { 'x-internal-service-token': TOKEN } }; + expect(TrustlineRateLimiter.isInternalService(req)).toBe(false); + }); + + test('returns false for a null/undefined request', () => { + process.env.INTERNAL_SERVICE_TOKEN = TOKEN; + expect(TrustlineRateLimiter.isInternalService(null)).toBe(false); + expect(TrustlineRateLimiter.isInternalService(undefined)).toBe(false); + }); +}); + +describe('TrustlineRateLimiter – Violation Metrics', () => { + beforeEach(() => { + TrustlineRateLimiter.resetViolationMetrics(); + vi.clearAllMocks(); + }); + + test('getRateLimitViolationMetrics returns empty object initially', () => { + expect(TrustlineRateLimiter.getRateLimitViolationMetrics()).toEqual({}); + }); + + test('recordViolation increments count and sets lastSeen', () => { + const key = 'trustline:ops:merchant:abc'; + TrustlineRateLimiter.recordViolation(key); + TrustlineRateLimiter.recordViolation(key); + + const metrics = TrustlineRateLimiter.getRateLimitViolationMetrics(); + expect(metrics[key].count).toBe(2); + expect(metrics[key].lastSeen).toBeTruthy(); + }); + + test('tracks multiple keys independently', () => { + TrustlineRateLimiter.recordViolation('key-a'); + TrustlineRateLimiter.recordViolation('key-b'); + TrustlineRateLimiter.recordViolation('key-b'); + + const metrics = TrustlineRateLimiter.getRateLimitViolationMetrics(); + expect(metrics['key-a'].count).toBe(1); + expect(metrics['key-b'].count).toBe(2); + }); + + test('resetViolationMetrics clears all data', () => { + TrustlineRateLimiter.recordViolation('some-key'); + TrustlineRateLimiter.resetViolationMetrics(); + expect(TrustlineRateLimiter.getRateLimitViolationMetrics()).toEqual({}); + }); + + test('getRateLimitViolationMetrics returns a snapshot copy', () => { + TrustlineRateLimiter.recordViolation('copy-key'); + const snapshot = TrustlineRateLimiter.getRateLimitViolationMetrics(); + // Mutating the snapshot should not affect stored metrics + snapshot['copy-key'].count = 9999; + expect(TrustlineRateLimiter.getRateLimitViolationMetrics()['copy-key'].count).toBe(1); + }); +}); + +describe('TrustlineRateLimiter – Burst Rate Limiter', () => { + beforeEach(() => { + vi.clearAllMocks(); + TrustlineRateLimiter.resetViolationMetrics(); + }); + + test('creates burst limiter with correct window and max', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + expect(rateLimitFactory).toHaveBeenCalledWith( + expect.objectContaining({ + windowMs: TRUSTLINE_BURST_WINDOW_MS, + max: TRUSTLINE_BURST_MAX, + keyGenerator: TrustlineRateLimiter.getTrustlineOperationKey, + }), + ); + }); + + test('burst limiter uses the operations key generator', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.keyGenerator).toBe(TrustlineRateLimiter.getTrustlineOperationKey); + }); + + test('burst limiter skip function allows internal services', () => { + process.env.INTERNAL_SERVICE_TOKEN = 'svc-token'; + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { headers: { 'x-internal-service-token': 'svc-token' } }; + expect(config.skip(req)).toBe(true); + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('burst limiter skip function does NOT skip normal clients', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { merchant: { id: 'merchant-1' }, headers: {} }; + expect(config.skip(req)).toBe(false); + }); + + test('burst limiter handler records violation and returns 429 response', () => { + const rateLimitFactory = mockRateLimit; + mockIpKeyGenerator.mockReturnValue('1.2.3.4'); + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { ip: '1.2.3.4', headers: {} }; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + const options = { statusCode: 429, message: { error: 'Burst limit exceeded.' } }; + + config.handler(req, res, vi.fn(), options); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith(options.message); + + const metrics = TrustlineRateLimiter.getRateLimitViolationMetrics(); + const key = 'trustline:ops:ip:1.2.3.4'; + expect(metrics[key]).toBeDefined(); + expect(metrics[key].count).toBe(1); + }); + + test('burst limiter has passOnStoreError enabled', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.passOnStoreError).toBe(true); + }); + + test('burst limit error message includes TRUSTLINE_BURST_LIMIT code', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineBurstRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.message.code).toBe('TRUSTLINE_BURST_LIMIT'); + }); +}); + +describe('TrustlineRateLimiter – Enhanced Operation Rate Limiter', () => { + beforeEach(() => { + vi.clearAllMocks(); + TrustlineRateLimiter.resetViolationMetrics(); + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('skip function returns true for internal services', () => { + process.env.INTERNAL_SERVICE_TOKEN = 'int-svc'; + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { merchant: { metadata: { tier: 'basic' } }, headers: { 'x-internal-service-token': 'int-svc' } }; + expect(config.skip(req)).toBe(true); + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('skip function returns true for enterprise merchants', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.skip({ merchant: { metadata: { tier: 'enterprise' } }, headers: {} })).toBe(true); + }); + + test('skip function returns true for premium merchants', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.skip({ merchant: { metadata: { tier: 'premium' } }, headers: {} })).toBe(true); + }); + + test('skip function returns false for basic-tier merchants', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.skip({ merchant: { metadata: { tier: 'basic' } }, headers: {} })).toBe(false); + }); + + test('skip function returns false for unauthenticated requests', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.skip({ headers: {} })).toBe(false); + }); + + test('handler records violation and responds with 429', () => { + mockIpKeyGenerator.mockReturnValue('10.0.0.1'); + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { ip: '10.0.0.1', headers: {} }; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + const options = { statusCode: 429, message: { error: 'Too many trustline operations.', code: 'TRUSTLINE_RATE_LIMIT' } }; + + config.handler(req, res, vi.fn(), options); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith(options.message); + const metrics = TrustlineRateLimiter.getRateLimitViolationMetrics(); + expect(metrics['trustline:ops:ip:10.0.0.1'].count).toBe(1); + }); + + test('error message includes TRUSTLINE_RATE_LIMIT code', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.message.code).toBe('TRUSTLINE_RATE_LIMIT'); + }); + + test('configures correct window and max values', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineOperationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.windowMs).toBe(TRUSTLINE_RATE_LIMIT_WINDOW_MS); + expect(config.max).toBe(TRUSTLINE_RATE_LIMIT_MAX); + }); +}); + +describe('TrustlineRateLimiter – Enhanced Verification Rate Limiter', () => { + beforeEach(() => { + vi.clearAllMocks(); + TrustlineRateLimiter.resetViolationMetrics(); + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('configures higher max than operation limiter', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.max).toBe(TRUSTLINE_VERIFICATION_RATE_LIMIT_MAX); + expect(config.max).toBeGreaterThan(TRUSTLINE_RATE_LIMIT_MAX); + }); + + test('skip function allows internal services', () => { + process.env.INTERNAL_SERVICE_TOKEN = 'svc'; + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { headers: { 'x-internal-service-token': 'svc' } }; + expect(config.skip(req)).toBe(true); + delete process.env.INTERNAL_SERVICE_TOKEN; + }); + + test('handler records violation and responds with 429', () => { + mockIpKeyGenerator.mockReturnValue('5.5.5.5'); + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + const req = { ip: '5.5.5.5', headers: {} }; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + const options = { statusCode: 429, message: { error: 'Too many verification requests.' } }; + + config.handler(req, res, vi.fn(), options); + + expect(res.status).toHaveBeenCalledWith(429); + const metrics = TrustlineRateLimiter.getRateLimitViolationMetrics(); + expect(metrics['trustline:verify:ip:5.5.5.5'].count).toBe(1); + }); + + test('error message includes TRUSTLINE_VERIFY_RATE_LIMIT code', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.message.code).toBe('TRUSTLINE_VERIFY_RATE_LIMIT'); + }); + + test('uses verification key generator (not operations key)', () => { + const rateLimitFactory = mockRateLimit; + TrustlineRateLimiter.createTrustlineVerificationRateLimit({ store: {}, rateLimitFactory }); + + const config = rateLimitFactory.mock.calls[0][0]; + expect(config.keyGenerator).toBe(TrustlineRateLimiter.getTrustlineVerificationKey); + }); +}); + +describe('TrustlineRateLimiter – Key Generation Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('operation key prefers merchantId over API key and IP', () => { + const req = { + merchant: { id: 'merch-xyz' }, + headers: { 'x-api-key': 'somekey' }, + ip: '9.9.9.9', + }; + const key = TrustlineRateLimiter.getTrustlineOperationKey(req); + expect(key).toBe('trustline:ops:merchant:merch-xyz'); + }); + + test('operation key prefers hashed API key over IP when no merchant', () => { + mockIpKeyGenerator.mockReturnValue('2.2.2.2'); + const req = { headers: { 'x-api-key': 'testkey' }, ip: '2.2.2.2' }; + const key = TrustlineRateLimiter.getTrustlineOperationKey(req); + expect(key).toMatch(/^trustline:ops:api:[a-f0-9]{16}$/); + }); + + test('operation key falls back to IP when no merchant or API key', () => { + mockIpKeyGenerator.mockReturnValue('3.3.3.3'); + const req = { ip: '3.3.3.3', headers: {} }; + const key = TrustlineRateLimiter.getTrustlineOperationKey(req); + expect(key).toBe('trustline:ops:ip:3.3.3.3'); + }); + + test('operation key uses socket remote address as fallback for IP', () => { + mockIpKeyGenerator.mockReturnValue('7.7.7.7'); + const req = { socket: { remoteAddress: '7.7.7.7' }, headers: {} }; + const key = TrustlineRateLimiter.getTrustlineOperationKey(req); + expect(key).toBe('trustline:ops:ip:7.7.7.7'); + }); + + test('operation key uses unknown-ip when no address available', () => { + mockIpKeyGenerator.mockReturnValue('unknown-ip'); + const key = TrustlineRateLimiter.getTrustlineOperationKey({}); + expect(key).toBe('trustline:ops:ip:unknown-ip'); + }); + + test('verification key uses merchantId when present', () => { + const req = { merchant: { id: 'verif-m' } }; + const key = TrustlineRateLimiter.getTrustlineVerificationKey(req); + expect(key).toBe('trustline:verify:merchant:verif-m'); + }); + + test('verification key falls back to IP', () => { + mockIpKeyGenerator.mockReturnValue('8.8.8.8'); + const req = { ip: '8.8.8.8' }; + const key = TrustlineRateLimiter.getTrustlineVerificationKey(req); + expect(key).toBe('trustline:verify:ip:8.8.8.8'); + }); + + test('hashed API keys are truncated to 16 hex characters', () => { + mockIpKeyGenerator.mockReturnValue('0.0.0.0'); + const req = { headers: { 'x-api-key': 'a-very-long-api-key-value' }, ip: '0.0.0.0' }; + const key = TrustlineRateLimiter.getTrustlineOperationKey(req); + const hashPart = key.replace('trustline:ops:api:', ''); + expect(hashPart).toHaveLength(16); + expect(hashPart).toMatch(/^[a-f0-9]+$/); + }); +}); diff --git a/backend/src/routes/trustlines.js b/backend/src/routes/trustlines.js index bcafdd93..0a049bc1 100644 --- a/backend/src/routes/trustlines.js +++ b/backend/src/routes/trustlines.js @@ -31,13 +31,19 @@ async function initializeRateLimiting() { // Graceful degradation - continue without rate limiting rateLimiters = { operations: (req, res, next) => next(), - verifications: (req, res, next) => next() + verifications: (req, res, next) => next(), + burst: (req, res, next) => next(), }; } } return rateLimiters; } +/** Clears the cached rate-limiter instance. Used only in tests. */ +export function _resetRateLimiters() { + rateLimiters = null; +} + // Validation middleware const validateTxHash = [ param('txHash') @@ -78,13 +84,17 @@ const validatePaginationParams = [ /** * POST /trustlines/verify/:txHash - * + * * Verify trustline transaction signature with enhanced cryptographic verification * Implements Task #595: Add cryptographic signature verification to Trustline Manager */ -router.post('/verify/:txHash', +router.post('/verify/:txHash', authenticateApiKey, validateTxHash, + async (req, res, next) => { + const limits = await initializeRateLimiting(); + limits.burst(req, res, next); + }, async (req, res, next) => { const limits = await initializeRateLimiting(); limits.verifications(req, res, next); @@ -155,12 +165,16 @@ router.post('/verify/:txHash', /** * GET /trustlines/config - * + * * Get merchant's trustline configuration with optimized queries * Implements Task #596: Optimize SQL queries in Trustline Manager */ router.get('/config', authenticateApiKey, + async (req, res, next) => { + const limits = await initializeRateLimiting(); + limits.burst(req, res, next); + }, async (req, res, next) => { const limits = await initializeRateLimiting(); limits.operations(req, res, next); @@ -196,12 +210,16 @@ router.get('/config', /** * GET /trustlines/assets/:assetCode/payments - * + * * Get payments for specific asset with optimized filtering * Implements Task #596: Optimize SQL queries in Trustline Manager */ router.get('/assets/:assetCode/payments', authenticateApiKey, + async (req, res, next) => { + const limits = await initializeRateLimiting(); + limits.burst(req, res, next); + }, async (req, res, next) => { const limits = await initializeRateLimiting(); limits.operations(req, res, next); @@ -295,12 +313,16 @@ router.get('/assets/:assetCode/payments', /** * GET /trustlines/stats - * + * * Get trustline statistics with optimized aggregation * Implements Task #596: Optimize SQL queries in Trustline Manager */ router.get('/stats', authenticateApiKey, + async (req, res, next) => { + const limits = await initializeRateLimiting(); + limits.burst(req, res, next); + }, async (req, res, next) => { const limits = await initializeRateLimiting(); limits.operations(req, res, next); @@ -363,12 +385,16 @@ router.get('/stats', /** * POST /trustlines/validate-asset - * + * * Validate asset against merchant's allowed issuers and payment limits * Implements enhanced validation with error recovery */ router.post('/validate-asset', authenticateApiKey, + async (req, res, next) => { + const limits = await initializeRateLimiting(); + limits.burst(req, res, next); + }, async (req, res, next) => { const limits = await initializeRateLimiting(); limits.operations(req, res, next); diff --git a/backend/src/routes/trustlines.test.js b/backend/src/routes/trustlines.test.js index bce066f3..d4a8edb1 100644 --- a/backend/src/routes/trustlines.test.js +++ b/backend/src/routes/trustlines.test.js @@ -1,11 +1,13 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import trustlinesRouter, { _resetRateLimiters } from "./trustlines.js"; const { mockVerifyTrustlineTransaction, mockLogTrustlineVerification, mockGetMerchantAllowedAssets, + mockGetMerchantTrustlineConfig, mockRequireApiKeyAuth, mockAuthMiddleware, mockConnectRedisClient, @@ -16,6 +18,7 @@ const { mockVerifyTrustlineTransaction: vi.fn(), mockLogTrustlineVerification: vi.fn(), mockGetMerchantAllowedAssets: vi.fn(), + mockGetMerchantTrustlineConfig: vi.fn(), mockAuthMiddleware: (req, _res, next) => { req.merchant = { id: "merchant_123", metadata: { tier: "basic" } }; next(); @@ -30,6 +33,7 @@ const { vi.mock("../lib/trustline-manager.js", () => ({ trustlineManager: { verifyTrustlineTransaction: mockVerifyTrustlineTransaction, + getMerchantTrustlineConfig: mockGetMerchantTrustlineConfig, queryOptimizer: { logTrustlineVerification: mockLogTrustlineVerification, getMerchantAllowedAssets: mockGetMerchantAllowedAssets, @@ -57,8 +61,6 @@ vi.mock("../lib/stellar.js", () => ({ isValidStellarAccountId: mockIsValidStellarAccountId, })); -import trustlinesRouter from "./trustlines.js"; - function createApp() { const app = express(); app.use(express.json()); @@ -67,6 +69,18 @@ function createApp() { } describe("Trustline routes", () => { + // Helper that returns a 429-blocking limiter for a specific slot + function blockingLimiter(slot) { + const pass = (_req, _res, next) => next(); + const block = (_req, res) => + res.status(429).json({ error: "Rate limit exceeded", code: "TEST_LIMIT" }); + return { + operations: slot === "operations" ? block : pass, + verifications: slot === "verifications" ? block : pass, + burst: slot === "burst" ? block : pass, + }; + } + beforeEach(() => { vi.clearAllMocks(); mockRequireApiKeyAuth.mockReturnValue(mockAuthMiddleware); @@ -74,6 +88,7 @@ describe("Trustline routes", () => { mockCreateTrustlineRateLimits.mockReturnValue({ operations: (_req, _res, next) => next(), verifications: (_req, _res, next) => next(), + burst: (_req, _res, next) => next(), }); mockIsValidAssetCode.mockReturnValue(true); mockIsValidStellarAccountId.mockReturnValue(true); @@ -117,3 +132,129 @@ describe("Trustline routes", () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Rate Limiting – Route-level tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("Trustline routes – rate limiting enforcement", () => { + beforeEach(() => { + // Reset the cached rate-limiter so each test controls which limiters fire + _resetRateLimiters(); + vi.clearAllMocks(); + mockRequireApiKeyAuth.mockReturnValue((req, _res, next) => { + req.merchant = { id: "merchant_123", metadata: { tier: "basic" } }; + next(); + }); + mockConnectRedisClient.mockResolvedValue({}); + mockIsValidAssetCode.mockReturnValue(true); + mockIsValidStellarAccountId.mockReturnValue(true); + }); + + function blockingLimiter(slot) { + const pass = (_req, _res, next) => next(); + const block = (_req, res) => + res.status(429).json({ error: "Rate limit exceeded", code: "TEST_LIMIT" }); + return { + operations: slot === "operations" ? block : pass, + verifications: slot === "verifications" ? block : pass, + burst: slot === "burst" ? block : pass, + }; + } + + it("POST /verify/:txHash returns 429 when verification limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("verifications")); + + const response = await request(createApp()) + .post(`/trustlines/verify/${"a".repeat(64)}`) + .send({ expectedOperation: "changeTrust" }); + + expect(response.status).toBe(429); + expect(response.body.code).toBe("TEST_LIMIT"); + }); + + it("POST /verify/:txHash returns 429 when burst limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("burst")); + + const response = await request(createApp()) + .post(`/trustlines/verify/${"b".repeat(64)}`) + .send({ expectedOperation: "changeTrust" }); + + expect(response.status).toBe(429); + }); + + it("GET /trustlines/config returns 429 when operations limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("operations")); + + const response = await request(createApp()).get("/trustlines/config"); + + expect(response.status).toBe(429); + }); + + it("GET /trustlines/config returns 429 when burst limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("burst")); + + const response = await request(createApp()).get("/trustlines/config"); + + expect(response.status).toBe(429); + }); + + it("GET /trustlines/stats returns 429 when burst limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("burst")); + + const response = await request(createApp()).get("/trustlines/stats"); + + expect(response.status).toBe(429); + }); + + it("POST /validate-asset returns 429 when burst limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("burst")); + + const response = await request(createApp()) + .post("/trustlines/validate-asset") + .send({ assetCode: "USDC" }); + + expect(response.status).toBe(429); + }); + + it("POST /validate-asset returns 429 when operations limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("operations")); + + const response = await request(createApp()) + .post("/trustlines/validate-asset") + .send({ assetCode: "USDC" }); + + expect(response.status).toBe(429); + }); + + it("GET /trustlines/assets/:assetCode/payments returns 429 when burst limiter triggers", async () => { + mockCreateTrustlineRateLimits.mockReturnValue(blockingLimiter("burst")); + + const response = await request(createApp()).get( + "/trustlines/assets/USDC/payments", + ); + + expect(response.status).toBe(429); + }); + + it("all rate limiters pass through for normal requests", async () => { + mockVerifyTrustlineTransaction.mockResolvedValue({ + valid: true, + reason: "ok", + trustlineSpecific: true, + }); + mockLogTrustlineVerification.mockResolvedValue({ rows: [] }); + mockCreateTrustlineRateLimits.mockReturnValue({ + operations: (_req, _res, next) => next(), + verifications: (_req, _res, next) => next(), + burst: (_req, _res, next) => next(), + }); + + const response = await request(createApp()) + .post(`/trustlines/verify/${"c".repeat(64)}`) + .send({ expectedOperation: "changeTrust" }); + + expect(response.status).toBe(200); + expect(response.body.verification.valid).toBe(true); + }); +});