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
26 changes: 26 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
});
}

Expand Down
57 changes: 57 additions & 0 deletions src/services/gateway-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { count: number; resetAt: number }>();

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.
Expand Down
46 changes: 46 additions & 0 deletions test/services/gateway-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
});
});
});