Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/public-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"express": "5.1.0",
"express-rate-limit": "8.2.1",
"ioredis": "5.10.0",
"rate-limit-redis": "3.1.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for security advisories for rate-limit-redis 3.1.0
gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: NPM, package: "rate-limit-redis") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

# Check NPM for latest version and 3.1.0 release date
echo "=== rate-limit-redis versions ==="
npm view rate-limit-redis time --json | jq '.["3.1.0"], .["4.0.2"], .modified'

# Check compatibility notes in the package README or changelog
echo "=== Checking package metadata for 3.1.0 ==="
npm view rate-limit-redis@3.1.0 peerDependencies
npm view rate-limit-redis@3.1.0 dependencies

echo "=== Checking package metadata for 4.0.2 ==="
npm view rate-limit-redis@4.0.2 peerDependencies
npm view rate-limit-redis@4.0.2 dependencies

Repository: geturbackend/urBackend

Length of output: 152


🌐 Web query:

rate-limit-redis 3.1.0 vs 4.0.2 breaking changes compatibility ioredis 5.x

💡 Result:

The rate-limit-redis package maintains compatibility with ioredis 5.x throughout the v3 and v4 release cycles [1][2]. Transitioning from v3.1.0 to v4.0.2 involves several breaking changes and architectural shifts [3][4]. Breaking Changes (v3.1.0 to v4.0.2) 1. Node.js Support: Version 4.0.0 and later dropped support for Node.js v14 [3][4]. Users must be on Node.js 16 or higher [5][2]. 2. Dependency Updates: Version 4.0.0 introduced support for express-rate-limit v7 [3][4]. This often necessitates updating the express-rate-limit middleware alongside the store [6]. 3. Packaging Changes: Version 4.0.0 switched to using pkgroll for bundling, which may affect how the library is resolved or tree-shaken in some build environments [3][4]. Compatibility with ioredis 5.x ioredis 5.x remains fully compatible with both v3.1.0 and v4.x.x [1][2]. The integration pattern for ioredis relies on the sendCommand option, which has remained consistent [1][2]. For both versions, you should configure the store as follows [1][2]: new RedisStore({ sendCommand: (...args) => client.call(...args), }) Note that while the configuration pattern is stable, later versions (4.x and 5.x) have improved internal script handling and bug fixes, such as the resolution of race conditions affecting hit counters and TTLs [7]. Always ensure that each rate limiter instance uses its own new RedisStore instance to prevent counter collisions [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== peerDependencies for rate-limit-redis@3.1.0 ==="
npm view rate-limit-redis@3.1.0 peerDependencies --json || true

echo "=== peerDependencies for rate-limit-redis@4.0.2 ==="
npm view rate-limit-redis@4.0.2 peerDependencies --json || true

echo "=== dependencies for rate-limit-redis@3.1.0 ==="
npm view rate-limit-redis@3.1.0 dependencies --json || true

echo "=== dependencies for rate-limit-redis@4.0.2 ==="
npm view rate-limit-redis@4.0.2 dependencies --json || true

Repository: geturbackend/urBackend

Length of output: 152


🌐 Web query:

rate-limit-redis 3.1.0 security vulnerability advisory

💡 Result:

There are no known security vulnerability advisories specifically for the rate-limit-redis package version 3.1.0 [1]. It is possible that you may be conflating this package with a vulnerability in its core dependency, express-rate-limit. Specifically, a high-severity vulnerability (CVE-2026-30827) was recently identified in express-rate-limit (versions 8.0.0 through 8.2.1), which causes IPv4-mapped IPv6 addresses to share a single rate-limit bucket on dual-stack servers [2][3]. This issue has been patched in newer versions of express-rate-limit [3][4]. If you are using rate-limit-redis, ensure that your dependencies are up to date. The latest releases of rate-limit-redis (as of mid-2026) require express-rate-limit version 8.5.0 or higher [1]. Always verify security advisories against the specific package name, as vulnerability scanning tools may sometimes flag dependencies or misattribute issues [5][6].

Citations:


🌐 Web query:

rate-limit-redis 4.0.2 security vulnerability advisory

💡 Result:

There are no known security vulnerabilities specifically targeting the rate-limit-redis package itself [1]. However, the rate-limit-redis package is a store for the express-rate-limit middleware, and it is important to note that a vulnerability has been identified in the parent middleware, express-rate-limit, which may affect applications using it [2][3][4]. The vulnerability, CVE-2026-30827, affects express-rate-limit versions 8.0.0 through 8.2.1 [3][4]. It involves an issue where IPv4-mapped IPv6 addresses are handled incorrectly on dual-stack network servers, potentially allowing a single client to exhaust the shared rate limit bucket and trigger a denial-of-service for all IPv4 clients [3][4]. If your application uses express-rate-limit within this vulnerable range, it is recommended to upgrade express-rate-limit to version 8.0.2, 8.1.1, 8.2.2, or 8.3.0 and higher [3][4]. You should ensure that your project's dependencies are updated accordingly [3]. For ongoing monitoring, you can check for updates to your project's dependencies using tools like npm audit or security scanning platforms [2][1].

Citations:


Clarify rate-limit-redis downgrade risk and prioritize the real security fix

  • No known published security advisories specifically target rate-limit-redis@3.1.0; upstream indicates rate-limit-redis stays compatible with ioredis@5.x across the v3/v4 line (when using the standard sendCommand/client.call(...) integration).
  • The security problem is instead in express-rate-limit@8.2.1 (CVE-2026-30827; affects 8.0.08.2.1), so upgrading express-rate-limit to a patched release is required regardless of the rate-limit-redis version.
  • Confirm the downgrade doesn’t break integration assumptions between rate-limit-redis@3.1.0, express-rate-limit@8.2.1, and ioredis@5.10.0 (peer-dependency/integration expectations).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/public-api/package.json` at line 31, The package change downgraded
rate-limit-redis but the real security fix is to upgrade express-rate-limit
(CVE-2026-30827); update express-rate-limit to a patched release (>=8.2.2) in
package.json, run CI/unit/integration tests exercising rate-limit middleware,
and confirm the app still integrates with rate-limit-redis@3.1.0 and
ioredis@5.10.0 by verifying usage of sendCommand/client.call and any
peer-dependency expectations in the rate-limit-redis and ioredis docs; update
the PR description to state that no published advisory targets
rate-limit-redis@3.1.0 and that the security remediation is the
express-rate-limit upgrade.

"jsonwebtoken": "9.0.2",
"mongoose": "8.19.2",
"multer": "2.0.2",
Expand All @@ -37,6 +38,7 @@
"zod": "4.1.13"
},
"devDependencies": {
"autocannon": "8.0.0",
"jest": "30.3.0",
"supertest": "7.2.2"
}
Expand Down
50 changes: 50 additions & 0 deletions apps/public-api/src/controllers/health.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,56 @@ const getHealth = async (req, res) => {
});
};

const getRedisHealth = async (req, res) => {
if (!redis || redis.status !== 'ready') {
return res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
redis: { connected: false, error: 'Redis client not ready' },
});
Comment thread
Abhushan187 marked this conversation as resolved.
}

try {
const info = await redis.info('stats');
const memory = await redis.info('memory');
const clients = await redis.info('clients');

const parseInfo = (raw, key) => {
const match = raw.match(new RegExp(`${key}:(\\d+)`));
return match ? parseInt(match[1], 10) : null;
};

const parseInfoStr = (raw, key) => {
const match = raw.match(new RegExp(`${key}:(.+)`));
return match ? match[1].trim() : null;
};

const connectedClients = parseInfo(clients, 'connected_clients');
const usedMemory = parseInfoStr(memory, 'used_memory_human');
const totalCommands = parseInfo(info, 'total_commands_processed');

return res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
redis: {
connected: true,
status: redis.status,
connectedClients,
usedMemory,
totalCommandsProcessed: totalCommands,
reconnectAttempts: redis.options?.retryStrategy ? 'enabled' : 'disabled',
},
});
} catch (err) {
return res.status(503).json({
status: 'error',
timestamp: new Date().toISOString(),
redis: { connected: false, error: err.message },
});
Comment thread
Abhushan187 marked this conversation as resolved.
}
};

module.exports = {
getHealth,
getRedisHealth,
};
34 changes: 33 additions & 1 deletion apps/public-api/src/middlewares/api_usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ const rateLimit = require('express-rate-limit');
const { Log, redis, ApiAnalytics, getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('@urbackend/common');
const FIRST_API_SUCCESS_FLAG_TTL_SECONDS = 2 * 365 * 24 * 60 * 60;

// Rate Limiter
// --- Redis-backed project rate limiter store ---
const RedisStore = require('rate-limit-redis');

// Rate Limiter (global IP-based)
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
Expand All @@ -15,6 +18,35 @@ const limiter = rateLimit({
}
});

// Project-specific Redis-backed rate limiter (replaces in-memory projectRateLimiter)
const projectRateLimiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:project:',
}),
windowMs: 15 * 60 * 1000,
max: async (req, res) => {
if (req.project && req.project.rateLimit) {
return req.project.rateLimit;
}
return 500;
},
keyGenerator: (req, res) => {
if (!req.project || !req.project._id) {
return 'unauthorized';
}
return req.project._id.toString();
},
handler: (req, res, next, options) => {
res.status(options.statusCode).json({
error: "Too Many Requests",
message: "Project rate limit exceeded. Please try again later."
});
},
standardHeaders: true,
legacyHeaders: false,
});
Comment thread
Abhushan187 marked this conversation as resolved.

// Logger with API analytics
const logger = (req, res, next) => {
// Capture start time for response time measurement
Expand Down
3 changes: 2 additions & 1 deletion apps/public-api/src/routes/health.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const express = require('express');
const { getHealth } = require('../controllers/health.controller');
const { getHealth, getRedisHealth } = require('../controllers/health.controller');

const router = express.Router();

router.get('/', getHealth);
router.get('/redis', getRedisHealth);

module.exports = router;
45 changes: 45 additions & 0 deletions apps/public-api/tests/load/rate-limit.load.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const autocannon = require('autocannon');
const { redis } = require('@urbackend/common');

const TARGET_URL = process.env.TARGET_URL || 'http://localhost:1235';
const API_KEY = process.env.TEST_API_KEY || 'test-api-key';

async function runRateLimitLoadTest() {
console.log('🚀 Starting rate limit load test...\n');

// Warm up: clear any existing rate limit keys
const keys = await redis.keys('rl:project:*');
if (keys.length) await redis.del(...keys);
console.log(`Cleared ${keys.length} existing rate limit keys\n`);

const instance = autocannon({
url: `${TARGET_URL}/api/data`,
connections: 100,
duration: 30,
headers: { 'x-api-key': API_KEY },
requests: [{ method: 'GET' }],
});

autocannon.track(instance, { renderProgressBar: true });

const result = await instance;
console.log('\n📊 Results:');
console.log(` 2xx: ${result['2xx']}`);
console.log(` 429: ${result['429']}`);
console.log(` Mean latency: ${result.latency.mean}ms`);
console.log(` P99 latency: ${result.latency.p99}ms`);
console.log(` Errors: ${result.errors}`);

// Verify Redis keys were created
const rlKeys = await redis.keys('rl:project:*');
console.log(`\n🔑 Redis rate limit keys created: ${rlKeys.length}`);
}

if (require.main === module) {
runRateLimitLoadTest().catch((err) => {
console.error('Load test failed:', err);
process.exit(1);
});
}

module.exports = { runRateLimitLoadTest };
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const request = require('supertest');
const app = require('../../src/app');
const { redis } = require('@urbackend/common');

describe('Redis resilience', () => {
afterEach(async () => {
if (redis.status === 'ready') await redis.flushdb();
});
Comment thread
Abhushan187 marked this conversation as resolved.

test('health endpoint returns 503 when Redis is unreachable', async () => {
// Simulate Redis failure by disconnecting temporarily
await redis.disconnect();

const res = await request(app).get('/api/health/redis');
expect(res.status).toBe(503);
expect(res.body.redis.connected).toBe(false);

// Reconnect for subsequent tests
await redis.connect();
});
Comment thread
Abhushan187 marked this conversation as resolved.

test('public API endpoints degrade gracefully when Redis is down', async () => {
await redis.disconnect();

const res = await request(app)
.get('/api/data')
.set('x-api-key', 'test-key');

// Should not crash; may return 401 or 500 depending on auth flow
expect([200, 401, 403, 500]).toContain(res.status);

Comment thread
Abhushan187 marked this conversation as resolved.
await redis.connect();
});
});
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ services:

# ── Redis ─────────────────────────────────────────────────────────────────────
redis:
image: redis:latest
image: redis:7-alpine
container_name: urbackend_redis
restart: unless-stopped
command: redis-server --maxclients 10000 --tcp-keepalive 60
volumes:
- redis_data:/data
sysctls:
- net.core.somaxconn=65535

# ── Dashboard API (Admin Server) ─────────────────────────────────────────────
dashboard-api:
Expand Down
17 changes: 14 additions & 3 deletions packages/common/src/config/redis.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,25 @@ if (!process.env.REDIS_URL) {

const redis = new Redis(process.env.REDIS_URL, {
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
if (times > 3) {
if (times > 10) {
console.warn("⚠️ Redis: Max retries reached. Caching will be disabled.");
return null; // Stop retrying
}
const baseDelay = Math.min(Math.pow(2, times) * 100, 30000);
const jitter = Math.floor(Math.random() * 100);
const delay = baseDelay + jitter;
console.log(`🔁 Redis retry attempt ${times}, next delay ${delay}ms`);
return delay;
},
maxRetriesPerRequest: null // Allow requests to fail if not connected
maxRetriesPerRequest: 3,
enableReadyCheck: true,
keepAlive: 30000,
connectTimeout: 10000,
disconnectTimeout: 2000,
commandTimeout: 5000,
lazyConnect: false,
enableOfflineQueue: true,
offlineQueue: true,
});

redis.on('ready', () => {
Expand Down
Loading