diff --git a/.env.example b/.env.example index dc8a7790..0dbeda45 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,15 @@ NODE_ENV=production # Frontend URL for CORS in production (leave empty for same-origin setups) FRONTEND_URL= -# Global API rate limit (requests per minute per IP, production only) -API_RATE_LIMIT=100 +# Global API rate limit (requests per minute per user session, production only) +# Authenticated requests are keyed by user ID; unauthenticated by IP. +# Internal node-to-node traffic (node_proxy tokens) bypasses this limit entirely. +API_RATE_LIMIT=200 + +# Polling endpoint rate limit (requests per minute, production only) +# Applies to dashboard/status polling endpoints that are exempt from the global limit. +# Increase for environments with many concurrent browser sessions behind shared NAT. +API_POLLING_RATE_LIMIT=300 # ─── SSO / LDAP Configuration (Admiral) ─────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1430ce..47af2ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **api:** add tiered rate limiting to prevent dashboard polling lockouts. High-frequency + polling endpoints (stats, system stats, stack statuses) are now exempt from the global + rate limiter and governed by a separate 300 req/min safety net. The global limit is + raised from 100 to 200 req/min. Authenticated requests are now keyed by user session + instead of IP address, preventing shared NAT/VPN environments from pooling rate limit + budgets. Internal node-to-node traffic (node_proxy tokens) bypasses all rate limiters. + Webhook triggers get a dedicated 500 req/min limit for CI/CD integrations. + ## [0.42.3](https://github.com/AnsoCode/Sencho/compare/v0.42.2...v0.42.3) (2026-04-09) diff --git a/backend/package-lock.json b/backend/package-lock.json index 728cabd3..fcb260b7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,7 +17,7 @@ "@types/http-proxy": "^1.17.17", "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", - "axios": "^1.13.6", + "axios": "^1.15.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", "composerize": "^1.7.5", @@ -2763,9 +2763,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", diff --git a/backend/package.json b/backend/package.json index 8d873aaa..130f0fcd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,7 @@ "@types/http-proxy": "^1.17.17", "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", - "axios": "^1.13.6", + "axios": "^1.15.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", "composerize": "^1.7.5", diff --git a/backend/src/__tests__/rate-limiting.test.ts b/backend/src/__tests__/rate-limiting.test.ts new file mode 100644 index 00000000..9fb00016 --- /dev/null +++ b/backend/src/__tests__/rate-limiting.test.ts @@ -0,0 +1,282 @@ +/** + * Stress tests for the tiered rate limiting system. + * + * Validates that: + * - Polling endpoints are exempt from the global limiter but have their own ceiling + * - Standard endpoints are governed by the global limiter + * - Webhook triggers have a dedicated limiter + * - Node proxy tokens bypass all rate limiters + * - Authenticated requests are keyed by user session, not IP + * + * These tests run against the real Express app with in-memory SQLite. + * Rate limits in development mode are 10x production values (1000/3000/5000), + * so we test header presence and tier separation rather than hitting ceilings. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + + // Log in to get an auth cookie for authenticated tests + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + const setCookie = loginRes.headers['set-cookie']; + authCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +// ── Tier 0/1: Polling endpoints ────────────────────────────────────────────── + +describe('Tier 0/1: Polling endpoints', () => { + const pollingPaths = [ + '/api/health', + '/api/meta', + '/api/stats', + '/api/system/stats', + '/api/stacks/statuses', + '/api/metrics/historical', + '/api/auth/status', + '/api/auth/sso/providers', + '/api/license', + ]; + + it.each(pollingPaths)('%s returns polling-tier rate limit headers', async (path) => { + const res = await request(app).get(path); + const limit = parseInt(res.headers['ratelimit-limit'], 10); + // Dev mode polling limit is 3000 (production would be 300) + expect(limit).toBe(3000); + }); + + it('polling endpoints do NOT count against the global limiter', async () => { + // Snapshot the global limiter's remaining count before polling + const before = await request(app).get('/api/stacks'); + const remainBefore = parseInt(before.headers['ratelimit-remaining'], 10); + + // Hit 10 polling endpoints (none should decrement the global counter) + await Promise.all(Array.from({ length: 10 }, () => + request(app).get('/api/health') + )); + + // Check that the global limiter's remaining decreased by exactly 1 + // (the GET /api/stacks below, not the 10 polling requests) + const after = await request(app).get('/api/stacks'); + const remainAfter = parseInt(after.headers['ratelimit-remaining'], 10); + expect(remainAfter).toBe(remainBefore - 1); + }); + + it('rapid polling does not trigger global rate limit', async () => { + // Snapshot the global limiter budget before polling + const before = await request(app).get('/api/stacks'); + const remainBefore = parseInt(before.headers['ratelimit-remaining'], 10); + + // Fire 20 rapid polling requests + const requests = Array.from({ length: 20 }, () => + request(app).get('/api/stats') + ); + const results = await Promise.all(requests); + + // All should succeed (no 429) + for (const res of results) { + expect(res.status).not.toBe(429); + } + + // Standard endpoint budget should only decrease by 1 (this request), + // not by 21 (the 20 polling requests should not count) + const after = await request(app).get('/api/stacks'); + const remainAfter = parseInt(after.headers['ratelimit-remaining'], 10); + expect(remainAfter).toBe(remainBefore - 1); + }); +}); + +// ── Tier 2: Standard endpoints ─────────────────────────────────────────────── + +describe('Tier 2: Standard endpoints', () => { + it('standard endpoints return global-tier rate limit headers', async () => { + const res = await request(app).get('/api/stacks'); + const limit = parseInt(res.headers['ratelimit-limit'], 10); + // Dev mode global limit is 1000 (production would be 200) + expect(limit).toBe(1000); + }); + + it('standard endpoints do NOT get polling-tier headers', async () => { + const res = await request(app).get('/api/containers'); + const limit = parseInt(res.headers['ratelimit-limit'], 10); + // Should be 1000 (global), not 3000 (polling) + expect(limit).not.toBe(3000); + expect(limit).toBe(1000); + }); +}); + +// ── Node proxy bypass ──────────────────────────────────────────────────────── + +describe('Node proxy bypass', () => { + it('requests with valid node_proxy token bypass all rate limiters', async () => { + const nodeToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1h' }); + + const res = await request(app) + .get('/api/stats') + .set('Authorization', `Bearer ${nodeToken}`); + + // Node proxy requests should NOT receive rate limit headers from either limiter + // (both skip functions return true for node_proxy tokens) + // When skipped, express-rate-limit does not set headers + expect(res.headers['ratelimit-limit']).toBeUndefined(); + }); + + it('requests with invalid Bearer token still get rate limited', async () => { + const res = await request(app) + .get('/api/stats') + .set('Authorization', 'Bearer invalid-token-here'); + + // Should still get rate limit headers (polling tier for /stats) + const limit = parseInt(res.headers['ratelimit-limit'], 10); + expect(limit).toBe(3000); + }); + + it('requests with non-node_proxy Bearer token still get rate limited', async () => { + // A regular user JWT (not node_proxy scope) should still be rate limited + const userToken = jwt.sign( + { username: 'testuser', role: 'admin' }, + TEST_JWT_SECRET, + { expiresIn: '1h' } + ); + + const res = await request(app) + .get('/api/stacks') + .set('Authorization', `Bearer ${userToken}`); + + const limit = parseInt(res.headers['ratelimit-limit'], 10); + expect(limit).toBe(1000); + }); +}); + +// ── Hybrid key generator (per-user keying) ─────────────────────────────────── + +describe('Hybrid key generator', () => { + it('authenticated requests are keyed by user, not IP', async () => { + // Make requests with auth cookie + const res1 = await request(app) + .get('/api/stacks') + .set('Cookie', authCookie); + + // Make a request without auth (keyed by IP) + const res2 = await request(app).get('/api/stacks'); + + // Both should succeed, and their remaining counts should be independent + // (different rate limit buckets: user:testadmin vs IP) + const remaining1 = parseInt(res1.headers['ratelimit-remaining'], 10); + const remaining2 = parseInt(res2.headers['ratelimit-remaining'], 10); + + // Each should have its own budget (999 for this test since each made 1 request) + expect(remaining1).toBeGreaterThanOrEqual(990); + expect(remaining2).toBeGreaterThanOrEqual(990); + }); + + it('two different users get independent rate limit budgets', async () => { + // User A (via cookie) + const resA = await request(app) + .get('/api/stacks') + .set('Cookie', authCookie); + + // User B (via Bearer with different username) + const userBToken = jwt.sign( + { username: 'otheruser', role: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '1h' } + ); + const resB = await request(app) + .get('/api/stacks') + .set('Authorization', `Bearer ${userBToken}`); + + // Both should have independent remaining counts + const remainA = parseInt(resA.headers['ratelimit-remaining'], 10); + const remainB = parseInt(resB.headers['ratelimit-remaining'], 10); + + // Each user's budget should be near-full + expect(remainA).toBeGreaterThanOrEqual(990); + expect(remainB).toBeGreaterThanOrEqual(990); + }); +}); + +// ── Webhook trigger tier ───────────────────────────────────────────────────── + +describe('Tier W: Webhook trigger', () => { + it('webhook trigger route is exempt from the global limiter', async () => { + // We can't easily test the webhook-specific limiter without creating a webhook, + // but we can verify the global limiter skip logic by checking that rapid + // POST requests to a webhook-trigger-shaped path don't decrement the global counter. + + // Hit a standard endpoint first to establish a baseline + const baseline = await request(app).get('/api/stacks'); + const baselineRemaining = parseInt(baseline.headers['ratelimit-remaining'], 10); + + // POST to webhook trigger (will 404 since no webhook exists, but the rate + // limiter skip runs before the route handler) + await request(app).post('/api/webhooks/999/trigger').send({}); + + // Check that the global limiter's remaining hasn't decreased + const after = await request(app).get('/api/stacks'); + const afterRemaining = parseInt(after.headers['ratelimit-remaining'], 10); + + // Should have decreased by exactly 1 (the GET /api/stacks request itself) + expect(afterRemaining).toBe(baselineRemaining - 1); + }); +}); + +// ── Tier separation stress test ────────────────────────────────────────────── + +describe('Tier separation under load', () => { + it('50 polling requests do not affect standard endpoint budget', async () => { + // Blast 50 polling requests in parallel + const pollingRequests = Array.from({ length: 50 }, () => + request(app).get('/api/health') + ); + await Promise.all(pollingRequests); + + // Standard endpoint should still have a nearly full budget + const stdRes = await request(app).get('/api/stacks'); + const remaining = parseInt(stdRes.headers['ratelimit-remaining'], 10); + + // Should have lost very few from the standard budget (only from other tests) + expect(remaining).toBeGreaterThanOrEqual(980); + }); + + it('mixed polling and standard requests maintain independent counters', async () => { + // Interleave 10 polling and 10 standard requests + const mixed = []; + for (let i = 0; i < 10; i++) { + mixed.push(request(app).get('/api/stats')); // polling + mixed.push(request(app).get('/api/containers')); // standard + } + const results = await Promise.all(mixed); + + // No 429s should occur (we're well within dev limits) + for (const res of results) { + expect(res.status).not.toBe(429); + } + + // Check that polling results have polling-tier headers + for (let i = 0; i < results.length; i += 2) { + const limit = parseInt(results[i].headers['ratelimit-limit'], 10); + expect(limit).toBe(3000); + } + + // Check that standard results have global-tier headers + for (let i = 1; i < results.length; i += 2) { + const limit = parseInt(results[i].headers['ratelimit-limit'], 10); + expect(limit).toBe(1000); + } + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index dfbf57f1..71be8117 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,7 @@ import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; -import rateLimit from 'express-rate-limit'; +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import helmet from 'helmet'; import WebSocket, { WebSocketServer } from 'ws'; import jwt from 'jsonwebtoken'; @@ -138,22 +138,137 @@ app.use(cors({ credentials: true, })); -// Global API rate limiter — caps total requests per IP across all /api/ routes. -// Auth-specific limiters (authRateLimiter, ssoRateLimiter) apply additional, -// stricter limits on their respective routes and stack independently. -const globalApiLimiter = rateLimit({ +// Cookie parser must run before rate limiters so the hybrid key generator +// can read req.cookies for per-user rate limit bucketing. +app.use(cookieParser()); + +// ── Rate Limiting ───────────────────────────────────────────────────────────── +// +// Tiered rate limiting to prevent UX lockouts while maintaining security: +// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit, +// with a 300/min safety net to prevent resource exhaustion. +// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs). +// Tier 2 (Standard): All other endpoints at 200/min (raised from 100). +// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min). +// +// Enterprise adaptations: +// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters. +// - Authenticated requests are keyed by user ID (not IP) to prevent shared +// NAT/VPN environments from pooling rate limit budgets. + +/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */ +const POLLING_EXEMPT_PATHS = new Set([ + '/meta', '/health', '/stats', '/system/stats', + '/stacks/statuses', '/metrics/historical', + '/auth/status', '/auth/sso/providers', '/license', +]); + +const WEBHOOK_TRIGGER_RE = /^\/webhooks\/\d+\/trigger$/; + +/** + * Returns true if the request bears a node_proxy Bearer token. + * Uses jwt.decode() (no signature verification) to avoid crypto overhead on the + * hot path; authMiddleware performs full verification downstream. Worst case for + * a forged token: it skips the rate limiter but is still rejected by auth. + * Result is memoized on the request object so the two sequential limiters + * don't repeat the work. + */ +function isNodeProxyRequest(req: Request): boolean { + const cached = (req as any)._isNodeProxy; + if (cached !== undefined) return cached; + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + (req as any)._isNodeProxy = false; + return false; + } + try { + const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null; + const result = decoded?.scope === 'node_proxy'; + (req as any)._isNodeProxy = result; + return result; + } catch { + (req as any)._isNodeProxy = false; + return false; + } +} + +/** + * Hybrid rate limit key: uses the JWT username/sub claim for authenticated + * requests (per-user budgets) and falls back to IP for unauthenticated ones. + * Uses jwt.decode() (no verification) to avoid double-verification cost; + * authMiddleware handles signature checks downstream. + */ +function rateLimitKeyGenerator(req: Request): string { + const cookie = req.cookies?.[COOKIE_NAME]; + if (cookie) { + try { + const decoded = jwt.decode(cookie) as { username?: string } | null; + if (decoded?.username) return `user:${decoded.username}`; + } catch { /* fall through to IP */ } + } + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ')) { + try { + const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null; + if (decoded?.username) return `user:${decoded.username}`; + if (decoded?.sub) return `user:${decoded.sub}`; + } catch { /* fall through to IP */ } + } + return ipKeyGenerator(req.ip || 'unknown'); +} + +/** Shared config for all rate limiters (1-minute window, standard headers). */ +const rateLimitBase = { windowMs: 60 * 1000, - max: process.env.NODE_ENV === 'production' - ? parseInt(process.env.API_RATE_LIMIT || '100', 10) - : 1000, standardHeaders: true, legacyHeaders: false, +} as const; + +// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook +// triggers (Tier W), and internal node-to-node traffic (node_proxy). +const globalApiLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' + ? parseInt(process.env.API_RATE_LIMIT || '200', 10) + : 1000, + keyGenerator: rateLimitKeyGenerator, message: { error: 'Too many requests. Please try again shortly.' }, - skip: (req: Request) => req.path === '/meta' || req.path === '/health', + skip: (req: Request) => { + if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true; + if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true; + if (isNodeProxyRequest(req)) return true; + return false; + }, }); app.use('/api/', globalApiLimiter); +// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to +// prevent resource exhaustion from runaway or malicious polling. +const pollingLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' + ? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10) + : 3000, + keyGenerator: rateLimitKeyGenerator, + message: { error: 'Too many polling requests. Please try again shortly.' }, + skip: (req: Request) => { + if (isNodeProxyRequest(req)) return true; + return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)); + }, +}); + +app.use('/api/', pollingLimiter); + +// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler. +// CI/CD platforms (GitHub Actions, GitLab runners) often share datacenter IPs, +// so a higher ceiling prevents dropped deployments during burst activity. +const webhookTriggerLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' ? 500 : 5000, + message: { error: 'Too many webhook triggers. Please try again shortly.' }, +}); + // JSON body parser that also captures the raw bytes for HMAC verification. const jsonParser = express.json({ verify: (req, _res, buf) => { @@ -191,7 +306,6 @@ app.use((req: Request, res: Response, next: NextFunction): void => { } jsonParser(req, res, next); }); -app.use(cookieParser()); // Node Context Middleware const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => { @@ -2190,7 +2304,7 @@ app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: R }); // Webhook trigger - public endpoint, authenticated via HMAC signature -app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promise => { +app.post('/api/webhooks/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise => { try { const id = parseInt(req.params.id as string, 10); const db = DatabaseService.getInstance(); diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 8b6cd9a0..2649094f 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -88,7 +88,20 @@ This applies to all stack endpoints: read, write, deploy, down, restart, stop, s ## Rate limiting -All API endpoints are rate-limited per IP address. Authentication endpoints have stricter limits to prevent brute-force attacks. When exceeded, the server responds with `429 Too Many Requests`: +Sencho uses a tiered rate limiting system that balances security with usability: + +| Tier | Limit | Applies to | Keyed by | +|------|-------|------------|----------| +| **Polling** | 300/min | Dashboard status endpoints (`/stats`, `/system/stats`, `/stacks/statuses`, etc.) | User session | +| **Standard** | 200/min | All other API endpoints | User session | +| **Webhooks** | 500/min | `POST /webhooks/:id/trigger` | IP address | +| **Auth** | 5-10/15min | Login, setup, SSO endpoints | IP address | + +Authenticated requests (browser sessions and API tokens) are rate-limited per user, not per IP. This prevents teams behind shared NAT or VPN from pooling rate limit budgets. Unauthenticated requests fall back to IP-based limiting. + +Internal node-to-node traffic (requests authenticated with node proxy tokens) bypasses rate limiting entirely. + +When exceeded, the server responds with `429 Too Many Requests`: ```json { @@ -98,6 +111,8 @@ All API endpoints are rate-limited per IP address. Authentication endpoints have Standard rate limit headers (`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`) are included in all API responses, so clients can self-throttle accordingly. +The default limits can be tuned via environment variables (`API_RATE_LIMIT`, `API_POLLING_RATE_LIMIT`). See [Self-Hosting](/operations/self-hosting) for details. + ## License tier requirements Some endpoints are gated by license tier: diff --git a/docs/operations/self-hosting.mdx b/docs/operations/self-hosting.mdx index 25d13487..10469c12 100644 --- a/docs/operations/self-hosting.mdx +++ b/docs/operations/self-hosting.mdx @@ -89,7 +89,8 @@ Quick reference for all environment variables. See [Configuration](/getting-star | `COMPOSE_DIR` | `/app/compose` | Path to compose project files (1:1 rule applies) | | `DATA_DIR` | `/app/data` | Persistent data directory | | `FRONTEND_URL` | *(empty)* | Frontend origin for CORS; leave empty for same-origin | -| `API_RATE_LIMIT` | `100` | Maximum API requests per minute per IP (production only) | +| `API_RATE_LIMIT` | `200` | Maximum API requests per minute per user session (production only) | +| `API_POLLING_RATE_LIMIT` | `300` | Rate limit for dashboard polling endpoints (production only) | | `NODE_ENV` | `production` | Set automatically in the Docker image | SSO variables are documented separately in the [SSO Quickstart](/getting-started/sso-quickstart). diff --git a/docs/reference/security-advisories.mdx b/docs/reference/security-advisories.mdx index dcbf268a..ad4e8e69 100644 --- a/docs/reference/security-advisories.mdx +++ b/docs/reference/security-advisories.mdx @@ -13,7 +13,7 @@ A comprehensive security audit was performed before the Sencho paid tiers launch - **Path traversal prevention** in `env_file` resolution, preventing reads outside the compose directory - **Stack name validation** enforced on all routes, rejecting names with special characters or path components -- **Global API rate limiting** to protect against brute-force and abuse +- **Tiered API rate limiting** with per-user session keying to protect against brute-force and abuse ### Authentication and secrets