feat(api): make tor and breached password lookups opt-in (#2834)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-19 01:22:17 +02:00
1 parent efd677f32b
commit f32356801d
17 files changed
+186 -40

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
torExitBlockingEnabled: config.torExitList.enabled,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
+54
View File
@@ -166,3 +166,57 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
function withOptionalOutboundLookups(
master: MasterConfig,
selfHosted: boolean,
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
tor_exit_list: {enabled: overrides.torExitList},
breached_password_check: {enabled: overrides.breachedPasswordCheck},
},
instance: {
...master.instance,
self_hosted: selfHosted,
},
};
}
describe('buildAPIConfigFromMaster optional outbound lookups', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('keeps both lookups on when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('leaves both lookups off on a self-hosted instance', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
it('lets a self-hosted operator switch each lookup on', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
);
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
);
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
+6
View File
@@ -348,6 +348,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
torExitList: {
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
},
breachedPasswordCheck: {
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
+5 -3
View File
@@ -137,9 +137,11 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
await startAbuseReplicationSubscriber(kvClient);
logger.info('Abusive-IP auto-banner replication started');
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
if (config.torExitList.enabled) {
torExitListCache.setKvClient(kvClient);
await torExitListCache.initialize();
logger.info('Tor exit list cache initialized');
}
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
urlBlocklistCache.setRefreshSubscriber(kvClient);
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
+13 -2
View File
@@ -29,10 +29,19 @@ interface MiddlewarePipelineOptions {
trustClientIpHeader: boolean;
clientIpHeaderName?: string;
maxInflightRequests: number;
torExitBlockingEnabled: boolean;
}
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
const {
logger,
nodeEnv,
corsOrigins,
trustClientIpHeader,
clientIpHeaderName,
maxInflightRequests,
torExitBlockingEnabled,
} = options;
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
@@ -100,7 +109,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
}),
);
}
routes.use(TorExitMiddleware);
if (torExitBlockingEnabled) {
routes.use(TorExitMiddleware);
}
routes.use(AuditLogMiddleware);
routes.use(RequireClientIpMiddleware());
routes.use(ServiceMiddleware);
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {Hono} from 'hono';
import {describe, expect, it} from 'vitest';
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
logger: new NoopLogger(),
nodeEnv: 'test',
corsOrigins: ['http://localhost:3000'],
trustClientIpHeader: true,
clientIpHeaderName: 'x-forwarded-for',
maxInflightRequests: 100,
torExitBlockingEnabled,
});
return routes.routes.map((route) => route.handler);
}
describe('tor exit blocking in the middleware pipeline', () => {
it('registers the tor exit middleware when the switch is on', () => {
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
});
it('leaves the tor exit middleware unregistered when the switch is off', () => {
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
});
});
+4
View File
@@ -5,6 +5,7 @@ import type {ApiContext} from '@app/api/ApiContext';
import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import type {User} from '@app/api/models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
@@ -116,6 +117,9 @@ export async function verifyPassword(
}
export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
if (!Config.breachedPasswordCheck.enabled) {
return false;
}
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = hashed.slice(0, 5);
const hashSuffix = hashed.slice(5);
@@ -3,6 +3,7 @@
import crypto from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '@app/api/auth/AuthPassword';
import {getConfig} from '@app/api/Config';
import {server} from '@app/api/test/msw/server';
import {delay, HttpResponse, http} from 'msw';
import {beforeEach, describe, expect, test} from 'vitest';
@@ -68,6 +69,19 @@ describe('isPasswordPwned', () => {
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
expect(requestedPrefixes).toHaveLength(1);
});
test('makes no upstream call when the check is switched off', async () => {
const config = getConfig();
const originalEnabled = config.breachedPasswordCheck.enabled;
const requestedPrefixes: Array<string> = [];
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
try {
config.breachedPasswordCheck.enabled = false;
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
expect(requestedPrefixes).toHaveLength(0);
} finally {
config.breachedPasswordCheck.enabled = originalEnabled;
}
});
test('fails open on a non-OK response', async () => {
server.use(http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text('', {status: 503})));
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
+6
View File
@@ -207,6 +207,12 @@ export interface APIConfig {
blocklistFeeds: {
enabled: boolean;
};
torExitList: {
enabled: boolean;
};
breachedPasswordCheck: {
enabled: boolean;
};
captcha: {
enabled: boolean;
provider: 'hcaptcha' | 'turnstile' | 'none';