diff --git a/backend/src/controllers/trading.controller.ts b/backend/src/controllers/trading.controller.ts index 422e38dd..e3c69ba3 100644 --- a/backend/src/controllers/trading.controller.ts +++ b/backend/src/controllers/trading.controller.ts @@ -25,19 +25,11 @@ export class TradingController { const marketId = req.params.marketId as string; const { outcome, amount, minShares } = req.body; - // Validation - if (outcome === undefined || !amount) { - res - .status(400) - .json({ success: false, error: 'Missing outcome or amount' }); - return; - } - const xdr = await tradingService.buildBuySharesTx( userId, userPublicKey, marketId, - Number(outcome), + outcome, BigInt(amount), BigInt(minShares || 0) ); @@ -71,47 +63,13 @@ export class TradingController { const marketId = req.params.marketId as string; const { outcome, amount, minShares } = req.body; - // Validate input - if (outcome === undefined || outcome === null) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'outcome is required (0 for NO, 1 for YES)', - }, - }); - return; - } - - if (!amount || amount <= 0) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'amount must be greater than 0', - }, - }); - return; - } - - if (![0, 1].includes(outcome)) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'outcome must be 0 (NO) or 1 (YES)', - }, - }); - return; - } - // Call service const result = await tradingService.buyShares({ userId, marketId, outcome, - amount, - minShares, + amount: Number(amount), + minShares: minShares ? Number(minShares) : undefined, }); res.status(201).json({ @@ -179,18 +137,11 @@ export class TradingController { const marketId = req.params.marketId as string; const { outcome, shares, minPayout } = req.body; - if (outcome === undefined || !shares) { - res - .status(400) - .json({ success: false, error: 'Missing outcome or shares' }); - return; - } - const xdr = await tradingService.buildSellSharesTx( userId, userPublicKey, marketId, - Number(outcome), + outcome, BigInt(shares), BigInt(minPayout || 0) ); @@ -224,47 +175,13 @@ export class TradingController { const marketId = req.params.marketId as string; const { outcome, shares, minPayout } = req.body; - // Validate input - if (outcome === undefined || outcome === null) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'outcome is required (0 for NO, 1 for YES)', - }, - }); - return; - } - - if (!shares || shares <= 0) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'shares must be greater than 0', - }, - }); - return; - } - - if (![0, 1].includes(outcome)) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'outcome must be 0 (NO) or 1 (YES)', - }, - }); - return; - } - // Call service const result = await tradingService.sellShares({ userId, marketId, outcome, - shares, - minPayout, + shares: Number(shares), + minPayout: minPayout ? Number(minPayout) : undefined, }); res.status(200).json({ @@ -425,46 +342,10 @@ export class TradingController { const marketId = req.params.marketId as string; const { usdcAmount } = req.body; - if (!usdcAmount) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'usdcAmount is required', - }, - }); - return; - } - - let parsedAmount: bigint; - try { - parsedAmount = BigInt(usdcAmount); - } catch { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'usdcAmount must be a valid integer string', - }, - }); - return; - } - - if (parsedAmount <= BigInt(0)) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'usdcAmount must be greater than 0', - }, - }); - return; - } - const result = await tradingService.addLiquidity( userId, marketId, - parsedAmount + BigInt(usdcAmount) ); res.status(200).json({ @@ -521,46 +402,10 @@ export class TradingController { const marketId = req.params.marketId as string; const { lpTokens } = req.body; - if (!lpTokens) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'lpTokens is required', - }, - }); - return; - } - - let parsedTokens: bigint; - try { - parsedTokens = BigInt(lpTokens); - } catch { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'lpTokens must be a valid integer string', - }, - }); - return; - } - - if (parsedTokens <= BigInt(0)) { - res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'lpTokens must be greater than 0', - }, - }); - return; - } - const result = await tradingService.removeLiquidity( userId, marketId, - parsedTokens + BigInt(lpTokens) ); res.status(200).json({ diff --git a/backend/src/routes/trading.ts b/backend/src/routes/trading.ts index b3267d6e..ccbdd62e 100644 --- a/backend/src/routes/trading.ts +++ b/backend/src/routes/trading.ts @@ -5,6 +5,14 @@ import { Router } from 'express'; import { tradingController } from '../controllers/trading.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { tradeRateLimiter } from '../middleware/rateLimit.middleware.js'; +import { validate } from '../middleware/validation.middleware.js'; +import { + marketIdParam, + buySharesBody, + sellSharesBody, + addLiquidityBody, + removeLiquidityBody, +} from '../schemas/validation.schemas.js'; const router: Router = Router(); @@ -84,8 +92,11 @@ const router: Router = Router(); * 404: * $ref: '#/components/responses/NotFound' */ -router.post('/:marketId/buy', requireAuth, (req, res) => - tradingController.buyShares(req, res) +router.post( + '/:marketId/buy', + requireAuth, + validate({ params: marketIdParam, body: buySharesBody }), + (req, res) => tradingController.buyShares(req, res) ); /** @@ -156,8 +167,11 @@ router.post('/:marketId/buy', requireAuth, (req, res) => * 404: * $ref: '#/components/responses/NotFound' */ -router.post('/:marketId/sell', requireAuth, (req, res) => - tradingController.sellShares(req, res) +router.post( + '/:marketId/sell', + requireAuth, + validate({ params: marketIdParam, body: sellSharesBody }), + (req, res) => tradingController.sellShares(req, res) ); /** @@ -225,15 +239,21 @@ router.get('/:marketId/odds', (req, res) => /** * POST /api/markets/:marketId/liquidity/add - Add USDC Liquidity to Pool */ -router.post('/:marketId/liquidity/add', requireAuth, (req, res) => - tradingController.addLiquidity(req, res) +router.post( + '/:marketId/liquidity/add', + requireAuth, + validate({ params: marketIdParam, body: addLiquidityBody }), + (req, res) => tradingController.addLiquidity(req, res) ); /** * POST /api/markets/:marketId/liquidity/remove - Remove Liquidity from Pool */ -router.post('/:marketId/liquidity/remove', requireAuth, (req, res) => - tradingController.removeLiquidity(req, res) +router.post( + '/:marketId/liquidity/remove', + requireAuth, + validate({ params: marketIdParam, body: removeLiquidityBody }), + (req, res) => tradingController.removeLiquidity(req, res) ); // ─── User-signed Transaction Routes ────────────────────────────────────────── diff --git a/backend/src/schemas/validation.schemas.ts b/backend/src/schemas/validation.schemas.ts index 537d2fdd..40a7c70c 100644 --- a/backend/src/schemas/validation.schemas.ts +++ b/backend/src/schemas/validation.schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { MarketCategory } from '@prisma/client'; +import { stellarService } from '../services/stellar.service.js'; // --- Sanitization helper --- @@ -10,9 +11,16 @@ import { MarketCategory } from '@prisma/client'; export function stripHtml(val: string): string { // Strip script tags and their content val = val.replace(/)<[^<]*)*<\/script>/gi, ''); + // Strip style tags and their content + val = val.replace(/)<[^<]*)*<\/style>/gi, ''); + // Strip event handlers (e.g., onclick, onerror) + val = val.replace(/\s+on\w+="[^"]*"/gi, ''); + val = val.replace(/\s+on\w+='[^']*'/gi, ''); + // Strip javascript: pseudo-protocol + val = val.replace(/javascript:[^"']*/gi, ''); // Strip remaining HTML tags val = val.replace(/<[^>]*>/g, ''); - // Strip HTML entities (e.g. & < ' ') + // Strip common HTML entities (e.g. & < ' ') val = val.replace(/&(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z]+);/g, ''); return val; } @@ -33,7 +41,9 @@ export function sanitizedString(min: number, max: number) { export const stellarAddress = z .string() - .regex(/^G[A-Z0-9]{55}$/, 'Invalid Stellar public key'); + .refine((val) => stellarService.isValidPublicKey(val), { + message: 'Invalid Stellar public key format or checksum', + }); export const uuidParam = z.object({ id: z.string().uuid(), @@ -115,6 +125,90 @@ export const commitPredictionBody = z.object({ ), }); +export const buySharesBody = z.object({ + outcome: z.number().int().min(0).max(1), + amount: z + .string() + .regex(/^\d+$/, 'Amount must be a numeric string (USDC base units)') + .refine( + (val) => { + try { + return BigInt(val) > 0n; + } catch { + return false; + } + }, + { message: 'Amount must be greater than 0' } + ) + .refine( + (val) => { + try { + return BigInt(val) <= 1_000_000_000_000n; + } catch { + return false; + } + }, + { message: 'Amount exceeds maximum limit' } + ), + minShares: z + .string() + .regex(/^\d+$/, 'minShares must be a numeric string') + .optional(), +}); + +export const sellSharesBody = z.object({ + outcome: z.number().int().min(0).max(1), + shares: z + .string() + .regex(/^\d+$/, 'Shares must be a numeric string (base units)') + .refine( + (val) => { + try { + return BigInt(val) > 0n; + } catch { + return false; + } + }, + { message: 'Shares must be greater than 0' } + ), + minPayout: z + .string() + .regex(/^\d+$/, 'minPayout must be a numeric string') + .optional(), +}); + +export const addLiquidityBody = z.object({ + usdcAmount: z + .string() + .regex(/^\d+$/, 'usdcAmount must be a numeric string') + .refine( + (val) => { + try { + return BigInt(val) > 0n; + } catch { + return false; + } + }, + { message: 'usdcAmount must be greater than 0' } + ), +}); + +export const removeLiquidityBody = z.object({ + lpTokens: z + .string() + .regex(/^\d+$/, 'lpTokens must be a numeric string') + .refine( + (val) => { + try { + return BigInt(val) > 0n; + } catch { + return false; + } + }, + { message: 'lpTokens must be greater than 0' } + ), +}); + export const revealPredictionBody = z.object({ predictionId: z.string().uuid(), }); diff --git a/backend/src/services/prediction.service.ts b/backend/src/services/prediction.service.ts index ecd7f1cd..d3f4f0b1 100644 --- a/backend/src/services/prediction.service.ts +++ b/backend/src/services/prediction.service.ts @@ -15,6 +15,7 @@ import { notifyWinningsClaimed, notifyBalanceUpdated, } from '../websocket/realtime.js'; +import { marketBlockchainService, MarketBlockchainService, } from './blockchain/market.js'; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index ea4987b1..32514e2b 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -8,6 +8,7 @@ import { NotificationService, } from './notification.service.js'; import { logger } from '../utils/logger.js'; +import { stripHtml } from '../schemas/validation.schemas.js'; export class UserService { private userRepository: UserRepository; @@ -112,15 +113,25 @@ export class UserService { avatarUrl?: string; } ) { + // Sanitize inputs + const sanitizedData = { ...data }; + if (sanitizedData.username) + sanitizedData.username = stripHtml(sanitizedData.username); + if (sanitizedData.displayName) + sanitizedData.displayName = stripHtml(sanitizedData.displayName); + if (sanitizedData.bio) sanitizedData.bio = stripHtml(sanitizedData.bio); + // Check username uniqueness if changing - if (data.username) { - const existing = await this.userRepository.findByUsername(data.username); + if (sanitizedData.username) { + const existing = await this.userRepository.findByUsername( + sanitizedData.username + ); if (existing && existing.id !== userId) { throw new Error('Username already taken'); } } - const user = await this.userRepository.update(userId, data); + const user = await this.userRepository.update(userId, sanitizedData); const { passwordHash: _, twoFaSecret: __, ...userWithoutSensitive } = user; return userWithoutSensitive; } diff --git a/backend/tests/auth.integration.test.ts b/backend/tests/auth.integration.test.ts index e7394985..2cba598f 100644 --- a/backend/tests/auth.integration.test.ts +++ b/backend/tests/auth.integration.test.ts @@ -139,7 +139,7 @@ describe('Auth Integration Tests', () => { it('should decode valid access token', () => { const payload = { userId: 'user-123', - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', tier: 'EXPERT' as const, }; @@ -154,7 +154,7 @@ describe('Auth Integration Tests', () => { it('should reject tampered token', () => { const token = signAccessToken({ userId: 'user-123', - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', tier: 'BEGINNER', }); @@ -170,7 +170,7 @@ describe('Auth Integration Tests', () => { const sessionData = { userId: 'user-123', tokenId: 'token-456', - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -187,7 +187,7 @@ describe('Auth Integration Tests', () => { const oldSession = { userId: 'user-123', tokenId: 'old-token', - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -195,7 +195,7 @@ describe('Auth Integration Tests', () => { const newSession = { userId: 'user-123', tokenId: 'new-token', - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -418,7 +418,7 @@ describe('Auth Integration Tests', () => { const oldSession = { userId, tokenId: oldTokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -428,7 +428,7 @@ describe('Auth Integration Tests', () => { const newSession = { userId, tokenId: newTokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -470,7 +470,7 @@ describe('Auth Integration Tests', () => { const oldSession = { userId, tokenId: oldTokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -481,7 +481,7 @@ describe('Auth Integration Tests', () => { const newSession = { userId, tokenId: newTokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -502,7 +502,7 @@ describe('Auth Integration Tests', () => { const session = { userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }; @@ -522,7 +522,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); @@ -568,7 +568,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); @@ -589,7 +589,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId: `concurrent-token-${i}`, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); @@ -607,7 +607,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); @@ -627,7 +627,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); @@ -652,7 +652,7 @@ describe('Auth Integration Tests', () => { sessionService.createSession({ userId, tokenId: `race-token-${i}`, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }) @@ -672,7 +672,7 @@ describe('Auth Integration Tests', () => { await sessionService.createSession({ userId, tokenId, - publicKey: 'GBTEST', + publicKey: 'GDNX7YG5NRHBKIZITO3FIFYXWLDDAL27IPXLQZSNJBZIIVPDTXJS3YNM', createdAt: Date.now(), expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); diff --git a/backend/tests/integration/trading.integration.test.ts b/backend/tests/integration/trading.integration.test.ts index 13a9cf19..a4ba1f12 100644 --- a/backend/tests/integration/trading.integration.test.ts +++ b/backend/tests/integration/trading.integration.test.ts @@ -89,7 +89,7 @@ describe('Trading API - User-Signed Transaction Flow', () => { it('should return unsigned XDR for a valid market', async () => { // Mock market vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'market-1', + id: '123e4567-e89b-12d3-a456-426614174001', status: MarketStatus.OPEN, } as any); @@ -99,7 +99,7 @@ describe('Trading API - User-Signed Transaction Flow', () => { ); const response = await request(app) - .post('/api/markets/market-1/build-tx/buy') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174001/build-tx/buy') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, @@ -113,7 +113,7 @@ describe('Trading API - User-Signed Transaction Flow', () => { // Verify it called AMM with the user's public key from the JWT expect(ammService.buildBuySharesTx).toHaveBeenCalledWith('GUSER123', { - marketId: 'market-1', + marketId: '123e4567-e89b-12d3-a456-426614174001', outcome: 1, amountUsdc: BigInt(1000), minShares: BigInt(900), @@ -122,12 +122,12 @@ describe('Trading API - User-Signed Transaction Flow', () => { it('should fail if market is not OPEN', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'market-1', + id: '123e4567-e89b-12d3-a456-426614174001', status: MarketStatus.CLOSED, } as any); const response = await request(app) - .post('/api/markets/market-1/build-tx/buy') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174001/build-tx/buy') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, @@ -193,7 +193,7 @@ describe('Trading API - Direct Buy Flow', () => { it('should buy shares successfully with valid data', async () => { // Mock market (OPEN) vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', contractAddress: 'contract', title: 'Test Market', status: MarketStatus.OPEN, @@ -236,12 +236,12 @@ describe('Trading API - Direct Buy Flow', () => { } as any); const response = await request(app) - .post('/api/markets/test-market-id/buy') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/buy') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, - amount: 100, - minShares: 90, + amount: '100', + minShares: '90', }) .expect(201); @@ -254,7 +254,7 @@ describe('Trading API - Direct Buy Flow', () => { // Verify AMM was called correctly expect(ammService.buyShares).toHaveBeenCalledWith({ - marketId: 'test-market-id', + marketId: '123e4567-e89b-12d3-a456-426614174000', outcome: 1, amountUsdc: 100, minShares: 90, @@ -263,7 +263,7 @@ describe('Trading API - Direct Buy Flow', () => { it('should reject buy with insufficient balance', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', status: MarketStatus.OPEN, } as any); @@ -273,11 +273,11 @@ describe('Trading API - Direct Buy Flow', () => { } as any); const response = await request(app) - .post('/api/markets/test-market-id/buy') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/buy') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, - amount: 100, + amount: '100', }) .expect(400); @@ -291,16 +291,16 @@ describe('Trading API - Direct Buy Flow', () => { it('should reject buy with invalid market (CLOSED)', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', status: MarketStatus.CLOSED, } as any); const response = await request(app) - .post('/api/markets/test-market-id/buy') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/buy') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, - amount: 100, + amount: '100', }) .expect(400); @@ -323,7 +323,7 @@ describe('Trading API - Direct Sell Flow', () => { it('should sell shares successfully with valid data', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', } as any); // Mock user has shares @@ -367,12 +367,12 @@ describe('Trading API - Direct Sell Flow', () => { } as any); const response = await request(app) - .post('/api/markets/test-market-id/sell') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/sell') .set('Authorization', `Bearer ${authToken}`) .send({ outcome: 1, - shares: 50, - minPayout: 48, + shares: '50', + minPayout: '48', }) .expect(200); @@ -382,7 +382,7 @@ describe('Trading API - Direct Sell Flow', () => { expect(response.body.data).toHaveProperty('txHash'); expect(ammService.sellShares).toHaveBeenCalledWith({ - marketId: 'test-market-id', + marketId: '123e4567-e89b-12d3-a456-426614174000', outcome: 1, shares: 50, minPayout: 48, @@ -403,7 +403,7 @@ describe('Trading API - Odds & Liquidity', () => { it('should return odds successfully', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', } as any); vi.mocked(ammService.getOdds).mockResolvedValue({ @@ -417,7 +417,7 @@ describe('Trading API - Odds & Liquidity', () => { }); const response = await request(app) - .get('/api/markets/test-market-id/odds') + .get('/api/markets/123e4567-e89b-12d3-a456-426614174000/odds') .expect(200); expect(response.body.success).toBe(true); @@ -428,7 +428,7 @@ describe('Trading API - Odds & Liquidity', () => { it('should add liquidity successfully', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', status: MarketStatus.OPEN, } as any); @@ -438,10 +438,14 @@ describe('Trading API - Odds & Liquidity', () => { }); const response = await request(app) - .post('/api/markets/test-market-id/liquidity/add') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/liquidity/add') .set('Authorization', `Bearer ${authToken}`) - .send({ usdcAmount: '1000' }) - .expect(200); + .send({ usdcAmount: '1000' }); + + if (response.status !== 200) { + console.log('DEBUG addLiquidity failure:', JSON.stringify(response.body, null, 2)); + } + expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('lpTokensMinted', '500'); @@ -453,7 +457,7 @@ describe('Trading API - Odds & Liquidity', () => { it('should remove liquidity successfully', async () => { vi.mocked(prisma.market.findUnique).mockResolvedValue({ - id: 'test-market-id', + id: '123e4567-e89b-12d3-a456-426614174000', status: MarketStatus.OPEN, } as any); @@ -465,7 +469,7 @@ describe('Trading API - Odds & Liquidity', () => { }); const response = await request(app) - .post('/api/markets/test-market-id/liquidity/remove') + .post('/api/markets/123e4567-e89b-12d3-a456-426614174000/liquidity/remove') .set('Authorization', `Bearer ${authToken}`) .send({ lpTokens: '500' }) .expect(200); diff --git a/backend/tests/integration/treasury.integration.test.ts b/backend/tests/integration/treasury.integration.test.ts index 63e162f3..1bb9900d 100644 --- a/backend/tests/integration/treasury.integration.test.ts +++ b/backend/tests/integration/treasury.integration.test.ts @@ -140,7 +140,7 @@ describe('Treasury API Integration Tests', () => { const marketId = '123e4567-e89b-12d3-a456-426614174000'; const creatorAddress = - 'GCREATORTEST12345678901234567890123456789012345678901234'; // 56 chars + 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY'; // Valid 56 chars const response = await request(app) .post('/api/treasury/distribute-creator') diff --git a/backend/tests/middleware/integration.test.ts b/backend/tests/middleware/integration.test.ts index 7a49915d..0fec3249 100644 --- a/backend/tests/middleware/integration.test.ts +++ b/backend/tests/middleware/integration.test.ts @@ -28,13 +28,13 @@ describe('Validation and Error Handling Integration', () => { app.use(errorHandler); const response = await request(app).post('/api/test').send({ - publicKey: 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ', + publicKey: 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY', }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.publicKey).toBe( - 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ' + 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY' ); }); diff --git a/backend/tests/middleware/validation.middleware.test.ts b/backend/tests/middleware/validation.middleware.test.ts index 4aba3731..5902762a 100644 --- a/backend/tests/middleware/validation.middleware.test.ts +++ b/backend/tests/middleware/validation.middleware.test.ts @@ -28,13 +28,13 @@ describe('Validation Middleware', () => { app.use(errorHandler); const response = await request(app).post('/challenge').send({ - publicKey: 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ', + publicKey: 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY', }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.publicKey).toBe( - 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ' + 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY' ); }); @@ -74,7 +74,7 @@ describe('Validation Middleware', () => { app.use(errorHandler); const response = await request(app).post('/login').send({ - publicKey: 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ', + publicKey: 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY', signature: 'test-signature', nonce: 'test-nonce', }); @@ -90,7 +90,7 @@ describe('Validation Middleware', () => { app.use(errorHandler); const response = await request(app).post('/login').send({ - publicKey: 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ', + publicKey: 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY', signature: '', nonce: 'test-nonce', }); @@ -210,13 +210,13 @@ describe('Validation Middleware', () => { app.use(errorHandler); const response = await request(app).post('/verify').send({ - address: 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ', + address: 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY', }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.address).toBe( - 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ' + 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY' ); }); diff --git a/backend/tests/middleware/validation.schemas.test.ts b/backend/tests/middleware/validation.schemas.test.ts index 88a5ad73..41d16d50 100644 --- a/backend/tests/middleware/validation.schemas.test.ts +++ b/backend/tests/middleware/validation.schemas.test.ts @@ -20,7 +20,7 @@ import { // Valid Stellar public key for tests const VALID_STELLAR_KEY = - 'GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKXQ'; + 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY'; const VALID_UUID = '123e4567-e89b-12d3-a456-426614174000'; // Helper to create a future datetime string diff --git a/backend/tests/security/sanitization.test.ts b/backend/tests/security/sanitization.test.ts new file mode 100644 index 00000000..24108db1 --- /dev/null +++ b/backend/tests/security/sanitization.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { stripHtml, stellarAddress, buySharesBody } from '../../src/schemas/validation.schemas.js'; + +describe('Security Audit - Input Sanitization & Validation', () => { + describe('XSS Sanitization (stripHtml)', () => { + it('should strip script tags and their content', () => { + const input = 'Check out this market! '; + expect(stripHtml(input)).toBe('Check out this market! '); + }); + + it('should strip inline event handlers', () => { + const input = ' Click me'; + // Our stripHtml replaces on\w+="[^"]*" with empty string + const result = stripHtml(input); + expect(result).not.toContain('onerror'); + expect(result).not.toContain('alert'); + }); + + it('should strip style tags', () => { + const input = 'Normal text '; + expect(stripHtml(input)).toBe('Normal text '); + }); + + it('should strip javascript: pseudo-protocol', () => { + const input = 'Click'; + const result = stripHtml(input); + expect(result).not.toContain('javascript:'); + expect(result).toBe('Click'); + }); + + it('should strip multiple tags and handle nested-like structures', () => { + const input = '
Title

Message

'; + expect(stripHtml(input)).toBe('TitleMessage '); + }); + }); + + describe('Stellar Address Validation (checksum)', () => { + it('should accept valid Stellar public keys', () => { + const realValidKey = 'GAMCVGJFOWWCF6N7YSS66DEZQSCGWZU2SCOWIA2NTMCKTODDTPUOOYDY'; + expect(stellarAddress.safeParse(realValidKey).success).toBe(true); + }); + + it('should reject keys with invalid format', () => { + expect(stellarAddress.safeParse('not-a-key').success).toBe(false); + expect(stellarAddress.safeParse('B' + 'A'.repeat(55)).success).toBe(false); + }); + + it('should reject keys with valid format but invalid checksum', () => { + // G followed by 55 chars, but checksum is likely wrong + const invalidChecksumKey = 'G' + 'A'.repeat(55); + expect(stellarAddress.safeParse(invalidChecksumKey).success).toBe(false); + }); + }); + + describe('Numeric Input Validation (Trading)', () => { + it('should reject negative amounts', () => { + const result = buySharesBody.safeParse({ + outcome: 1, + amount: '-100', + minShares: '0' + }); + expect(result.success).toBe(false); + }); + + it('should reject zero amounts if required to be > 0', () => { + const result = buySharesBody.safeParse({ + outcome: 1, + amount: '0', + minShares: '0' + }); + expect(result.success).toBe(false); + }); + + it('should reject non-numeric strings for amounts', () => { + const result = buySharesBody.safeParse({ + outcome: 1, + amount: 'abc', + minShares: '0' + }); + expect(result.success).toBe(false); + }); + + it('should reject extremely large numbers (overflow protection)', () => { + const result = buySharesBody.safeParse({ + outcome: 1, + amount: '999999999999999999999999999999999999', // very large + minShares: '0' + }); + expect(result.success).toBe(false); + }); + + it('should accept valid numeric strings', () => { + const result = buySharesBody.safeParse({ + outcome: 1, + amount: '1000000', // 1 USDC + minShares: '900000' + }); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c6818974..51fd2f62 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "vite": "^7.2.4" } }, @@ -54,7 +55,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1429,7 +1429,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1471,7 +1470,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1577,7 +1575,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1799,7 +1796,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2486,7 +2482,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2533,6 +2528,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2548,7 +2559,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2782,7 +2792,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2904,7 +2913,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index b263d311..10ffd290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "preview": "vite preview" }, "dependencies": { @@ -22,6 +24,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "vite": "^7.2.4" } -} +} \ No newline at end of file