The refreshToken handler interpolates req.body.refreshToken directly into two SQL queries (lines 34, 43) without any sanitization or parameterization. An attacker with a crafted token string (e.g., ' OR '1'='1) can extract arbitrary rows from the sessions and users tables — including credentials for other sessions. This is a direct path to account takeover.
// Line 34 — session lookup const result = await db.query( 'SELECT * FROM sessions WHERE token = $1', [refreshToken] // parameterized, not interpolated ); // Line 43 — user lookup const user = await db.query( 'SELECT * FROM users WHERE id = $1', [session.userId] // safe, already validated above );
The /users handler constructs a WHERE clause by interpolating req.query.role directly into the SQL string. While the immediate effect is unauthorized user enumeration (users can query all admins regardless of their own role), a crafted role value could also be used to inject SQL through a separate query path. The fix is straightforward parameterization.
router.get('/users', async (req: Request, res: Response) => {
const { role } = req.query;
// Filter out roles the requesting user isn't allowed to see
const params: string[] = [];
const conditions: string[] = [];
if (role) {
params.push(role);
conditions.push(`role = $${params.length}`);
}
const whereClause = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
const query = `SELECT id, email, role FROM users${whereClause}`;
await db.query(query, params);
});
The token TTL of 15 * 60 is a raw number literal. This value is repeated or referenced in multiple places (token generation, expiry checks, rotation logic). If the TTL changes, every call-site must be found and updated manually — error-prone and brittle.
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes const REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days // Use in token creation: jwt.sign(payload, secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
The rate limiter falls back through three IP sources (req.ip, req.socket.remoteAddress, then 'unknown') inconsistently across environments. req.socket.remoteAddress may return the direct connection IP rather than the real client IP when behind a proxy — defeating the purpose of the rate limit entirely. Use req.ip consistently and rely on app.set('trust proxy', 1) in production to ensure it reflects the X-Forwarded-For header.
// At app initialization (server.js): app.set('trust proxy', 1); // trust first proxy // In rateLimit.ts: const ip = req.ip; if (!ip) { res.status(400).json({ error: 'Unable to determine client IP' }); return; }
The rateLimitMap is a Map that grows indefinitely: entries are only written, never removed. In a long-running process (Render, PM2, etc.), this map accumulates one entry per unique IP per minute. Under heavy traffic, this will eventually exhaust available memory. Add a cleanup pass on every hit, or switch to an lru-cache with a size cap.
const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); export function rateLimit(req: Request, res: Response, next: NextFunction) { const ip = req.ip; // Prune expired entries on every request (amortized O(1)) const now = Date.now(); for (const [key, entry] of rateLimitMap) { if (entry.resetAt < now) rateLimitMap.delete(key); } const entry = rateLimitMap.get(ip); // ... rest of rate limit logic }
Loopback integrates with GitHub in under 5 minutes. Set a quality gate, block insecure merges, and give your team the confidence to ship fast.