diff --git a/REVEAL_PREDICTION_ISSUE_RESOLUTION.md b/REVEAL_PREDICTION_ISSUE_RESOLUTION.md new file mode 100644 index 0000000..6b4cd05 --- /dev/null +++ b/REVEAL_PREDICTION_ISSUE_RESOLUTION.md @@ -0,0 +1,94 @@ +# Reveal Prediction Tests - Issue Resolution Summary + +## Issue Description +Tests needed once Issue #1 (reveal_prediction implementation) is complete. + +## Acceptance Criteria +- ✅ Test valid reveal matches commitment +- ✅ Test invalid salt rejection +- ✅ Test double-reveal rejection +- ✅ Test reveal after closing time rejection + +## Resolution + +### What Was Done + +1. **Verified Existing Implementation** + - The `reveal_prediction` functionality was already fully implemented in `contracts/contracts/boxmeout/src/market.rs` + - All required tests were already present and passing + +2. **Code Quality Improvements** + - Fixed unused variable warning: `commit_hash2` → `_commit_hash2` + - Fixed unused import warning: Removed unused `Ledger` import + - Reduced compiler warnings from 3 to 1 (remaining warning is in unrelated amm.rs file) + +3. **Documentation** + - Created comprehensive test documentation: `REVEAL_PREDICTION_TESTS.md` + - Documented all 13 reveal-related tests + - Mapped each acceptance criterion to specific test functions + - Added test execution instructions + +### Test Coverage + +All acceptance criteria are fully covered: + +| Acceptance Criterion | Test Function | Status | +|---------------------|---------------|--------| +| Valid reveal matches commitment | `test_reveal_prediction_happy_path` | ✅ PASS | +| Invalid salt rejection | `test_reveal_rejects_wrong_salt` | ✅ PASS | +| Double-reveal rejection | `test_reveal_rejects_duplicate_reveal` | ✅ PASS | +| Reveal after closing time rejection | `test_reveal_rejects_after_closing_time` | ✅ PASS | + +### Additional Test Coverage + +Beyond the core requirements, the following edge cases are also tested: + +- No commitment rejection +- Wrong hash rejection (wrong outcome) +- Closed market rejection +- YES pool updates on reveal +- NO pool updates on reveal +- Full lifecycle (commit → reveal → resolve → claim) +- Multiple users with different outcomes + +### Test Results + +``` +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured +``` + +All 13 reveal-related tests pass successfully. + +## Changes Made + +### Files Modified +- `contracts/contracts/boxmeout/src/market.rs` - Fixed code warnings + +### Files Created +- `contracts/contracts/boxmeout/REVEAL_PREDICTION_TESTS.md` - Comprehensive test documentation +- `REVEAL_PREDICTION_ISSUE_RESOLUTION.md` - This summary document + +## Branch Information + +- **Branch:** `feature/reveal-prediction-tests` +- **Base:** `main` +- **Commit:** d652179 + +## How to Verify + +Run the tests: +```bash +cargo test reveal --manifest-path contracts/contracts/boxmeout/Cargo.toml +``` + +Expected output: All 13 tests pass. + +## Next Steps + +1. Review the pull request +2. Merge to main branch +3. Close the related issue + +## Conclusion + +All acceptance criteria for the reveal_prediction tests have been met. The implementation is production-ready with comprehensive test coverage, proper error handling, and clean code with minimal warnings. diff --git a/WALLET_RATE_LIMITING_IMPLEMENTATION.md b/WALLET_RATE_LIMITING_IMPLEMENTATION.md new file mode 100644 index 0000000..9891154 --- /dev/null +++ b/WALLET_RATE_LIMITING_IMPLEMENTATION.md @@ -0,0 +1,142 @@ +# Wallet-Based Rate Limiting Implementation + +## Summary + +Implemented wallet-based rate limiting with separate limits for authentication, predictions, and trades. All rate-limited responses now include the `Retry-After` header. + +## Changes Made + +### 1. Updated Rate Limiting Middleware (`backend/src/middleware/rateLimit.middleware.ts`) + +#### Key Changes: +- **Wallet-based key generation**: Rate limits now use wallet address (Stellar public key) instead of just IP +- **New rate limiters added**: + - `predictionRateLimiter`: 10 requests/min per wallet + - `tradeRateLimiter`: 30 requests/min per wallet +- **Updated existing limiters**: + - `authRateLimiter`: Changed from 10/15min to 5/min per wallet/IP + - All limiters now use wallet address when available +- **Retry-After header**: All rate-limited responses include `retryAfter` field and header + +#### New Helper Functions: +```typescript +// Get wallet address from authenticated request +function getWalletKey(req: any): string { + const authReq = req as AuthenticatedRequest; + return authReq.user?.publicKey || getIpKey(req); +} + +// Custom handler to add Retry-After header +function createRateLimitHandler(message: string) { + return (req: Request, res: Response) => { + const retryAfter = res.getHeader('Retry-After'); + res.status(429).json({ + ...rateLimitMessage(message), + retryAfter: retryAfter ? parseInt(retryAfter as string, 10) : undefined, + }); + }; +} +``` + +### 2. Updated Routes + +#### Markets Routes (`backend/src/routes/markets.routes.ts`) +- Added `apiRateLimiter` to all endpoints +- Added `tradeRateLimiter` to buy/sell share endpoints +- Added placeholder routes for buy-shares and sell-shares + +#### Predictions Routes (`backend/src/routes/predictions.ts`) +- Added `predictionRateLimiter` to commit and reveal endpoints +- Added `apiRateLimiter` to read-only endpoints +- Added placeholder routes for additional prediction operations + +### 3. Test Suite (`backend/src/middleware/__tests__/rateLimit.middleware.test.ts`) + +Created comprehensive test suite covering: +- Auth rate limiting (5/min per wallet) +- Prediction rate limiting (10/min per wallet) +- Trade rate limiting (30/min per wallet) +- Challenge rate limiting (5/min per public key) +- Retry-After header inclusion +- Wallet-based key isolation +- Standard rate limit headers + +### 4. Documentation (`backend/RATE_LIMITING.md`) + +Created comprehensive documentation covering: +- Rate limit configuration for all endpoints +- Implementation details +- Usage examples +- Testing guidelines +- Security considerations +- Future enhancements + +## Rate Limit Summary + +| Endpoint Type | Limit | Window | Key | Applies To | +|--------------|-------|--------|-----|------------| +| Auth | 5 | 1 min | Wallet/IP | `/api/auth/login` | +| Challenge | 5 | 1 min | Wallet/IP | `/api/auth/challenge` | +| Refresh | 10 | 1 min | IP | `/api/auth/refresh` | +| Predictions | 10 | 1 min | Wallet/IP | Commit/reveal endpoints | +| Trades | 30 | 1 min | Wallet/IP | Buy/sell shares | +| API General | 100 | 1 min | Wallet/IP | General endpoints | +| Sensitive Ops | 5 | 1 hour | Wallet/IP | Profile updates | + +## Response Format + +### Rate-Limited Response (429) +```json +{ + "success": false, + "error": { + "code": "RATE_LIMITED", + "message": "Too many prediction requests. Please slow down." + }, + "retryAfter": 45 +} +``` + +### Headers Included +- `RateLimit-Limit`: Maximum requests allowed +- `RateLimit-Remaining`: Requests remaining in window +- `RateLimit-Reset`: Unix timestamp when window resets +- `Retry-After`: Seconds until client can retry + +## Acceptance Criteria Met + +✅ **Rate limit by wallet address (not just IP)** +- Implemented `getWalletKey()` function that uses `publicKey` from authenticated user +- Falls back to IP for unauthenticated requests + +✅ **Separate limits: auth (5/min), predictions (10/min), trades (30/min)** +- `authRateLimiter`: 5 requests/min +- `predictionRateLimiter`: 10 requests/min +- `tradeRateLimiter`: 30 requests/min + +✅ **Return Retry-After header** +- Custom handler `createRateLimitHandler()` adds `Retry-After` header +- Response body includes `retryAfter` field with seconds value + +## Testing + +Run the test suite: +```bash +cd backend +npm test -- rateLimit.middleware.test.ts +``` + +## Security Benefits + +1. **Per-wallet limiting**: Prevents single wallet from abusing the system +2. **IP fallback**: Protects against unauthenticated abuse +3. **Separate limits**: Allows different thresholds for different operation types +4. **Redis-backed**: Distributed rate limiting across multiple server instances +5. **Retry-After**: Helps clients implement proper backoff strategies + +## Future Enhancements + +- Tier-based limits (FREE, PREMIUM, VIP users get different limits) +- Burst allowances for legitimate high-frequency trading +- Automatic temporary bans for repeated violations +- Rate limit analytics and monitoring dashboard diff --git a/backend/RATE_LIMITING.md b/backend/RATE_LIMITING.md new file mode 100644 index 0000000..3140ee8 --- /dev/null +++ b/backend/RATE_LIMITING.md @@ -0,0 +1,184 @@ +# Rate Limiting Implementation + +## Overview + +The BoxMeOut platform implements wallet-based rate limiting to prevent abuse while ensuring fair access for all users. Rate limits are applied per wallet address (Stellar public key) rather than just IP address, providing more accurate user-level throttling. + +## Rate Limit Configuration + +### Authentication Endpoints + +**authRateLimiter** +- **Limit**: 5 requests per minute +- **Key**: Wallet address (publicKey from request body) or IP +- **Applies to**: `/api/auth/login` +- **Purpose**: Prevent brute force attacks on authentication + +**challengeRateLimiter** +- **Limit**: 5 requests per minute +- **Key**: Wallet address (publicKey from request body) or IP +- **Applies to**: `/api/auth/challenge` +- **Purpose**: Prevent nonce generation spam + +**refreshRateLimiter** +- **Limit**: 10 requests per minute +- **Key**: IP address +- **Applies to**: `/api/auth/refresh` +- **Purpose**: Prevent token refresh abuse + +### Trading & Prediction Endpoints + +**predictionRateLimiter** +- **Limit**: 10 requests per minute +- **Key**: Wallet address (from authenticated user) or IP +- **Applies to**: Prediction commitment and reveal endpoints +- **Purpose**: Prevent prediction spam + +**tradeRateLimiter** +- **Limit**: 30 requests per minute +- **Key**: Wallet address (from authenticated user) or IP +- **Applies to**: Buy/sell share endpoints +- **Purpose**: Allow active trading while preventing abuse + +### General Endpoints + +**apiRateLimiter** +- **Limit**: 100 requests per minute +- **Key**: Wallet address (from authenticated user) or IP +- **Applies to**: General API endpoints +- **Purpose**: Protect against API abuse + +**sensitiveOperationRateLimiter** +- **Limit**: 5 requests per hour +- **Key**: Wallet address (from authenticated user) or IP +- **Applies to**: Sensitive operations (profile updates, etc.) +- **Purpose**: Prevent abuse of sensitive operations + +## Implementation Details + +### Wallet-Based Key Generation + +Rate limits are applied based on the user's wallet address (Stellar public key) when available: + +```typescript +function getWalletKey(req: any): string { + const authReq = req as AuthenticatedRequest; + // Use publicKey (wallet address) if available, otherwise fall back to IP + return authReq.user?.publicKey || getIpKey(req); +} +``` + +### Retry-After Header + +All rate-limited responses include a `Retry-After` header indicating when the client can retry: + +```json +{ + "success": false, + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Please slow down." + }, + "retryAfter": 45 +} +``` + +### Standard Headers + +The following standard rate limit headers are included in all responses: + +- `RateLimit-Limit`: Maximum number of requests allowed in the window +- `RateLimit-Remaining`: Number of requests remaining in the current window +- `RateLimit-Reset`: Unix timestamp when the rate limit window resets + +### Redis Storage + +Rate limit counters are stored in Redis with the following key format: + +``` +rl:{prefix}:{wallet_address_or_ip} +``` + +Examples: +- `rl:auth:GTEST123456789` +- `rl:predictions:GWALLET1` +- `rl:trades:192.168.1.1` + +### Fallback Behavior + +If Redis is unavailable, the rate limiter falls back to in-memory storage. This ensures the application continues to function even if Redis is down, though rate limits will not be shared across multiple server instances. + +## Usage in Routes + +### Example: Applying Rate Limiters + +```typescript +import { + authRateLimiter, + predictionRateLimiter, + tradeRateLimiter +} from '../middleware/rateLimit.middleware.js'; + +// Auth routes +router.post('/login', authRateLimiter, authController.login); +router.post('/challenge', challengeRateLimiter, authController.challenge); + +// Prediction routes +router.post('/predict', requireAuth, predictionRateLimiter, predictionsController.commit); +router.post('/reveal', requireAuth, predictionRateLimiter, predictionsController.reveal); + +// Trade routes +router.post('/buy-shares', requireAuth, tradeRateLimiter, marketsController.buyShares); +router.post('/sell-shares', requireAuth, tradeRateLimiter, marketsController.sellShares); +``` + +### Creating Custom Rate Limiters + +For endpoints with special requirements: + +```typescript +const customLimiter = createRateLimiter({ + windowMs: 60 * 1000, // 1 minute + max: 20, // 20 requests + prefix: 'custom', // Redis key prefix + message: 'Custom limit', // Error message + useWallet: true // Use wallet-based keys (default: true) +}); + +router.post('/custom-endpoint', requireAuth, customLimiter, controller.action); +``` + +## Testing + +Rate limiting is automatically disabled in test environments (`NODE_ENV === 'test'`) to avoid interfering with test execution. + +To test rate limiting manually: + +1. Make repeated requests to an endpoint +2. Observe the `RateLimit-*` headers in responses +3. Verify 429 status code when limit is exceeded +4. Check `Retry-After` header value + +## Monitoring + +Monitor rate limiting effectiveness by tracking: + +- Number of 429 responses per endpoint +- Most frequently rate-limited wallet addresses +- Redis key expiration and memory usage +- Rate limit bypass attempts + +## Security Considerations + +1. **Wallet Spoofing**: Rate limits use authenticated wallet addresses, which are verified through signature validation +2. **IP Fallback**: Unauthenticated requests fall back to IP-based limiting +3. **Redis Security**: Ensure Redis is properly secured and not publicly accessible +4. **DDoS Protection**: Rate limiting is one layer; use additional DDoS protection at the infrastructure level + +## Future Enhancements + +- Dynamic rate limits based on user tier (FREE, PREMIUM, VIP) +- Burst allowances for legitimate high-frequency trading +- Whitelist for trusted wallets/IPs +- Rate limit analytics dashboard +- Automatic ban for repeated violations diff --git a/backend/SECURITY_AUDIT.md b/backend/SECURITY_AUDIT.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/middleware/__tests__/rateLimit.middleware.test.ts b/backend/src/middleware/__tests__/rateLimit.middleware.test.ts new file mode 100644 index 0000000..ddd74eb --- /dev/null +++ b/backend/src/middleware/__tests__/rateLimit.middleware.test.ts @@ -0,0 +1,232 @@ +// backend/src/middleware/__tests__/rateLimit.middleware.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import express, { Request, Response } from 'express'; +import { + authRateLimiter, + predictionRateLimiter, + tradeRateLimiter, + challengeRateLimiter, +} from '../rateLimit.middleware.js'; +import { AuthenticatedRequest } from '../../types/auth.types.js'; + +// Mock Redis client +vi.mock('../../config/redis.js', () => ({ + getRedisClient: vi.fn(() => ({ + call: vi.fn(), + })), +})); + +describe('Rate Limit Middleware', () => { + let app: express.Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + }); + + describe('authRateLimiter', () => { + it('should limit auth requests to 5 per minute per wallet', async () => { + app.post('/auth', authRateLimiter, (req: Request, res: Response) => { + res.json({ success: true }); + }); + + const publicKey = 'GTEST123456789'; + + // Make 5 successful requests + for (let i = 0; i < 5; i++) { + const response = await request(app) + .post('/auth') + .send({ publicKey }) + .expect(200); + + expect(response.body.success).toBe(true); + } + + // 6th request should be rate limited + const response = await request(app) + .post('/auth') + .send({ publicKey }) + .expect(429); + + expect(response.body.error.code).toBe('RATE_LIMITED'); + expect(response.body.retryAfter).toBeDefined(); + }); + + it('should include Retry-After header in rate limit response', async () => { + app.post('/auth', authRateLimiter, (req: Request, res: Response) => { + res.json({ success: true }); + }); + + const publicKey = 'GTEST123456789'; + + // Exhaust rate limit + for (let i = 0; i < 5; i++) { + await request(app).post('/auth').send({ publicKey }); + } + + // Check rate limited response + const response = await request(app) + .post('/auth') + .send({ publicKey }) + .expect(429); + + expect(response.headers['retry-after']).toBeDefined(); + expect(response.body.retryAfter).toBeDefined(); + }); + }); + + describe('predictionRateLimiter', () => { + it('should limit prediction requests to 10 per minute per wallet', async () => { + // Mock authenticated middleware + app.use((req: Request, res: Response, next) => { + (req as AuthenticatedRequest).user = { + userId: 'user123', + publicKey: 'GTEST123456789', + tier: 'FREE', + }; + next(); + }); + + app.post( + '/predictions', + predictionRateLimiter, + (req: Request, res: Response) => { + res.json({ success: true }); + } + ); + + // Make 10 successful requests + for (let i = 0; i < 10; i++) { + const response = await request(app).post('/predictions').expect(200); + expect(response.body.success).toBe(true); + } + + // 11th request should be rate limited + const response = await request(app).post('/predictions').expect(429); + + expect(response.body.error.code).toBe('RATE_LIMITED'); + expect(response.body.error.message).toContain('prediction'); + }); + + it('should use wallet address as key for rate limiting', async () => { + app.use((req: Request, res: Response, next) => { + const publicKey = req.headers['x-wallet'] as string; + (req as AuthenticatedRequest).user = { + userId: 'user123', + publicKey: publicKey || 'GDEFAULT', + tier: 'FREE', + }; + next(); + }); + + app.post( + '/predictions', + predictionRateLimiter, + (req: Request, res: Response) => { + res.json({ success: true }); + } + ); + + // Wallet 1 makes 10 requests + for (let i = 0; i < 10; i++) { + await request(app) + .post('/predictions') + .set('x-wallet', 'GWALLET1') + .expect(200); + } + + // Wallet 1 is rate limited + await request(app) + .post('/predictions') + .set('x-wallet', 'GWALLET1') + .expect(429); + + // Wallet 2 can still make requests + const response = await request(app) + .post('/predictions') + .set('x-wallet', 'GWALLET2') + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); + + describe('tradeRateLimiter', () => { + it('should limit trade requests to 30 per minute per wallet', async () => { + app.use((req: Request, res: Response, next) => { + (req as AuthenticatedRequest).user = { + userId: 'user123', + publicKey: 'GTEST123456789', + tier: 'FREE', + }; + next(); + }); + + app.post('/trades', tradeRateLimiter, (req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Make 30 successful requests + for (let i = 0; i < 30; i++) { + const response = await request(app).post('/trades').expect(200); + expect(response.body.success).toBe(true); + } + + // 31st request should be rate limited + const response = await request(app).post('/trades').expect(429); + + expect(response.body.error.code).toBe('RATE_LIMITED'); + expect(response.body.error.message).toContain('trade'); + }); + }); + + describe('challengeRateLimiter', () => { + it('should limit challenge requests to 5 per minute per public key', async () => { + app.post( + '/challenge', + challengeRateLimiter, + (req: Request, res: Response) => { + res.json({ success: true }); + } + ); + + const publicKey = 'GTEST123456789'; + + // Make 5 successful requests + for (let i = 0; i < 5; i++) { + const response = await request(app) + .post('/challenge') + .send({ publicKey }) + .expect(200); + + expect(response.body.success).toBe(true); + } + + // 6th request should be rate limited + const response = await request(app) + .post('/challenge') + .send({ publicKey }) + .expect(429); + + expect(response.body.error.code).toBe('RATE_LIMITED'); + }); + }); + + describe('Rate limit headers', () => { + it('should include standard rate limit headers', async () => { + app.post('/auth', authRateLimiter, (req: Request, res: Response) => { + res.json({ success: true }); + }); + + const response = await request(app) + .post('/auth') + .send({ publicKey: 'GTEST' }) + .expect(200); + + expect(response.headers['ratelimit-limit']).toBeDefined(); + expect(response.headers['ratelimit-remaining']).toBeDefined(); + expect(response.headers['ratelimit-reset']).toBeDefined(); + }); + }); +}); diff --git a/backend/src/middleware/rateLimit.middleware.ts b/backend/src/middleware/rateLimit.middleware.ts index 550a9d4..0f55210 100644 --- a/backend/src/middleware/rateLimit.middleware.ts +++ b/backend/src/middleware/rateLimit.middleware.ts @@ -4,6 +4,7 @@ import { getRedisClient } from '../config/redis.js'; import { AuthenticatedRequest } from '../types/auth.types.js'; import { logger } from '../utils/logger.js'; import { ipKeyGenerator } from 'express-rate-limit'; +import { Request, Response, NextFunction } from 'express'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type RateLimiterMiddleware = any; @@ -55,21 +56,47 @@ function getIpKey(req: any): string { } } +/** + * Get wallet address from authenticated request + * Falls back to IP if wallet not available + */ +function getWalletKey(req: any): string { + const authReq = req as AuthenticatedRequest; + // Use publicKey (wallet address) if available, otherwise fall back to IP + return authReq.user?.publicKey || getIpKey(req); +} + +/** + * Custom handler to add Retry-After header + */ +function createRateLimitHandler(message: string) { + return (req: Request, res: Response) => { + const retryAfter = res.getHeader('Retry-After'); + res.status(429).json({ + ...rateLimitMessage(message), + retryAfter: retryAfter ? parseInt(retryAfter as string, 10) : undefined, + }); + }; +} + /** * Rate limiter for authentication endpoints (strict) * Prevents brute force attacks on login * - * Limits: 10 attempts per 15 minutes per IP + * Limits: 5 attempts per minute per wallet/IP */ export const authRateLimiter: RateLimiterMiddleware = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10, + windowMs: 60 * 1000, // 1 minute + max: 5, standardHeaders: true, // Return rate limit info in RateLimit-* headers legacyHeaders: false, // Disable X-RateLimit-* headers store: createRedisStore('auth'), - keyGenerator: (req: any) => getIpKey(req), - message: rateLimitMessage( - 'Too many authentication attempts. Please try again in 15 minutes.' + keyGenerator: (req: any) => { + // For auth endpoints, use publicKey from body if available + return req.body?.publicKey || getIpKey(req); + }, + handler: createRateLimitHandler( + 'Too many authentication attempts. Please try again later.' ), skip: () => process.env.NODE_ENV === 'test', // Skip in tests }); @@ -90,17 +117,51 @@ export const challengeRateLimiter: RateLimiterMiddleware = rateLimit({ // For challenge endpoint, use publicKey if available, otherwise IP return req.body?.publicKey || getIpKey(req); }, - message: rateLimitMessage( + handler: createRateLimitHandler( 'Too many challenge requests. Please wait a moment.' ), skip: () => process.env.NODE_ENV === 'test', }); +/** + * Rate limiter for prediction endpoints + * Limits: 10 requests per minute per wallet address + */ +export const predictionRateLimiter: RateLimiterMiddleware = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 10, + standardHeaders: true, + legacyHeaders: false, + store: createRedisStore('predictions'), + keyGenerator: getWalletKey, + handler: createRateLimitHandler( + 'Too many prediction requests. Please slow down.' + ), + skip: () => process.env.NODE_ENV === 'test', +}); + +/** + * Rate limiter for trade endpoints (buy/sell shares) + * Limits: 30 requests per minute per wallet address + */ +export const tradeRateLimiter: RateLimiterMiddleware = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 30, + standardHeaders: true, + legacyHeaders: false, + store: createRedisStore('trades'), + keyGenerator: getWalletKey, + handler: createRateLimitHandler( + 'Too many trade requests. Please slow down.' + ), + skip: () => process.env.NODE_ENV === 'test', +}); + /** * Rate limiter for general API endpoints (lenient) * Protects against API abuse while allowing normal usage * - * Limits: 100 requests per minute per user or IP + * Limits: 100 requests per minute per wallet or IP */ export const apiRateLimiter: RateLimiterMiddleware = rateLimit({ windowMs: 60 * 1000, // 1 minute @@ -108,11 +169,8 @@ export const apiRateLimiter: RateLimiterMiddleware = rateLimit({ standardHeaders: true, legacyHeaders: false, store: createRedisStore('api'), - keyGenerator: (req: any) => { - const authReq = req as AuthenticatedRequest; - return authReq.user?.userId || getIpKey(req); - }, - message: rateLimitMessage('Too many requests. Please slow down.'), + keyGenerator: getWalletKey, + handler: createRateLimitHandler('Too many requests. Please slow down.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -129,7 +187,7 @@ export const refreshRateLimiter: RateLimiterMiddleware = rateLimit({ legacyHeaders: false, store: createRedisStore('refresh'), keyGenerator: (req: any) => getIpKey(req), - message: rateLimitMessage('Too many refresh attempts.'), + handler: createRateLimitHandler('Too many refresh attempts.'), skip: () => process.env.NODE_ENV === 'test', }); @@ -137,7 +195,7 @@ export const refreshRateLimiter: RateLimiterMiddleware = rateLimit({ * Rate limiter for sensitive operations (very strict) * Use for actions like changing email, connecting new wallet, etc. * - * Limits: 5 requests per hour per user + * Limits: 5 requests per hour per wallet */ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour @@ -145,11 +203,8 @@ export const sensitiveOperationRateLimiter: RateLimiterMiddleware = rateLimit({ standardHeaders: true, legacyHeaders: false, store: createRedisStore('sensitive'), - keyGenerator: (req: any) => { - const authReq = req as AuthenticatedRequest; - return authReq.user?.userId || getIpKey(req); - }, - message: rateLimitMessage( + keyGenerator: getWalletKey, + handler: createRateLimitHandler( 'Too many sensitive operations. Please try again later.' ), skip: () => process.env.NODE_ENV === 'test', @@ -164,6 +219,7 @@ export function createRateLimiter(options: { max: number; prefix: string; message?: string; + useWallet?: boolean; }): RateLimiterMiddleware { return rateLimit({ windowMs: options.windowMs, @@ -171,11 +227,10 @@ export function createRateLimiter(options: { standardHeaders: true, legacyHeaders: false, store: createRedisStore(options.prefix), - keyGenerator: (req: any) => { - const authReq = req as AuthenticatedRequest; - return authReq.user?.userId || getIpKey(req); - }, - message: rateLimitMessage(options.message || 'Rate limit exceeded.'), + keyGenerator: options.useWallet !== false ? getWalletKey : getIpKey, + handler: createRateLimitHandler( + options.message || 'Rate limit exceeded.' + ), skip: () => process.env.NODE_ENV === 'test', }); } diff --git a/backend/src/middleware/validation.middleware.ts b/backend/src/middleware/validation.middleware.ts index 37f411f..fc6d9fa 100644 --- a/backend/src/middleware/validation.middleware.ts +++ b/backend/src/middleware/validation.middleware.ts @@ -64,8 +64,20 @@ export const schemas = { // Market schemas createMarket: z.object({ - title: z.string().min(10).max(200), - description: z.string().min(20).max(2000), + title: z + .string() + .min(5) + .max(200) + .refine((val) => val.trim().length >= 5, { + message: 'Title must be at least 5 characters after trimming', + }), + description: z + .string() + .min(10) + .max(5000) + .refine((val) => val.trim().length >= 10, { + message: 'Description must be at least 10 characters after trimming', + }), category: z.enum([ 'WRESTLING', 'BOXING', @@ -75,16 +87,32 @@ export const schemas = { 'CRYPTO', 'ENTERTAINMENT', ]), - outcomeA: z.string().min(5).max(100), - outcomeB: z.string().min(5).max(100), + outcomeA: z.string().min(1).max(100), + outcomeB: z.string().min(1).max(100), closingAt: z.string().datetime(), - resolutionSource: z.string().max(500).optional(), + resolutionTime: z.string().datetime().optional(), }), // Pagination pagination: z.object({ - page: z.string().regex(/^\d+$/).transform(Number).optional().default('1'), - limit: z.string().regex(/^\d+$/).transform(Number).optional().default('20'), + page: z + .string() + .regex(/^\d+$/) + .transform(Number) + .refine((val) => val >= 1 && val <= 10000, { + message: 'Page must be between 1 and 10000', + }) + .optional() + .default('1'), + limit: z + .string() + .regex(/^\d+$/) + .transform(Number) + .refine((val) => val >= 1 && val <= 100, { + message: 'Limit must be between 1 and 100', + }) + .optional() + .default('20'), sort: z.string().optional(), order: z.enum(['asc', 'desc']).optional().default('desc'), }), @@ -94,13 +122,119 @@ export const schemas = { id: z.string().uuid(), }), - // Stellar address + // Stellar address (strict base32 validation) stellarAddress: z.object({ - address: z.string().regex(/^G[A-Z0-9]{55}$/), + address: z.string().regex(/^G[A-Z2-7]{55}$/, { + message: 'Invalid Stellar address format', + }), }), - // Wallet challenge + // Wallet challenge (strict base32 validation) walletChallenge: z.object({ - publicKey: z.string().regex(/^G[A-Z0-9]{55}$/), + publicKey: z.string().regex(/^G[A-Z2-7]{55}$/, { + message: 'Invalid Stellar public key format', + }), + }), + + // Prediction schemas + commitPrediction: z.object({ + predictedOutcome: z + .number() + .int() + .min(0) + .max(1) + .refine((val) => val === 0 || val === 1, { + message: 'Predicted outcome must be 0 or 1', + }), + amountUsdc: z + .number() + .positive() + .finite() + .max(922337203685.4775807) + .refine((val) => val > 0, { + message: 'Amount must be greater than 0', + }), + }), + + revealPrediction: z.object({ + predictionId: z.string().uuid(), + }), + + // Pool creation + createPool: z.object({ + initialLiquidity: z + .string() + .regex(/^\d+$/) + .refine((val) => BigInt(val) > 0n, { + message: 'Initial liquidity must be greater than 0', + }) + .refine((val) => BigInt(val) <= BigInt(Number.MAX_SAFE_INTEGER), { + message: 'Initial liquidity exceeds maximum safe value', + }), + }), + + // Buy/Sell shares + tradeShares: z.object({ + outcome: z + .number() + .int() + .min(0) + .max(1) + .refine((val) => val === 0 || val === 1, { + message: 'Outcome must be 0 or 1', + }), + amount: z + .number() + .positive() + .finite() + .max(922337203685.4775807) + .refine((val) => val > 0, { + message: 'Amount must be greater than 0', + }), + }), + + // Oracle attestation + attestMarket: z.object({ + outcome: z + .number() + .int() + .min(0) + .max(1) + .refine((val) => val === 0 || val === 1, { + message: 'Outcome must be 0 or 1', + }), + }), + + // Treasury distribution + distributeLeaderboard: z.object({ + recipients: z + .array( + z.object({ + address: z.string().regex(/^G[A-Z2-7]{55}$/, { + message: 'Invalid Stellar address format', + }), + amount: z + .string() + .regex(/^\d+$/) + .refine((val) => BigInt(val) > 0n, { + message: 'Amount must be greater than 0', + }), + }) + ) + .min(1) + .max(100), + }), + + distributeCreator: z.object({ + marketId: z.string().uuid(), + creatorAddress: z.string().regex(/^G[A-Z2-7]{55}$/, { + message: 'Invalid Stellar address format', + }), + amount: z + .string() + .regex(/^\d+$/) + .refine((val) => BigInt(val) > 0n, { + message: 'Amount must be greater than 0', + }), }), }; diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 8afdbc3..de375a8 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -6,6 +6,7 @@ import { challengeRateLimiter, refreshRateLimiter, } from '../middleware/rateLimit.middleware.js'; +import { validate, schemas } from '../middleware/validation.middleware.js'; const router = Router(); @@ -16,8 +17,11 @@ const router = Router(); * @body { publicKey: string } * @returns { nonce: string, message: string, expiresAt: number } */ -router.post('/challenge', challengeRateLimiter, (req, res) => - authController.challenge(req, res) +router.post( + '/challenge', + challengeRateLimiter, + validate({ body: schemas.walletChallenge }), + (req, res) => authController.challenge(req, res) ); /** diff --git a/backend/src/routes/markets.routes.ts b/backend/src/routes/markets.routes.ts index e36124a..804c8aa 100644 --- a/backend/src/routes/markets.routes.ts +++ b/backend/src/routes/markets.routes.ts @@ -4,6 +4,8 @@ import { Router } from 'express'; import { marketsController } from '../controllers/markets.controller.js'; import { requireAuth, optionalAuth } from '../middleware/auth.middleware.js'; +import { apiRateLimiter, tradeRateLimiter } from '../middleware/rateLimit.middleware.js'; +import { validate, schemas } from '../middleware/validation.middleware.js'; const router = Router(); @@ -11,32 +13,72 @@ const router = Router(); * POST /api/markets - Create new market * Requires authentication and wallet connection */ -router.post('/', requireAuth, (req, res) => - marketsController.createMarket(req, res) +router.post( + '/', + requireAuth, + apiRateLimiter, + validate({ body: schemas.createMarket }), + (req, res) => marketsController.createMarket(req, res) ); /** * GET /api/markets - List all markets * Optional authentication for personalized results */ -router.get('/', optionalAuth, (req, res) => - marketsController.listMarkets(req, res) +router.get( + '/', + optionalAuth, + apiRateLimiter, + validate({ query: schemas.pagination }), + (req, res) => marketsController.listMarkets(req, res) ); /** * GET /api/markets/:id - Get market details * Optional authentication for personalized data */ -router.get('/:id', optionalAuth, (req, res) => - marketsController.getMarketDetails(req, res) +router.get( + '/:id', + optionalAuth, + apiRateLimiter, + validate({ params: schemas.idParam }), + (req, res) => marketsController.getMarketDetails(req, res) ); /** * POST /api/markets/:id/pool - Create AMM pool for a market * Requires authentication and admin/operator privileges (uses admin signer) */ -router.post('/:id/pool', requireAuth, (req, res) => - marketsController.createPool(req, res) +router.post( + '/:id/pool', + requireAuth, + apiRateLimiter, + validate({ params: schemas.idParam, body: schemas.createPool }), + (req, res) => marketsController.createPool(req, res) +); + +/** + * POST /api/markets/:id/buy-shares - Buy shares in a market + * Requires authentication, uses trade rate limiter (30/min per wallet) + */ +router.post( + '/:id/buy-shares', + requireAuth, + tradeRateLimiter, + validate({ params: schemas.idParam, body: schemas.tradeShares }), + (req, res) => marketsController.buyShares(req, res) +); + +/** + * POST /api/markets/:id/sell-shares - Sell shares in a market + * Requires authentication, uses trade rate limiter (30/min per wallet) + */ +router.post( + '/:id/sell-shares', + requireAuth, + tradeRateLimiter, + validate({ params: schemas.idParam, body: schemas.tradeShares }), + (req, res) => marketsController.sellShares(req, res) ); export default router; diff --git a/backend/src/routes/oracle.ts b/backend/src/routes/oracle.ts index 3d3f9d1..3390ef4 100644 --- a/backend/src/routes/oracle.ts +++ b/backend/src/routes/oracle.ts @@ -4,28 +4,38 @@ import { Router } from 'express'; import { oracleController } from '../controllers/oracle.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; +import { validate, schemas } from '../middleware/validation.middleware.js'; const router = Router(); /** * POST /api/markets/:id/attest - Submit oracle attestation */ -router.post('/:id/attest', requireAuth, (req, res) => - oracleController.attestMarket(req, res) +router.post( + '/:id/attest', + requireAuth, + validate({ params: schemas.idParam, body: schemas.attestMarket }), + (req, res) => oracleController.attestMarket(req, res) ); /** * POST /api/markets/:id/resolve - Trigger market resolution */ -router.post('/:id/resolve', requireAuth, (req, res) => - oracleController.resolveMarket(req, res) +router.post( + '/:id/resolve', + requireAuth, + validate({ params: schemas.idParam }), + (req, res) => oracleController.resolveMarket(req, res) ); /** * POST /api/markets/:id/claim - Claim winnings for a resolved market */ -router.post('/:id/claim', requireAuth, (req, res) => - oracleController.claimWinnings(req, res) +router.post( + '/:id/claim', + requireAuth, + validate({ params: schemas.idParam }), + (req, res) => oracleController.claimWinnings(req, res) ); export default router; diff --git a/backend/src/routes/predictions.ts b/backend/src/routes/predictions.ts index 2259a15..19f030d 100644 --- a/backend/src/routes/predictions.ts +++ b/backend/src/routes/predictions.ts @@ -4,25 +4,52 @@ import { Router } from 'express'; import { predictionsController } from '../controllers/predictions.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; +import { predictionRateLimiter, apiRateLimiter } from '../middleware/rateLimit.middleware.js'; const router = Router(); /** * POST /api/markets/:marketId/commit - Commit Prediction (Phase 1) * Server generates and stores salt securely + * Rate limit: 10 requests per minute per wallet */ -router.post('/:marketId/commit', requireAuth, (req, res) => +router.post('/:marketId/commit', requireAuth, predictionRateLimiter, (req, res) => predictionsController.commitPrediction(req, res) ); /** * POST /api/markets/:marketId/reveal - Reveal Prediction (Phase 2) * Server provides stored salt for blockchain verification + * Rate limit: 10 requests per minute per wallet */ -router.post('/:marketId/reveal', requireAuth, (req, res) => +router.post('/:marketId/reveal', requireAuth, predictionRateLimiter, (req, res) => predictionsController.revealPrediction(req, res) ); +/** + * GET /api/markets/:marketId/predictions - Get Market Predictions + * Rate limit: 100 requests per minute per wallet/IP + */ +router.get('/:marketId/predictions', apiRateLimiter, (req, res) => + predictionsController.getMarketPredictions(req, res) +); + +/** + * GET /api/users/:userId/positions - Get User Positions + * Rate limit: 100 requests per minute per wallet/IP + */ +router.get('/users/:userId/positions', requireAuth, apiRateLimiter, (req, res) => + predictionsController.getUserPositions(req, res) +); + +/** + * POST /api/users/:userId/claim-winnings - Claim Winnings + * Rate limit: 10 requests per minute per wallet + */ +router.post('/users/:userId/claim-winnings', requireAuth, predictionRateLimiter, (req, res) => + predictionsController.claimWinnings(req, res) +); + export default router; /* diff --git a/backend/src/routes/treasury.routes.ts b/backend/src/routes/treasury.routes.ts index 02655cc..d8ad9b4 100644 --- a/backend/src/routes/treasury.routes.ts +++ b/backend/src/routes/treasury.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { treasuryController } from '../controllers/treasury.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { requireAdmin } from '../middleware/admin.middleware.js'; +import { validate, schemas } from '../middleware/validation.middleware.js'; const router = Router(); @@ -9,12 +10,20 @@ router.get('/balances', requireAuth, (req, res) => treasuryController.getBalances(req, res) ); -router.post('/distribute-leaderboard', requireAuth, requireAdmin, (req, res) => - treasuryController.distributeLeaderboard(req, res) +router.post( + '/distribute-leaderboard', + requireAuth, + requireAdmin, + validate({ body: schemas.distributeLeaderboard }), + (req, res) => treasuryController.distributeLeaderboard(req, res) ); -router.post('/distribute-creator', requireAuth, requireAdmin, (req, res) => - treasuryController.distributeCreator(req, res) +router.post( + '/distribute-creator', + requireAuth, + requireAdmin, + validate({ body: schemas.distributeCreator }), + (req, res) => treasuryController.distributeCreator(req, res) ); export default router; diff --git a/backend/src/services/market.service.ts b/backend/src/services/market.service.ts index 9520b13..73f674e 100644 --- a/backend/src/services/market.service.ts +++ b/backend/src/services/market.service.ts @@ -6,6 +6,11 @@ import { executeTransaction } from '../database/transaction.js'; import { logger } from '../utils/logger.js'; import { factoryService } from './blockchain/factory.js'; import { ammService } from './blockchain/amm.js'; +import { + sanitizeMarketTitle, + sanitizeMarketDescription, + validateNumericInput, +} from '../utils/sanitization.js'; export class MarketService { private marketRepository: MarketRepository; @@ -67,21 +72,36 @@ export class MarketService { closingAt: Date; resolutionTime?: Date; }) { + // Sanitize title and description to prevent XSS + const sanitizedTitle = sanitizeMarketTitle(data.title); + const sanitizedDescription = sanitizeMarketDescription(data.description); + const sanitizedOutcomeA = sanitizeMarketTitle(data.outcomeA); + const sanitizedOutcomeB = sanitizeMarketTitle(data.outcomeB); + // Validate closing time is in the future if (data.closingAt <= new Date()) { throw new Error('Closing time must be in the future'); } - // Validate title length - if (data.title.length < 5 || data.title.length > 200) { + // Validate title length after sanitization + if (sanitizedTitle.length < 5 || sanitizedTitle.length > 200) { throw new Error('Title must be between 5 and 200 characters'); } - // Validate description length - if (data.description.length < 10 || data.description.length > 5000) { + // Validate description length after sanitization + if (sanitizedDescription.length < 10 || sanitizedDescription.length > 5000) { throw new Error('Description must be between 10 and 5000 characters'); } + // Validate outcome lengths + if (sanitizedOutcomeA.length < 1 || sanitizedOutcomeA.length > 100) { + throw new Error('Outcome A must be between 1 and 100 characters'); + } + + if (sanitizedOutcomeB.length < 1 || sanitizedOutcomeB.length > 100) { + throw new Error('Outcome B must be between 1 and 100 characters'); + } + // Default resolution time to 24 hours after closing if not provided const resolutionTime = data.resolutionTime || @@ -93,25 +113,25 @@ export class MarketService { } try { - // Call blockchain factory to create market on-chain + // Call blockchain factory to create market on-chain with sanitized data const blockchainResult = await factoryService.createMarket({ - title: data.title, - description: data.description, + title: sanitizedTitle, + description: sanitizedDescription, category: data.category, closingTime: data.closingAt, resolutionTime: resolutionTime, creator: data.creatorPublicKey, }); - // Store market in database with transaction hash + // Store market in database with sanitized data const market = await this.marketRepository.createMarket({ contractAddress: blockchainResult.marketId, - title: data.title, - description: data.description, + title: sanitizedTitle, + description: sanitizedDescription, category: data.category, creatorId: data.creatorId, - outcomeA: data.outcomeA, - outcomeB: data.outcomeB, + outcomeA: sanitizedOutcomeA, + outcomeB: sanitizedOutcomeB, closingAt: data.closingAt, }); @@ -150,11 +170,28 @@ export class MarketService { skip?: number; take?: number; }) { + // Validate pagination parameters to prevent overflow + const skip = options?.skip + ? validateNumericInput(options.skip, { + min: 0, + max: 100000, + allowDecimals: false, + }) + : 0; + + const take = options?.take + ? validateNumericInput(options.take, { + min: 1, + max: 100, + allowDecimals: false, + }) + : 20; + if (options?.status === MarketStatus.OPEN) { return await this.marketRepository.findActiveMarkets({ category: options.category, - skip: options.skip, - take: options.take, + skip, + take, }); } @@ -164,8 +201,8 @@ export class MarketService { ...(options?.status && { status: options.status }), }, orderBy: { createdAt: 'desc' }, - skip: options?.skip, - take: options?.take || 20, + skip, + take, }); } diff --git a/backend/src/services/prediction.service.ts b/backend/src/services/prediction.service.ts index 4cda04e..6e1180e 100644 --- a/backend/src/services/prediction.service.ts +++ b/backend/src/services/prediction.service.ts @@ -10,6 +10,10 @@ import { encrypt, decrypt, } from '../utils/crypto.js'; +import { + validateNumericInput, + validateOutcome, +} from '../utils/sanitization.js'; export class PredictionService { private predictionRepository: PredictionRepository; @@ -32,6 +36,15 @@ export class PredictionService { predictedOutcome: number, amountUsdc: number ) { + // Validate and sanitize inputs + const validatedOutcome = validateOutcome(predictedOutcome); + const validatedAmount = validateNumericInput(amountUsdc, { + min: 0.0000001, + max: 922337203685.4775807, + allowZero: false, + allowDecimals: true, + }); + // Validate market exists and is open const market = await this.marketRepository.findById(marketId); if (!market) { @@ -55,23 +68,13 @@ export class PredictionService { throw new Error('User already has a prediction for this market'); } - // Validate amount - if (amountUsdc <= 0) { - throw new Error('Amount must be greater than 0'); - } - - // Validate outcome - if (![0, 1].includes(predictedOutcome)) { - throw new Error('Predicted outcome must be 0 (NO) or 1 (YES)'); - } - // Check user balance const user = await this.userRepository.findById(userId); if (!user) { throw new Error('User not found'); } - if (Number(user.usdcBalance) < amountUsdc) { + if (Number(user.usdcBalance) < validatedAmount) { throw new Error('Insufficient balance'); } @@ -80,7 +83,7 @@ export class PredictionService { const commitmentHash = createCommitmentHash( userId, marketId, - predictedOutcome, + validatedOutcome, salt ); @@ -91,7 +94,7 @@ export class PredictionService { // const txHash = await blockchainService.commitPrediction( // marketId, // commitmentHash, - // amountUsdc + // validatedAmount // ); const txHash = 'mock-tx-hash-' + Date.now(); // Mock for now diff --git a/backend/src/utils/__tests__/sanitization.test.ts b/backend/src/utils/__tests__/sanitization.test.ts new file mode 100644 index 0000000..912c18c --- /dev/null +++ b/backend/src/utils/__tests__/sanitization.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from '@jest/globals'; +import { + sanitizeString, + sanitizeMarketTitle, + sanitizeMarketDescription, + validateNumericInput, + validateStellarAddress, + validateOutcome, + validateUsdcAmount, +} from '../sanitization'; + +describe('Sanitization Utilities', () => { + describe('sanitizeString', () => { + it('should escape HTML special characters', () => { + const input = ''; + const result = sanitizeString(input); + expect(result).not.toContain('Market'; + const result = sanitizeMarketTitle(input); + expect(result).not.toContain('