From dbc546f69e09ba25fcaa830df6989b4700d2456b Mon Sep 17 00:00:00 2001 From: Olagoke22 <115514757+Olagoke22@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:58:35 +0100 Subject: [PATCH] request throttle --- stellar-payment-platform/server.js | 872 +++++++----------- .../src/middleware/signatureRateLimit.js | 26 + .../src/routes/v1/userRoutes.js | 345 ++----- .../src/routes/v1/webhookRoutes.js | 5 +- .../tests/rate-limit.test.js | 130 ++- 5 files changed, 565 insertions(+), 813 deletions(-) create mode 100644 stellar-payment-platform/src/middleware/signatureRateLimit.js diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 057670ad..89705c97 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -1,10 +1,8 @@ -require('./config/envCheck'); const express = require('express'); const cors = require('cors'); -const helmet = require('helmet'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); +const RedisStore = require('rate-limit-redis'); const { createClient } = require('redis'); const { prisma, isPrismaConnectionError } = require('./prismaClient'); const { scheduleCleanupJob } = require('./src/cleanup-cron'); @@ -12,6 +10,8 @@ const { scheduleSoftDeletePurgeJob } = require('./src/soft-delete-purge-cron'); const { schedulePoolMonitoring } = require('./src/db-pool-monitor'); const { correlationId } = require('./middleware/correlation'); const { idempotencyMiddleware } = require('./middleware/idempotency'); +const { asyncHandler } = require('./src/middleware/asyncHandler'); +const { createSignatureRateLimiter } = require('./src/middleware/signatureRateLimit'); const Filter = require('bad-words'); const dotenv = require('dotenv'); const timeout = require('connect-timeout'); @@ -19,28 +19,12 @@ const compression = require('compression'); const { verifyMultiSignerThreshold } = require('./src/multisigner-verifier'); const { poolGet, poolRun, poolAll } = require('./src/db'); const { logger } = require('./src/logger'); -const pinoHttp = require('pino-http'); const xss = require('xss'); const { Keypair, StrKey } = require('@stellar/stellar-sdk'); -const { - metricsMiddleware, - getMetrics, - getContentType, - setMetricsSources, -} = require('./src/metrics'); -const { validateSchema } = require('./src/middleware/validateSchema'); -const { buildErrorHandler, notFoundHandler } = require('./src/middleware/errorHandler'); -const { ApiError, errorBody } = require('./src/errors'); -const { requireJson } = require('./src/middleware/requireJson'); -const { bodySizeLimit } = require('./src/middleware/bodyLimit'); -const { apiVersion } = require('./src/middleware/apiVersion'); -const { deprecationMiddleware } = require('./src/middleware/deprecation'); -const { - registerBodySchema, - federationQuerySchema, - lookupQuerySchema, - usersQuerySchema, -} = require('./src/schemas'); +const { metricsMiddleware, getMetrics, getContentType } = require('./src/metrics'); +const { registerValidator } = require('./src/validators/registerValidator'); +const { validate } = require('./src/middleware/validate'); +const { validationResult } = require('express-validator'); const Sentry = require('@sentry/node'); const { lookupCached, @@ -49,20 +33,11 @@ const { federationLookupCached, invalidateFederationCache, } = require('./src/cache'); -const { - paginatedResponse, - parsePagination, - parseCursorQuery, - keysetWhereDesc, - paginateByKeyset, - cursorPaginatedResponse, -} = require('./src/pagination'); +const { parsePagination, paginatedResponse } = require('./src/pagination'); const { normalizeNameTag, validateMemo, RESERVED_NAMES, - MAX_USERNAMES_PER_ADDRESS, - PRIMARY_USERNAME_ORDER, USER_DATABASE, shouldFallbackToLocalRegistry, } = require('./src/utils'); @@ -80,14 +55,12 @@ const app = express(); // #31 — Attach a correlation ID to every request before anything else runs so // all downstream middleware, handlers and logs can reference the same trace. app.use(correlationId); -app.use(pinoHttp({ logger, autoLogging: false })); // Use autoLogging: false if you want custom logs, or true if you want everything. PR says "Logs incoming HTTP requests", so let's enable it (default is true). -app.use(helmet()); app.use(timeout('10s')); app.use((err, req, res, next) => { if (req.timedout) { - logger.error(err, `[Correlation ID: ${req.correlationId}] Request Timeout`); - return next(new ApiError('SERVICE_UNAVAILABLE', undefined, { cause: err })); + logger.error(`[Correlation ID: ${req.correlationId}] Request Timeout`, err); + return res.status(503).json({ error: 'Service Unavailable', correlation_id: req.correlationId }); } next(err); }); @@ -96,17 +69,11 @@ app.set('query parser', 'simple'); const PORT = process.env.PORT || 5000; const STELLAR_TAG_DOMAIN = process.env.STELLAR_TAG_DOMAIN; -const envOrigins = process.env.CORS_ALLOWED_ORIGINS - ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(o => o.trim()) - : []; - const allowedOrigins = [ 'http://localhost:5173', 'http://localhost:3000', 'https://stellar-tags.vercel.app', STELLAR_TAG_DOMAIN, - process.env.VITE_API_BASE, - ...envOrigins, ].filter(Boolean); const corsOptions = { @@ -114,17 +81,14 @@ const corsOptions = { if (!origin || allowedOrigins.includes(origin)) { return callback(null, true); } - return callback(new Error('Not allowed by CORS')); + return callback(null, false); }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, - optionsSuccessStatus: 204 + optionsSuccessStatus: 204, }; -app.use(cors(corsOptions)); - -// Apply metrics middleware to track all HTTP requests app.use(metricsMiddleware); const REDIS_RETRY_MAX = 5; @@ -135,24 +99,23 @@ const redisClient = process.env.REDIS_URL ? createClient({ socket: { reconnectStrategy(retries, cause) { if (retries >= REDIS_RETRY_MAX) { - logger.error({ cause }, `Redis connection failed after ${REDIS_RETRY_MAX} retries`); + logger.error(`Redis connection failed after ${REDIS_RETRY_MAX} retries`, { cause }); return new Error(`Redis connection failed after ${REDIS_RETRY_MAX} retries`); } const delay = Math.min(2 ** retries * REDIS_RETRY_BASE_DELAY_MS, 10000); - logger.warn({ cause }, `Redis connection attempt ${retries + 1} failed, retrying in ${delay}ms...`); + logger.warn(`Redis connection attempt ${retries + 1} failed, retrying in ${delay}ms...`, { cause }); return delay; } } }) : null; if (redisClient) { - redisClient.on('error', (err) => logger.error(err, 'Redis client error:')); - redisClient.connect().catch((err) => logger.error(err, 'Redis connection error:')); + redisClient.on('error', (err) => logger.error('Redis client error:', err)); + redisClient.connect().catch((err) => logger.error('Redis connection error:', err)); } -setMetricsSources({ prisma, redisClient }); - const v1Router = require('./src/routes/v1')(redisClient); -const v2Router = require('./src/routes/v2')(redisClient); + +const signatureRateLimiter = createSignatureRateLimiter(redisClient); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -163,11 +126,8 @@ const limiter = rateLimit({ }) : undefined, // Return the standard RateLimit-* headers only standardHeaders: true, - legacyHeaders: true, - message: errorBody('RATE_LIMITED', 'Too many requests, please try again later.'), - // Prometheus scrapes /metrics on a fixed interval from a single address, so - // counting those scrapes against the shared quota would 429 the scraper. - skip: (req) => req.path === '/metrics', + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' }, // Key by authenticated user identifier when present (address/username), // otherwise fall back to client IP. This lets registered/identified users // get a per-account quota rather than being grouped by IP. @@ -206,29 +166,16 @@ const limiter = rateLimit({ }, }); -// Per-IP limiter specifically for sensitive, unauthenticated endpoints. -// Keys strictly by client IP so brute-force/spam from a single source is -// blocked regardless of how many account ids are rotated in the payload. -const ipLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - store: redisClient ? new RedisStore({ - sendCommand: (...args) => redisClient.sendCommand(args), - }) : undefined, - standardHeaders: true, - legacyHeaders: true, - message: errorBody('RATE_LIMITED', 'Too many requests, please try again later.'), - keyGenerator: (req) => req.ip || (req.connection && req.connection.remoteAddress) || '', -}); - app.use(cors(corsOptions)); - -// #588 — Per-route request body size limits. A single JSON parser enforces a -// cap that depends on the endpoint type (auth 1kb / standard 10kb / bulk 100kb) -// instead of the previous uniform 10kb, and answers oversized payloads with 413. -app.use(bodySizeLimit); - app.use(limiter); +app.use(express.json({ limit: '10kb' })); +app.use((err, _req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + return res.status(400).json({ error: 'Malformed JSON payload' }); + } + next(err); +}); + const isPrimitive = (v) => v === null || v === undefined || typeof v !== 'object'; const rejectNestedObjects = (req, res, next) => { @@ -237,15 +184,9 @@ const rejectNestedObjects = (req, res, next) => { if (source && typeof source === 'object') { for (const val of Object.values(source)) { if (!isPrimitive(val)) { - // Responds directly rather than delegating, so the middleware stays - // usable on its own — the same way validateSchema behaves. - return res.status(400).json( - errorBody( - 'INVALID_INPUT', - 'Invalid parameter type: nested objects and arrays are not allowed.', - { correlationId: req.correlationId }, - ), - ); + return res + .status(400) + .json({ detail: 'Invalid parameter type: nested objects and arrays are not allowed.' }); } } } @@ -255,23 +196,12 @@ const rejectNestedObjects = (req, res, next) => { app.use(rejectNestedObjects); -// Enable HTTP response compression for responses exceeding 1KB (1024 bytes) app.use(compression({ threshold: 1024 })); scheduleCleanupJob(prisma); scheduleSoftDeletePurgeJob(prisma); const poolMonitor = schedulePoolMonitoring(prisma); -const RESERVED_USERNAMES = [ - 'admin', - 'root', - 'stellar', - 'system', - 'superuser', - 'administrator', - 'support', -]; - // --------------------------------------------------------------------------- // #51 — ETag Caching Middleware for Federation Endpoint // --------------------------------------------------------------------------- @@ -308,45 +238,13 @@ const getLocalUserByUsername = async (username) => [username], ); -const listLocalUsers = async (search, page, limit, cursorPoint = null) => { +const listLocalUsers = async (search, page, limit) => { const searchPattern = `%${search}%`; - const LIKE_FILTER = - 'WHERE (username LIKE ? COLLATE NOCASE OR address LIKE ? COLLATE NOCASE)'; - - if (cursorPoint) { - // Keyset mode for the fallback path as well. created_at is stored as an - // ISO-8601 string, so lexicographic comparison matches chronological - // ordering and the tuple predicate seeks straight past the cursor row. - const rows = await poolAll( - `SELECT username, address, created_at - FROM username_registry - ${LIKE_FILTER} - AND (created_at < ? OR (created_at = ? AND username < ?)) - ORDER BY created_at DESC, username DESC - LIMIT ?`, - [searchPattern, searchPattern, String(cursorPoint.createdAt), String(cursorPoint.createdAt), String(cursorPoint.username), limit + 1], - ); - const normalized = rows.map((row) => ({ - username: row.username, - address: row.address, - createdAt: row.created_at, - })); - const { rows: pageRows, hasMore, nextCursor } = paginateByKeyset(normalized, limit); - return cursorPaginatedResponse( - pageRows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt, - })), - { limit, nextCursor, hasMore }, - ); - } - const skip = (page - 1) * limit; const rows = await poolAll( `SELECT username, address, created_at FROM username_registry - ${LIKE_FILTER} + WHERE username LIKE ? COLLATE NOCASE OR address LIKE ? COLLATE NOCASE ORDER BY created_at DESC LIMIT ? OFFSET ?`, [searchPattern, searchPattern, limit, skip], @@ -355,7 +253,7 @@ const listLocalUsers = async (search, page, limit, cursorPoint = null) => { const countRow = await poolGet( `SELECT COUNT(*) AS totalCount FROM username_registry - ${LIKE_FILTER}`, + WHERE username LIKE ? COLLATE NOCASE OR address LIKE ? COLLATE NOCASE`, [searchPattern, searchPattern], ); @@ -371,9 +269,14 @@ const listLocalUsers = async (search, page, limit, cursorPoint = null) => { ); }; -const registerLocalUser = async ({ username, address, isPrimary = false }) => { - // #613 — several usernames may share an address, so an existing address is - // no longer a conflict; only a duplicate username is. +const registerLocalUser = async ({ username, address }) => { + const existingByAddress = await getLocalUserByAddress(address); + if (existingByAddress) { + const conflictError = new Error('Address already registered'); + conflictError.statusCode = 409; + throw conflictError; + } + const existingByUsername = await getLocalUserByUsername(username); if (existingByUsername) { const conflictError = new Error('Username is already taken. Please choose another.'); @@ -382,126 +285,125 @@ const registerLocalUser = async ({ username, address, isPrimary = false }) => { } await poolRun( - `INSERT INTO username_registry (username, address, is_primary, created_at) - VALUES (?, ?, ?, ?)`, - [username, address, isPrimary, new Date().toISOString()], + `INSERT INTO username_registry (username, address, created_at) + VALUES (?, ?, ?)`, + [username, address, new Date().toISOString()], ); }; -// Expose /metrics endpoint for Prometheus to scrape -app.get('/metrics', async (req, res) => { +app.get('/metrics', asyncHandler(async (req, res) => { try { res.set('Content-Type', getContentType()); const metrics = await getMetrics(); res.end(metrics); } catch (err) { - logger.error(err, `[Correlation ID: ${req.correlationId}] Metrics error`); + logger.error(`[Correlation ID: ${req.correlationId}] Metrics error`, err); res.status(500).end(err.message); } -}); +})); -app.get('/federation', ipLimiter, etagCache, validateSchema({ query: federationQuerySchema }), async (req, res, next) => { - const { q: queryValue, type } = req.query; +app.get('/federation', etagCache, asyncHandler(async (req, res, next) => { + const { q, type } = req.query; + const queryValue = typeof q === 'string' ? q.trim() : ''; - try { - if (type === 'id') { - const cacheKey = federationIdKey(queryValue); - const cached = await federationLookupCached(cacheKey, async () => { - // #613 — an address can have several usernames; a reverse lookup - // resolves to the primary one. - const row = await prisma.user.findFirst({ - where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null }, - select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true }, - orderBy: PRIMARY_USERNAME_ORDER, + if (!queryValue) { + const error = new Error("Missing 'q' parameter"); + error.statusCode = 400; + return next(error); + } + + if (type === 'id') { + const cacheKey = federationIdKey(queryValue); + const cached = await federationLookupCached(cacheKey, async () => { + const row = await prisma.user.findFirst({ + where: { address: { equals: queryValue, mode: 'insensitive' } }, + select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true }, + }); + + if (!row) return null; + if (row.flaggedAt) { + const forbiddenError = new Error('Address is blocked'); + forbiddenError.statusCode = 403; + throw forbiddenError; + } + + const response = { + stellar_address: `${row.username}*${process.env.DOMAIN || 'localhost'}`, + account_id: row.address, + }; + if (row.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; + } + return response; + }); + + if (!cached) { + const notFoundError = new Error('Address not found'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + return res.json(cached); + } + + if (type === 'name' || !type) { + const nameTag = normalizeNameTag(queryValue); + const queryName = nameTag.toLowerCase(); + const cacheKey = federationNameKey(queryName); + + const cached = await federationLookupCached(cacheKey, async () => { + let row; + try { + row = await prisma.user.findUnique({ + where: { username: queryName }, + select: { address: true, memoType: true, memo: true, flaggedAt: true }, }); - if (!row) return null; - if (row.flaggedAt) { + if (row && row.flaggedAt) { const forbiddenError = new Error('Address is blocked'); forbiddenError.statusCode = 403; throw forbiddenError; } - - const response = { - stellar_address: `${row.username}*${process.env.DOMAIN || 'localhost'}`, - account_id: row.address, - }; - if (row.memoType) { - response.memo_type = row.memoType; - response.memo = row.memo; + } catch (error) { + if (error.statusCode === 403) throw error; + if (!shouldFallbackToLocalRegistry(error)) { + throw error; } - return response; - }); - if (!cached) { - const notFoundError = new Error('Address not found'); - notFoundError.statusCode = 404; - return next(notFoundError); + const localRow = await getLocalUserByUsername(queryName); + row = localRow + ? { address: localRow.address, memoType: null, memo: null } + : null; } - return res.json(cached); - } else if (type === 'name' || !type) { - const nameTag = normalizeNameTag(queryValue); - const queryName = nameTag.toLowerCase(); - const cacheKey = federationNameKey(queryName); - - const cached = await federationLookupCached(cacheKey, async () => { - let row; - try { - row = await prisma.user.findFirst({ - where: { username: queryName, deletedAt: null }, - select: { address: true, memoType: true, memo: true, flaggedAt: true }, - }); - - if (row && row.flaggedAt) { - const forbiddenError = new Error('Address is blocked'); - forbiddenError.statusCode = 403; - throw forbiddenError; - } - } catch (error) { - if (error.statusCode === 403) throw error; - if (!shouldFallbackToLocalRegistry(error)) { - throw error; - } - - const localRow = await getLocalUserByUsername(queryName); - row = localRow - ? { address: localRow.address, memoType: null, memo: null } - : null; - } - - const address = row?.address || USER_DATABASE[queryName]; - if (!address) return null; + const address = row?.address || USER_DATABASE[queryName]; + if (!address) return null; - const response = { - stellar_address: address, - account_id: address, - }; - if (row?.memoType) { - response.memo_type = row.memoType; - response.memo = row.memo; - } - return response; - }); - - if (!cached) { - const notFoundError = new Error('Name tag not found'); - notFoundError.statusCode = 404; - return next(notFoundError); + const response = { + stellar_address: address, + account_id: address, + }; + if (row?.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; } + return response; + }); - return res.json(cached); - } else { - return next( - new ApiError('INVALID_INPUT', "Unsupported query type. Supported types: 'id', 'name'"), - ); + if (!cached) { + const notFoundError = new Error('Name tag not found'); + notFoundError.statusCode = 404; + return next(notFoundError); } - } catch (error) { - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); + + return res.json(cached); } -}); + + return res.status(400).json({ + error: "Unsupported query type. Supported types: 'id', 'name'", + }); +})); // Initialise profanity filter once at module load (reused across requests). const profanityFilter = new Filter(); @@ -526,19 +428,7 @@ const verifyFreighterRegistrationSignature = ({ if (Buffer.isBuffer(signature)) { signatureBuffer = signature; } else if (typeof signature === 'string') { - // If it's a 128-char hex string - if (signature.length === 128 && /^[0-9a-fA-F]+$/.test(signature)) { - signatureBuffer = Buffer.from(signature, 'hex'); - } else { - signatureBuffer = Buffer.from(signature, 'base64'); - // If the resulting buffer is 86-88 bytes long, it might be the ASCII bytes of a base64 string (double encoded) - if (signatureBuffer.length >= 80 && signatureBuffer.length <= 90) { - const text = signatureBuffer.toString('utf8'); - if (/^[a-zA-Z0-9+/]+={0,2}$/.test(text)) { - signatureBuffer = Buffer.from(text, 'base64'); - } - } - } + signatureBuffer = Buffer.from(signature, 'base64'); } else { throw new Error('Invalid message signature format.'); } @@ -550,17 +440,11 @@ const verifyFreighterRegistrationSignature = ({ const payload = Buffer.concat([prefix, messageBytes]); const messageHash = crypto.createHash('sha256').update(payload).digest(); - // Verify against the hashed payload (SEP-0053) first + // Verify against the hashed payload, not the raw string! if (!keypair.verify(messageHash, signatureBuffer)) { - // If that fails, try verifying the raw message directly in case the wallet used signBlob - if (!keypair.verify(messageBytes, signatureBuffer)) { - // Also try verifying the payload without hashing it - if (!keypair.verify(payload, signatureBuffer)) { - const error = new Error('Signature verification failed.'); - error.statusCode = 401; - throw error; - } - } + const error = new Error('Signature verification failed.'); + error.statusCode = 401; + throw error; } if (claimedSigner !== address) { @@ -584,29 +468,51 @@ const verifyFreighterRegistrationSignature = ({ * - Validates that provided signature(s) meet minimum threshold * - Ensures authorization requirements are satisfied */ -app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson, validateSchema({ body: registerBodySchema }), async (req, res, next) => { - // registerBodySchema has already guaranteed that username is a trimmed - // 3-20 character alphanumeric string and address is a non-empty trimmed - // string, so those shape checks are not repeated here. +app.post('/register', signatureRateLimiter, idempotencyMiddleware(redisClient), asyncHandler(async (req, res, next) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: "Unsupported Media Type. Please send application/json" }); + } + + for (const validator of registerValidator) { + await validator.run(req); + } + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ + success: false, + errors: errors.array().map(err => ({ + field: err.path, + message: err.msg, + })), + }); + } + const safeUsername = xss(req.body.username); const username = normalizeNameTag(safeUsername); - const { address, memo_type: memoType, memo, signature = '', signerAddress = '' } = req.body; + const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; + const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; + const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + const signature = typeof req.body.signature === 'string' ? req.body.signature.trim() : ''; + const signerAddress = typeof req.body.signerAddress === 'string' ? req.body.signerAddress.trim() : ''; if (address.toUpperCase().startsWith('S')) { - return next( - new ApiError( - 'INVALID_INPUT', - 'Never share your Secret Key. Please register using your Public Key (starts with G).', - ), - ); + return res.status(400).json({ error: "Never share your Secret Key. Please register using your Public Key (starts with G)." }); + } + + if (!username || !address) { + return res.status(400).json({ error: 'Missing required fields: username and address are both required.' }); } - // Extract the username part before the * for the profanity check + // Extract the username part before the * for profanity check and length validation const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; - // Reject usernames containing profanity or offensive words. + if (usernameLocalPart.length < 3) { + return res.status(400).json({ error: "Username must be at least 3 characters long." }); + } + if (profanityFilter.isProfane(usernameLocalPart)) { - return next(new ApiError('INVALID_INPUT', 'Username contains restricted words')); + return res.status(400).json({ error: 'Username contains restricted words' }); } if (!StrKey.isValidEd25519PublicKey(address)) { @@ -617,50 +523,34 @@ app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson const memoError = validateMemo(memoType, memo); if (memoError) { - return next(new ApiError('INVALID_INPUT', memoError)); + return res.status(400).json({ error: memoError }); } const normalizedUsername = username.toLowerCase(); - - const normalizedLocalPart = normalizedUsername.includes('*') ? normalizedUsername.split('*')[0] : normalizedUsername; - if (RESERVED_USERNAMES.includes(normalizedLocalPart)) { - return res.status(403).json({ error: "Username is reserved." }); - } - const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help']; if (RESERVED_NAMES.includes(normalizedUsername)) { - return next(new ApiError('FORBIDDEN', 'This username is reserved and cannot be registered.')); + return res.status(403).json({ error: "This username is reserved and cannot be registered." }); } try { - // #613 — an address may carry several usernames (aliases). Registration - // adds another while the address is under the cap; the first username - // registered for an address becomes its primary. Reverse (type=id) - // federation lookups resolve to that primary. - let usernameCount = 0; + let existing = null; try { - usernameCount = await prisma.user.count({ - where: { address, deletedAt: null }, + existing = await prisma.user.findUnique({ + where: { address }, }); } catch (error) { if (!shouldFallbackToLocalRegistry(error)) { throw error; } - // Degraded path: the exact alias count is unavailable, so fall back to - // a presence check. The 5-username cap is enforced best-effort here. - usernameCount = (await getLocalUserByAddress(address)) ? 1 : 0; + existing = await getLocalUserByAddress(address); } - if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) { - return next( - new ApiError( - 'CONFLICT', - `This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`, - ), - ); + if (existing) { + const conflictError = new Error('Address already registered'); + conflictError.statusCode = 409; + return next(conflictError); } - const isPrimary = usernameCount === 0; let verificationResult = null; if (signature) { @@ -718,7 +608,6 @@ app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson data: { username: normalizedUsername, address, - isPrimary, ...(memoType && { memoType, memo }), }, }); @@ -729,14 +618,13 @@ app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson throw error; } - await registerLocalUser({ username: normalizedUsername, address, isPrimary }); + await registerLocalUser({ username: normalizedUsername, address }); } return res.status(201).json({ ok: true, username: normalizedUsername, address, - is_primary: isPrimary, federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, ...(verificationResult && { verification: { @@ -750,253 +638,152 @@ app.post('/register', ipLimiter, idempotencyMiddleware(redisClient), requireJson ...(memoType && { memo_type: memoType, memo }), }); } catch (error) { - if (error.code === '23505' || (error.message && error.message.includes('UNIQUE'))) { - return next(new ApiError('CONFLICT', 'Username is already taken. Please choose another.')); + if (error.code === 'SQLITE_CONSTRAINT' || (error.message && error.message.includes('UNIQUE'))) { + return res.status(409).json({ error: 'Username is already taken. Please choose another.' }); } - - // Handle verification errors + if (error.message && error.message.includes('Account not found')) { const notFoundError = new Error(`Account not found on Horizon: ${address}`); notFoundError.statusCode = 404; return next(notFoundError); } - // Handle signature verification errors - if (error.statusCode === 401) { - return next(error); - } - - // Handle other errors - logger.error({ err: error.message }, 'Registration error:'); - const registrationError = new Error(`Registration verification failed: ${error.message}`); - registrationError.statusCode = 500; - return next(registrationError); + throw error; } -}); +})); -app.all('/register', (req, res, next) => next(new ApiError('METHOD_NOT_ALLOWED'))); +app.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" })); -app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res, next) => { - const { address = '', search = '' } = req.query; +app.get('/lookup', asyncHandler(async (req, res, next) => { + const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + + if (!address && !search) { + const error = new Error("Missing required parameter: provide 'address' for exact lookup or 'search' for paginated search"); + error.statusCode = 400; + return next(error); + } if (address) { - try { - const result = await lookupCached(address, async () => { - let row; - try { - // #613 — an address can have several usernames; return the primary. - row = await prisma.user.findFirst({ - where: { address, deletedAt: null }, - select: { username: true }, - orderBy: PRIMARY_USERNAME_ORDER, - }); - } catch (error) { - if (!shouldFallbackToLocalRegistry(error)) { - throw error; - } - row = await getLocalUserByAddress(address); + const result = await lookupCached(address, async () => { + let row; + try { + row = await prisma.user.findUnique({ + where: { address }, + select: { username: true }, + }); + } catch (error) { + if (!shouldFallbackToLocalRegistry(error)) { + throw error; } - return row ? { username: row.username, address } : null; - }); - - if (!result) { - const notFoundError = new Error('Username not found for this address'); - notFoundError.statusCode = 404; - return next(notFoundError); + row = await getLocalUserByAddress(address); } + return row ? { username: row.username, address } : null; + }); - return res.json(result); - } catch (err) { - logger.error(err, "🚨 ACTUAL PRISMA ERROR:"); - - const dbError = new Error('Database lookup failed', { cause: err }); - dbError.statusCode = 500; - return next(dbError); + if (!result) { + const notFoundError = new Error('Username not found for this address'); + notFoundError.statusCode = 404; + return next(notFoundError); } + + return res.json(result); } - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } const where = { - deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, ], }; + let response; try { - let response = null; - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - response = cursorPaginatedResponse( - rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })), - { limit: cursorLimit, nextCursor, hasMore }, - ); - } else { - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - skip, - take: limit, - }), - ]); - - response = paginatedResponse( - rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })), - totalCount, - { page, limit }, - ); - } - } catch (error) { - if (!shouldFallbackToLocalRegistry(error)) { - throw error; - } + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); - response = await listLocalUsers(search, page, limit, cursor); + response = paginatedResponse( + rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })), + totalCount, + { page, limit }, + ); + } catch (error) { + if (!shouldFallbackToLocalRegistry(error)) { + throw error; } - return res.json(response); - } catch (error) { - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); + response = await listLocalUsers(search, page, limit); } -}); -app.get('/users', validateSchema({ query: usersQuerySchema }), async (req, res, next) => { - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); - const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } - const search = req.query.search ?? null; + return res.json(response); +})); + +app.get('/users', asyncHandler(async (req, res) => { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10)); + const search = typeof req.query.search === 'string' ? req.query.search : null; + const skip = (page - 1) * limit; const where = search ? { - deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, ], } - : { deletedAt: null }; - - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [{ createdAt: 'desc' }, { username: 'desc' }], - skip, - take: limit, - }), - ]); - - const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - - res.json({ - data, - meta: { - total: totalCount, - totalCount, - page, - currentPage: page, - limit, - totalPages, - }, + : {}; + + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt ? user.createdAt.toISOString() : undefined, + })); + + res.json({ + data, + meta: { + total: totalCount, totalCount, - totalPages, + page, currentPage: page, - }); - } catch (error) { - const dbError = new Error('Database error', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } -}); -// Request versioning: URI (/api/v1, /api/v2) first, then Accept-Version / -// API-Version header, defaulting to v1. Routers below then decide routing. -app.use(apiVersion); - -// RFC 8594 deprecation headers: attaches Deprecation/Sunset/Link to endpoints -// listed in src/config/deprecations.js and logs a server-side warning. -app.use(deprecationMiddleware()); - -// v2 first so an explicit /api/v2 request wins over the unversioned fallback. -app.use('/api/v2', v2Router); -// Explicit v1 mount, then /api (no version) and the legacy unversioned root -// both resolve to v1 so existing clients keep working unchanged. -app.use('/api/v1', v1Router); -app.use('/api', v1Router); + limit, + totalPages, + }, + totalCount, + totalPages, + currentPage: page, + }); +})); +// Mount v1 router for both legacy paths and explicit API versioning app.use('/', v1Router); +app.use('/api/v1', v1Router); // Auth endpoints (email OTP verification) - uses Redis when available app.use('/auth', require('./src/routes/v1/authRoutes')(redisClient)); -// API key management endpoints (rotation, invalidation, listing) -app.use('/auth/api-keys', require('./src/routes/v1/apiKeyRoutes')(redisClient)); - -// #497 — Expose RSA public key as a JWKS document so external services can -// verify RS256-signed tokens without sharing a secret. -app.get('/.well-known/jwks.json', (_req, res) => { - try { - const { getJwks } = require('./src/utils/jwt'); - const jwks = getJwks(); - res.setHeader('Content-Type', 'application/json'); - // Cache for 1 hour — key rotations are infrequent and consumers should - // re-fetch on verification failure anyway. - res.setHeader('Cache-Control', 'public, max-age=3600'); - return res.json(jwks); - } catch (err) { - logger.warn({ err }, 'JWKS endpoint: JWT_PUBLIC_KEY is not configured'); - return res.status(503).json({ error: 'JWKS not available: public key not configured.' }); - } -}); - app.get('/.well-known/stellar.toml', (_req, res) => { res.header("Access-Control-Allow-Origin", "*"); res.setHeader('Content-Type', 'text/plain'); @@ -1007,47 +794,47 @@ app.get('/api/v1/time', (_req, res) => { res.status(200).json({ time: new Date().toISOString() }); }); -app.get('/health', async (req, res) => { +app.get('/health', asyncHandler(async (_req, res) => { const checks = { database: null, redis: null }; let allOk = true; const errors = []; try { await prisma.$queryRaw`SELECT 1`; - checks.database = 'up'; - } catch (err) { - checks.database = 'down'; + checks.database = 'ok'; + } catch { + checks.database = 'error'; allOk = false; errors.push('Database unavailable'); - logger.error(err, `[Correlation ID: ${req.correlationId}] Database health check failed`); } if (redisClient) { try { await redisClient.ping(); - checks.redis = 'up'; - } catch (err) { - checks.redis = 'down'; + checks.redis = 'ok'; + } catch { + checks.redis = 'error'; allOk = false; errors.push('Redis unavailable'); - logger.error(err, `[Correlation ID: ${req.correlationId}] Redis health check failed`); } } else { checks.redis = 'not configured'; } - const response = { - status: allOk ? 'UP' : 'DOWN', - timestamp: new Date().toISOString(), - ...checks, - }; - - if (!allOk) { - response.message = errors.join(', '); - return res.status(503).json(response); + if (allOk) { + res.json({ status: 'ok', ...checks }); + } else { + res.status(503).json({ status: 'error', ...checks, message: errors.join(', ') }); } +})); - return res.status(200).json(response); +app.use((err, _req, _res, next) => { + if (err.type === 'entity.too.large') { + const error = new Error('Payload too large. Maximum allowed size is 10kb.'); + error.statusCode = 413; + return next(error); + } + next(err); }); // #295 — Report 5xx errors to Sentry (via defaultShouldHandleError) before @@ -1056,14 +843,42 @@ if (process.env.SENTRY_DSN) { Sentry.setupExpressErrorHandler(app); } -// Unmatched routes and every error share the standard envelope. -app.use(notFoundHandler); -app.use(buildErrorHandler(isPrismaConnectionError)); +// Global error handling middleware +// eslint-disable-next-line no-unused-vars +app.use((err, req, res, _next) => { + if (isPrismaConnectionError(err)) { + err.statusCode = 503; + err.message = 'Service Unavailable'; + } + + const statusCode = err.statusCode || 500; + const errorMessage = err.message || 'Internal server error'; + + if (statusCode >= 500) { + const errorId = crypto.randomUUID(); + // #31 — Prefix error logs with the correlation ID so a single API call can + // be traced across every log line it produced. + logger.error(`[Correlation ID: ${req.correlationId}] [Error ID: ${errorId}]`, err); + return res.status(statusCode).json({ + success: false, + error: statusCode === 500 ? 'Internal Server Error' : errorMessage, + reference_id: errorId, + correlation_id: req.correlationId, + ...(statusCode !== 500 ? { statusCode } : {}) + }); + } + + return res.status(statusCode).json({ + success: false, + error: errorMessage, + statusCode: statusCode, + }); +}); const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS, 10) || 10_000; let isShuttingDown = false; -const gracefulShutdown = (server, prismaClient, signal, redis = null) => { +const gracefulShutdown = (server, prismaClient, signal) => { if (isShuttingDown) return; isShuttingDown = true; @@ -1083,14 +898,7 @@ const gracefulShutdown = (server, prismaClient, signal, redis = null) => { try { await prismaClient.$disconnect(); } catch (err) { - logger.error(err, 'Error disconnecting Prisma during shutdown:'); - } - if (redis) { - try { - await redis.quit(); - } catch (err) { - logger.error(err, 'Error disconnecting Redis during shutdown:'); - } + logger.error('Error disconnecting Prisma during shutdown:', err); } process.exit(0); }); @@ -1104,13 +912,13 @@ if (require.main === module) { server.on('error', (e) => { if (e.code === 'EADDRINUSE') { - logger.error(e, `Port ${PORT} is in use, forcing shutdown so Railway can restart cleanly.`); + logger.error(`Port ${PORT} is in use, forcing shutdown so Railway can restart cleanly.`); process.exit(1); } }); - process.on('SIGTERM', (sig) => gracefulShutdown(server, prisma, sig, redisClient)); - process.on('SIGINT', (sig) => gracefulShutdown(server, prisma, sig, redisClient)); + process.on('SIGTERM', (sig) => gracefulShutdown(server, prisma, sig)); + process.on('SIGINT', (sig) => gracefulShutdown(server, prisma, sig)); } module.exports = { app, gracefulShutdown, rejectNestedObjects, validateMemo }; diff --git a/stellar-payment-platform/src/middleware/signatureRateLimit.js b/stellar-payment-platform/src/middleware/signatureRateLimit.js new file mode 100644 index 00000000..4bc3d4a6 --- /dev/null +++ b/stellar-payment-platform/src/middleware/signatureRateLimit.js @@ -0,0 +1,26 @@ +const rateLimit = require('express-rate-limit'); +const RedisStore = require('rate-limit-redis'); + +// Stricter secondary limit for endpoints that run signature verification +// (Horizon lookups + crypto). Keyed by IP so a single client cannot exhaust +// CPU by hammering these routes. Apply only to the heavy POST handlers. +const WINDOW_MS = 60 * 1000; +const MAX_REQUESTS = Number(process.env.SIGNATURE_RATE_LIMIT_MAX) || 10; + +const createSignatureRateLimiter = (redisClient) => + rateLimit({ + windowMs: WINDOW_MS, + max: MAX_REQUESTS, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.ip || req.socket?.remoteAddress || 'unknown', + store: redisClient + ? new RedisStore({ + prefix: 'sig-rl:', + sendCommand: (...args) => redisClient.sendCommand(args), + }) + : undefined, + message: { error: 'Too many requests, please try again later.' }, + }); + +module.exports = { createSignatureRateLimiter }; diff --git a/stellar-payment-platform/src/routes/v1/userRoutes.js b/stellar-payment-platform/src/routes/v1/userRoutes.js index a5147d52..8803eaf8 100644 --- a/stellar-payment-platform/src/routes/v1/userRoutes.js +++ b/stellar-payment-platform/src/routes/v1/userRoutes.js @@ -4,40 +4,24 @@ const { StrKey } = require('@stellar/stellar-sdk'); const { prisma } = require('../../../prismaClient'); const { verifyMultiSignerThreshold } = require('../../multisigner-verifier'); const { poolGet, poolRun, poolAll } = require('../../db'); -const { logger } = require('../../logger'); const { lookupCached, invalidateFederationCache } = require('../../cache'); -const { - paginatedResponse, - parsePagination, - parseCursorQuery, - keysetWhereDesc, - paginateByKeyset, - cursorPaginatedResponse, -} = require('../../pagination'); +const { parsePagination, paginatedResponse } = require('../../pagination'); const { asyncHandler } = require('../../middleware/asyncHandler'); +const { createSignatureRateLimiter } = require('../../middleware/signatureRateLimit'); const { normalizeNameTag, validateMemo, RESERVED_NAMES, - MAX_USERNAMES_PER_ADDRESS, - PRIMARY_USERNAME_ORDER, shouldFallbackToLocalRegistry, } = require('../../utils'); -const { validateSchema } = require('../../middleware/validateSchema'); -const { ApiError } = require('../../errors'); -const { requireJson } = require('../../middleware/requireJson'); -const { - registerBodySchema, - lookupQuerySchema, - usersQuerySchema, -} = require('../../schemas'); const router = express.Router(); +const signatureRateLimiter = createSignatureRateLimiter(); + const buildUserSearchWhere = (search) => { if (!search) return {}; return { - deletedAt: null, OR: [ { username: { contains: search, mode: 'insensitive' } }, { address: { contains: search, mode: 'insensitive' } }, @@ -116,37 +100,28 @@ const registerLocalUser = async ({ username, address }) => { ); }; -router.post('/register', requireJson, validateSchema({ body: registerBodySchema }), asyncHandler(async (req, res, next) => { +router.post('/register', signatureRateLimiter, asyncHandler(async (req, res, next) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: "Unsupported Media Type. Please send application/json" }); + } const safeUsername = xss(req.body.username); const username = normalizeNameTag(safeUsername); - const { address, memo_type: memoType, memo, signature = '', signerAddress = '' } = req.body; + const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; + const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; + const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + const signature = typeof req.body.signature === 'string' ? req.body.signature.trim() : ''; if (address.toUpperCase().startsWith('S')) { - return next( - new ApiError( - 'INVALID_INPUT', - 'Never share your Secret Key. Please register using your Public Key (starts with G).', - ), - ); + return res.status(400).json({ error: "Never share your Secret Key. Please register using your Public Key (starts with G)." }); } if (!username || !address) { - return next(new ApiError('INVALID_INPUT', 'Missing required fields: username and address are both required.')); - } - - const BLOCKED_EXCHANGES = [ - "GA5XIGA5C7QTPTWXQYYUGCGQFBLOUZLYVVKXUHZHZWBYEAIELE4KZTOG", - "GCO2IP3VKXUNOHURKEHCDFWNOSECYIMA5QLGNTKVVHESURVDMBWGIGLO", - "GBV4ZDEPNQ2FKSPKGJP2YKDAIZWQ2XKRQD4V4ACH3TCTXTGLWEBDU3OS" - ]; - - if (BLOCKED_EXCHANGES.includes(address) && !memo) { - return next(new ApiError('INVALID_INPUT', "Cannot map federation addresses directly to custodial exchange master wallets.")); + return res.status(400).json({ error: 'Missing required fields: username and address are both required.' }); } const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; if (usernameLocalPart.length < 3) { - return next(new ApiError('INVALID_INPUT', "Username must be at least 3 characters long.")); + return res.status(400).json({ error: "Username must be at least 3 characters long." }); } if (!StrKey.isValidEd25519PublicKey(address)) { @@ -157,39 +132,35 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema const memoError = validateMemo(memoType, memo); if (memoError) { - return next(new ApiError('INVALID_INPUT', memoError)); + return res.status(400).json({ error: memoError }); } - + if (signature && !StrKey.isValidEd25519PublicKey(signature)) { + const error = new Error('Invalid Stellar Public Key format.'); + error.statusCode = 400; + return next(error); + } const normalizedUsername = username.toLowerCase(); if (RESERVED_NAMES.includes(normalizedUsername)) { - return next(new ApiError('FORBIDDEN', 'This username is reserved and cannot be registered.')); + return res.status(403).json({ error: "This username is reserved and cannot be registered." }); } try { - // #613 — an address may carry several usernames (aliases). Registration - // adds another while the address is under the cap; the first username - // registered for an address becomes its primary. - const usernameCount = await prisma.user.count({ - where: { address, deletedAt: null }, + const existing = await prisma.user.findUnique({ + where: { address } }); - if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) { - return next( - new ApiError( - 'CONFLICT', - `This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`, - ), - ); + if (existing) { + const conflictError = new Error('Address already registered'); + conflictError.statusCode = 409; + return next(conflictError); } - const isPrimary = usernameCount === 0; let verificationResult = null; - const signerToVerify = signerAddress || address; - if (signerToVerify) { - verificationResult = await verifyMultiSignerThreshold(address, [signerToVerify], { + if (signature) { + verificationResult = await verifyMultiSignerThreshold(address, [signature], { operationType: 'management', }); @@ -206,7 +177,6 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema data: { username: normalizedUsername, address, - isPrimary, ...(memoType && { memoType, memo }), }, }); @@ -217,7 +187,6 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema ok: true, username: normalizedUsername, address, - is_primary: isPrimary, federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, ...(verificationResult && { verification: { @@ -232,224 +201,108 @@ router.post('/register', requireJson, validateSchema({ body: registerBodySchema }); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT' || (error.message && error.message.includes('UNIQUE'))) { - return next(new ApiError('CONFLICT', 'Username is already taken. Please choose another.')); + return res.status(409).json({ error: 'Username is already taken. Please choose another.' }); } - + if (error.message && error.message.includes('Account not found')) { const notFoundError = new Error(`Account not found on Horizon: ${address}`); notFoundError.statusCode = 404; return next(notFoundError); } - if (error.statusCode === 401) { - return next(error); - } - - logger.error('Registration error:', error.message); - const registrationError = new Error(`Registration verification failed: ${error.message}`, { cause: error }); - registrationError.statusCode = 500; - return next(registrationError); + throw error; } })); -router.all('/register', (req, res, next) => next(new ApiError('METHOD_NOT_ALLOWED'))); +router.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" })); -// #18 — Soft-delete endpoint. Sets deleted_at to now() instead of running a -// hard DELETE so the row is preserved for historical auditing. -router.delete('/register/:username', asyncHandler(async (req, res, next) => { - const username = normalizeNameTag( - typeof req.params.username === 'string' ? req.params.username.trim() : '', - ).toLowerCase(); +router.get('/lookup', asyncHandler(async (req, res, next) => { + const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; - if (!username) { - const error = new Error('Missing username parameter'); + if (!address && !search) { + const error = new Error("Missing required parameter: provide 'address' for exact lookup or 'search' for paginated search"); error.statusCode = 400; return next(error); } - try { - const existing = await prisma.user.findFirst({ - where: { username, deletedAt: null }, + if (address) { + const result = await lookupCached(address, async () => { + const row = await prisma.user.findUnique({ + where: { address }, + select: { username: true }, + }); + return row ? { username: row.username, address } : null; }); - if (!existing) { - const notFoundError = new Error('Username not found or already deleted'); + if (!result) { + const notFoundError = new Error('Username not found for this address'); notFoundError.statusCode = 404; return next(notFoundError); } - await prisma.user.update({ - where: { username }, - data: { deletedAt: new Date() }, - }); - - // Invalidate any stale federation cache entries - invalidateFederationCache(username, existing.address); - - return res.status(200).json({ ok: true, username, deleted: true }); - } catch (error) { - logger.error('Failed to unregister account:', error); - const dbError = new Error('Failed to unregister account', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } -})); - -router.get('/lookup', validateSchema({ query: lookupQuerySchema }), asyncHandler(async (req, res, next) => { - const { address = '', search = '' } = req.query; - - if (address) { - try { - const result = await lookupCached(address, async () => { - // #613 — an address can have several usernames; return the primary. - const row = await prisma.user.findFirst({ - where: { address, deletedAt: null }, - select: { username: true }, - orderBy: PRIMARY_USERNAME_ORDER, - }); - return row ? { username: row.username, address } : null; - }); - - if (!result) { - const notFoundError = new Error('Username not found for this address'); - notFoundError.statusCode = 404; - return next(notFoundError); - } - - return res.json(result); - } catch (error) { - console.warn('USER ROUTES ERROR:', error); - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } + return res.json(result); } - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } const where = buildUserSearchWhere(search); - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - skip, - take: limit, - }), - ]); - -const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt.toISOString(), - })); - - return res.json({ data, totalCount, totalPages, currentPage: page }); - } catch (error) { - const dbError = new Error('Database lookup failed', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })); + + return res.json({ data, totalCount, totalPages, currentPage: page }); })); -router.get('/users', validateSchema({ query: usersQuerySchema }), asyncHandler(async (req, res, next) => { - const { limit: cursorLimit, cursor, invalid: invalidCursor } = parseCursorQuery(req.query); +router.get('/users', asyncHandler(async (req, res) => { const { page, limit, skip } = parsePagination(req.query); - if (invalidCursor) { - return next(new ApiError('INVALID_INPUT', 'Invalid cursor parameter')); - } - const search = req.query.search ?? null; - const where = search ? buildUserSearchWhere(search) : { deletedAt: null }; - - try { - if (cursor) { - // Keyset mode: seek straight past the cursor row instead of skipping - // every preceding row, so deep pages cost the same as page one. - const candidates = await prisma.user.findMany({ - where: { AND: [where, keysetWhereDesc(cursor)] }, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - take: cursorLimit + 1, - }); - const { rows, hasMore, nextCursor } = paginateByKeyset(candidates, cursorLimit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - return res.json(cursorPaginatedResponse(data, { limit: cursorLimit, nextCursor, hasMore })); - } - - const [totalCount, rows] = await prisma.$transaction([ - prisma.user.count({ where }), - prisma.user.findMany({ - where, - orderBy: [ - { createdAt: 'desc' }, - { username: 'desc' }, - ], - skip, - take: limit, - }), - ]); + const search = typeof req.query.search === 'string' ? req.query.search : null; + const where = buildUserSearchWhere(search); -const totalPages = Math.ceil(totalCount / limit); - const data = rows.map((user) => ({ - username: user.username, - address: user.address, - created_at: user.createdAt ? user.createdAt.toISOString() : undefined, - })); - - res.json({ - data, - meta: { - total: totalCount, - totalCount, - page, - currentPage: page, - limit, - totalPages, - }, + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt ? user.createdAt.toISOString() : undefined, + })); + + res.json({ + data, + meta: { + total: totalCount, totalCount, - totalPages, + page, currentPage: page, - }); - } catch (error) { - const dbError = new Error('Database error', { cause: error }); - dbError.statusCode = 500; - return next(dbError); - } + limit, + totalPages, + }, + totalCount, + totalPages, + currentPage: page, + }); })); module.exports = router; diff --git a/stellar-payment-platform/src/routes/v1/webhookRoutes.js b/stellar-payment-platform/src/routes/v1/webhookRoutes.js index cd010366..2203ef56 100644 --- a/stellar-payment-platform/src/routes/v1/webhookRoutes.js +++ b/stellar-payment-platform/src/routes/v1/webhookRoutes.js @@ -7,10 +7,13 @@ const { verifyMultiSignerThreshold } = require('../../multisigner-verifier'); const { logger } = require('../../logger'); const { Keypair, StrKey } = require('@stellar/stellar-sdk'); const { asyncHandler } = require('../../middleware/asyncHandler'); +const { createSignatureRateLimiter } = require('../../middleware/signatureRateLimit'); const { shouldFallbackToLocalRegistry } = require('../../utils'); const router = express.Router(); +const signatureRateLimiter = createSignatureRateLimiter(); + const DEFAULT_FEDERATION_DOMAIN = 'localhost'; const verifyFreighterSignedMessage = ({ @@ -150,7 +153,7 @@ const normalizeWebhookEvents = (input) => { return events; }; -router.post('/webhooks', asyncHandler(async (req, res, next) => { +router.post('/webhooks', signatureRateLimiter, asyncHandler(async (req, res, next) => { try { if (!req.is('application/json')) { return res.status(415).json({ error: 'Unsupported Media Type. Please send application/json' }); diff --git a/stellar-payment-platform/tests/rate-limit.test.js b/stellar-payment-platform/tests/rate-limit.test.js index 71aad901..8a2bf6d2 100644 --- a/stellar-payment-platform/tests/rate-limit.test.js +++ b/stellar-payment-platform/tests/rate-limit.test.js @@ -68,11 +68,53 @@ jest.mock('../src/multisigner-verifier', () => ({ }), })); -jest.mock('pg', () => ({ - Pool: jest.fn().mockImplementation(() => ({ - query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), - on: jest.fn(), - end: jest.fn().mockResolvedValue(undefined), +jest.mock('../src/validators/registerValidator', () => ({ + registerValidator: [ + { + run: jest.fn().mockResolvedValue(undefined), + }, + ], +})); + +jest.mock('express-validator', () => ({ + validationResult: jest.fn(() => ({ + isEmpty: () => true, + array: () => [], + })), +})); + +jest.mock('sqlite3', () => ({ + verbose: () => ({ + Database: jest.fn().mockImplementation((_path, cb) => { + const db = { + run: jest.fn((sql, cb2) => cb2 && cb2(null)), + close: jest.fn((cb2) => cb2 && cb2()), + }; + if (cb) cb(null); + return db; + }), + }), +})); + +jest.mock('generic-pool', () => ({ + createPool: jest.fn(() => ({ + acquire: jest.fn().mockResolvedValue({ + run: jest.fn((sql, params, cb) => { + const fn = typeof params === 'function' ? params : cb; + if (fn) fn.call({ lastID: 0, changes: 0 }, null); + }), + get: jest.fn((sql, params, cb) => { + const fn = typeof params === 'function' ? params : cb; + if (fn) fn(null, null); + }), + all: jest.fn((sql, params, cb) => { + const fn = typeof params === 'function' ? params : cb; + if (fn) fn(null, []); + }), + }), + release: jest.fn(), + drain: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(undefined), })), })); @@ -80,7 +122,6 @@ jest.mock('../src/metrics', () => ({ metricsMiddleware: (req, res, next) => next(), getMetrics: jest.fn().mockResolvedValue(''), getContentType: jest.fn(() => 'text/plain'), - setMetricsSources: jest.fn(), })); jest.mock('@sentry/node', () => ({ @@ -105,7 +146,7 @@ describe('Rate Limiting — express-rate-limit', () => { describe('standard headers', () => { it('includes RateLimit-Limit header on /federation', async () => { const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.headers).toHaveProperty('ratelimit-limit'); @@ -114,29 +155,28 @@ describe('Rate Limiting — express-rate-limit', () => { it('includes RateLimit-Remaining header on /federation', async () => { const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.headers).toHaveProperty('ratelimit-remaining'); }); - it('includes RateLimit-Limit header on /register', async () => { + it('advertises the stricter signature-heavy limit on /register', async () => { const res = await request(app) - .post('/api/v1/register') + .post('/register') .send({ username: 'alice', address: VALID_ADDRESS }); expect(res.headers).toHaveProperty('ratelimit-limit'); - expect(res.headers['ratelimit-limit']).toBe('100'); + expect(res.headers['ratelimit-limit']).toBe('10'); }); - it('includes X-RateLimit-* legacy headers', async () => { + it('does NOT include deprecated X-RateLimit-* headers', async () => { const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); - expect(res.headers).toHaveProperty('x-ratelimit-limit'); - expect(res.headers).toHaveProperty('x-ratelimit-remaining'); - expect(res.headers).toHaveProperty('x-ratelimit-reset'); + expect(res.headers).not.toHaveProperty('x-ratelimit-limit'); + expect(res.headers).not.toHaveProperty('x-ratelimit-remaining'); }); }); @@ -147,22 +187,18 @@ describe('Rate Limiting — express-rate-limit', () => { // Send 100 requests (should all succeed or get normal responses) for (let i = 0; i < 100; i++) { await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); } // The 101st request should be rate limited const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.status).toBe(429); expect(res.body).toEqual({ - success: false, - error: { - code: 'RATE_LIMITED', - message: 'Too many requests, please try again later.', - }, + error: 'Too many requests, please try again later.', }); }); @@ -171,33 +207,29 @@ describe('Rate Limiting — express-rate-limit', () => { for (let i = 0; i < 100; i++) { await request(app) - .post('/api/v1/register') + .post('/register') .send(payload); } const res = await request(app) - .post('/api/v1/register') + .post('/register') .send(payload); expect(res.status).toBe(429); expect(res.body).toEqual({ - success: false, - error: { - code: 'RATE_LIMITED', - message: 'Too many requests, please try again later.', - }, + error: 'Too many requests, please try again later.', }); }); it('includes Retry-After header on 429 responses', async () => { for (let i = 0; i < 100; i++) { await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); } const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.status).toBe(429); @@ -205,6 +237,36 @@ describe('Rate Limiting — express-rate-limit', () => { }); }); + // ── Signature-heavy secondary limiter (10/min per IP) ─────────────────── + + describe('signature-heavy limiter', () => { + it('blocks the 11th POST /register with 429 + Retry-After', async () => { + const payload = { username: 'carol', address: VALID_ADDRESS }; + + for (let i = 0; i < 10; i++) { + const ok = await request(app).post('/register').send(payload); + expect(ok.status).not.toBe(429); + } + + const res = await request(app).post('/register').send(payload); + + expect(res.status).toBe(429); + expect(res.headers).toHaveProperty('retry-after'); + expect(res.body).toEqual({ + error: 'Too many requests, please try again later.', + }); + }); + + it('does not apply to GET endpoints', async () => { + for (let i = 0; i < 20; i++) { + const res = await request(app) + .get('/federation') + .query({ q: 'client*localhost' }); + expect(res.status).not.toBe(429); + } + }); + }); + // ── Rate limit counter is shared across endpoints ──────────────────────── describe('shared rate limit counter', () => { @@ -216,7 +278,7 @@ describe('Rate Limiting — express-rate-limit', () => { // /federation should now be rate limited too const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.status).toBe(429); @@ -229,7 +291,7 @@ describe('Rate Limiting — express-rate-limit', () => { it('fresh app instance has a full budget', async () => { // The beforeEach resetModules gives us a fresh app const res = await request(app) - .get('/api/v1/federation') + .get('/federation') .query({ q: 'client*localhost' }); expect(res.status).toBe(200);