diff --git a/src/app.ts b/src/app.ts index a233beef46..61dcc3afef 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,14 +29,18 @@ import { rootPath } from './paths'; import { poolRoutes } from './pools/pools.routes'; import { ConfigManagerV2 } from './services/config-manager-v2'; import { + clearAuthFailures, constantTimeEqual, extractBearerToken, getBindAddress, + isAuthLockedOut, isExposedHost, isLoopbackAddress, isSensitivePath, isTrustedLocalAddress, + isWeakApiKey, loadOrCreateApiKey, + recordAuthFailure, } from './services/gateway-security'; import { logger } from './services/logger'; import { quoteCache } from './services/quote-cache'; @@ -209,15 +213,37 @@ const configureGatewayServer = () => { const requireAuth = process.env.GATEWAY_REQUIRE_AUTH === 'true' || !!process.env.GATEWAY_API_KEY; if (requireAuth) { const gatewayApiKey = loadOrCreateApiKey(`${rootPath()}/conf`); + if (isWeakApiKey(gatewayApiKey)) { + logger.warn( + 'GATEWAY_API_KEY is shorter than 16 characters — prefer a high-entropy value (the ' + + 'auto-generated key is 256-bit). A weak token is brute-forceable from a private-network ' + + 'source, which the general rate limiter does not throttle.', + ); + } logger.info('API-token authentication is enabled for network requests to fund-moving routes.'); server.addHook('onRequest', async (request, reply) => { if (isLoopbackAddress(request.ip)) return; // trusted if (!isSensitivePath(request.url)) return; // only gate sensitive routes + // Lock out a source that keeps failing the token check. The global rate limiter + // allow-lists private-network sources (isTrustedLocalAddress), so without this a + // LAN/compose-adjacent attacker could brute-force the token unthrottled (#660 §2). + if (isAuthLockedOut(request.ip)) { + logger.warn(`Locked out ${request.ip} after repeated failed auth: ${request.method} ${request.url}`); + reply.code(429).send({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Too many failed authentication attempts; try again later.', + }); + return; + } const token = extractBearerToken(request.headers['authorization'] as string | undefined); if (!constantTimeEqual(token, gatewayApiKey)) { + recordAuthFailure(request.ip); logger.warn(`Rejected unauthenticated request from ${request.ip}: ${request.method} ${request.url}`); reply.code(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Invalid or missing API key' }); + return; } + clearAuthFailures(request.ip); // successful auth resets the counter for this source }); } diff --git a/src/services/gateway-security.ts b/src/services/gateway-security.ts index 1e7b8b2e57..c6f90fe5e5 100644 --- a/src/services/gateway-security.ts +++ b/src/services/gateway-security.ts @@ -126,6 +126,63 @@ export function loadOrCreateApiKey(confDir: string): string { return key; } +/** + * Heuristic weak-key check for an operator-supplied GATEWAY_API_KEY. The auto-generated + * key is 256-bit (64 hex chars); a short, human-chosen value is brute-forceable — which + * matters because the general rate limiter allow-lists private-network sources (see + * isTrustedLocalAddress), so a LAN/compose-adjacent client is not throttled on the token. + * Pure check — the caller decides how to surface it (we warn, not fail, so an existing + * deployment with a short key keeps working). + */ +export function isWeakApiKey(key: string): boolean { + return key.trim().length < 16; +} + +/** + * In-memory failed-authentication lockout for sensitive routes (hummingbot/gateway#660 §2). + * + * Keyed on the source IP and incremented ONLY on a failed token check, so a correctly + * authenticated client — including a sibling-container bot that the general rate limiter + * allow-lists — is never affected, while a token-guessing client is locked out after + * GATEWAY_AUTH_FAIL_MAX failures within GATEWAY_AUTH_FAIL_WINDOW_MS. This is the control the + * global limiter cannot provide: it exempts every private-network source (isTrustedLocalAddress) + * and would otherwise give a LAN/compose-adjacent attacker unlimited guesses against the token. + * + * Scope note: an IP-keyed lockout is a proportionate mitigation for this local/LAN threat + * model; it does not by itself stop an attacker able to rotate many source IPs (#660 §2). The + * primary defense remains a high-entropy token (the generated key is 256-bit). + */ +const AUTH_FAIL_MAX = Math.max(1, Number(process.env.GATEWAY_AUTH_FAIL_MAX ?? 10)); +const AUTH_FAIL_WINDOW_MS = Math.max(1000, Number(process.env.GATEWAY_AUTH_FAIL_WINDOW_MS ?? 15 * 60 * 1000)); +const authFailures = new Map(); + +export function isAuthLockedOut(ip: string | undefined, now: number = Date.now()): boolean { + const key = ip ?? 'unknown'; + const rec = authFailures.get(key); + if (!rec) return false; + if (now > rec.resetAt) { + authFailures.delete(key); + return false; + } + return rec.count >= AUTH_FAIL_MAX; +} + +export function recordAuthFailure(ip: string | undefined, now: number = Date.now()): void { + const key = ip ?? 'unknown'; + const rec = authFailures.get(key); + if (!rec || now > rec.resetAt) { + authFailures.set(key, { count: 1, resetAt: now + AUTH_FAIL_WINDOW_MS }); + } else { + rec.count += 1; + } +} + +/** Clear failed-auth state — on a successful auth for that source, or globally in tests. */ +export function clearAuthFailures(ip?: string): void { + if (ip === undefined) authFailures.clear(); + else authFailures.delete(ip); +} + /** * True when Gateway is running inside a container (Docker/Podman). Uses the same markers the * Hummingbot API relies on, so the two stay in agreement about the deployment shape. diff --git a/test/services/gateway-security.test.ts b/test/services/gateway-security.test.ts index 96fdbc28de..05ef99b67d 100644 --- a/test/services/gateway-security.test.ts +++ b/test/services/gateway-security.test.ts @@ -3,15 +3,19 @@ import { tmpdir } from 'os'; import path from 'path'; import { + clearAuthFailures, constantTimeEqual, extractBearerToken, getBindAddress, + isAuthLockedOut, isExposedHost, isLoopbackAddress, isPrivateNetworkAddress, isSensitivePath, isTrustedLocalAddress, + isWeakApiKey, loadOrCreateApiKey, + recordAuthFailure, } from '../../src/services/gateway-security'; describe('gateway-security', () => { @@ -159,4 +163,46 @@ describe('gateway-security', () => { } }); }); + + describe('isWeakApiKey', () => { + it.each(['', 'short', 'a'.repeat(15)])('true for weak key %p', (k) => { + expect(isWeakApiKey(k)).toBe(true); + }); + it.each(['a'.repeat(16), 'b'.repeat(64)])('false for strong-length key %p', (k) => { + expect(isWeakApiKey(k)).toBe(false); + }); + }); + + describe('failed-auth lockout', () => { + afterEach(() => clearAuthFailures()); + + it('does not lock out below the threshold', () => { + const ip = '172.18.0.9'; + for (let i = 0; i < 9; i++) recordAuthFailure(ip); + expect(isAuthLockedOut(ip)).toBe(false); + }); + + it('locks out at the threshold and is keyed per source', () => { + const attacker = '172.18.0.9'; + for (let i = 0; i < 10; i++) recordAuthFailure(attacker); + expect(isAuthLockedOut(attacker)).toBe(true); + expect(isAuthLockedOut('172.18.0.10')).toBe(false); // a different source is unaffected + }); + + it('a successful auth clears the counter', () => { + const ip = '10.0.0.5'; + for (let i = 0; i < 10; i++) recordAuthFailure(ip); + expect(isAuthLockedOut(ip)).toBe(true); + clearAuthFailures(ip); + expect(isAuthLockedOut(ip)).toBe(false); + }); + + it('the lockout window expires', () => { + const ip = '192.168.1.7'; + const t0 = 1_000_000; + for (let i = 0; i < 10; i++) recordAuthFailure(ip, t0); + expect(isAuthLockedOut(ip, t0)).toBe(true); + expect(isAuthLockedOut(ip, t0 + 16 * 60 * 1000)).toBe(false); // past the default 15-min window + }); + }); });