mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(auth): time-bound outbound fetches and re-key pwned cache (#2183)
This commit is contained in:
@@ -20,8 +20,11 @@ import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil}
|
||||
import * as AuthSession from './AuthSession';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
|
||||
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
|
||||
|
||||
interface CacheEntry {
|
||||
result: boolean;
|
||||
pwnedSuffixes: ReadonlySet<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@@ -30,34 +33,34 @@ class PwnedPasswordCache {
|
||||
private readonly maxSize: number;
|
||||
private readonly ttlMs: number;
|
||||
|
||||
constructor(maxSize = 1000, ttlMs = ms('1 hour')) {
|
||||
constructor(maxSize = PWNED_PASSWORD_CACHE_MAX_PREFIXES, ttlMs = ms('1 hour')) {
|
||||
this.maxSize = maxSize;
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): boolean | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
get(hashPrefix: string): ReadonlySet<string> | undefined {
|
||||
const entry = this.cache.get(hashPrefix);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
this.cache.delete(hashPrefix);
|
||||
return undefined;
|
||||
}
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.result;
|
||||
this.cache.delete(hashPrefix);
|
||||
this.cache.set(hashPrefix, entry);
|
||||
return entry.pwnedSuffixes;
|
||||
}
|
||||
|
||||
set(key: string, result: boolean): void {
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
set(hashPrefix: string, pwnedSuffixes: ReadonlySet<string>): void {
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(hashPrefix)) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, {
|
||||
result,
|
||||
this.cache.set(hashPrefix, {
|
||||
pwnedSuffixes,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
@@ -95,7 +98,11 @@ type ResetPasswordResult =
|
||||
webauthn: boolean;
|
||||
};
|
||||
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(1000, ms('1 hour'));
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
|
||||
|
||||
export function resetPwnedPasswordCacheForTesting(): void {
|
||||
pwnedPasswordCache.clear();
|
||||
}
|
||||
|
||||
export async function hashPassword(_ctx: ApiContext, password: string): Promise<string> {
|
||||
return hashPasswordUtil(password);
|
||||
@@ -112,9 +119,9 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
const cachedResult = pwnedPasswordCache.get(hashed);
|
||||
if (cachedResult !== undefined) {
|
||||
return cachedResult;
|
||||
const cachedSuffixes = pwnedPasswordCache.get(hashPrefix);
|
||||
if (cachedSuffixes !== undefined) {
|
||||
return cachedSuffixes.has(hashSuffix);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
|
||||
@@ -122,6 +129,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
'User-Agent': FLUXER_USER_AGENT,
|
||||
'Add-Padding': 'true',
|
||||
},
|
||||
signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.warn(
|
||||
@@ -153,20 +161,16 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
);
|
||||
}
|
||||
const limit = Math.min(lines.length, MAX_PWNED_LINES);
|
||||
const pwnedSuffixes = new Set<string>();
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const line = lines[i];
|
||||
const [hashSuffixLine, count] = line.split(':', 2);
|
||||
if (
|
||||
hashSuffixLine.length === hashSuffix.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(hashSuffixLine), Buffer.from(hashSuffix)) &&
|
||||
Number.parseInt(count, 10) > 0
|
||||
) {
|
||||
pwnedPasswordCache.set(hashed, true);
|
||||
return true;
|
||||
if (hashSuffixLine.length === hashSuffix.length && Number.parseInt(count, 10) > 0) {
|
||||
pwnedSuffixes.add(hashSuffixLine);
|
||||
}
|
||||
}
|
||||
pwnedPasswordCache.set(hashed, false);
|
||||
return false;
|
||||
pwnedPasswordCache.set(hashPrefix, pwnedSuffixes);
|
||||
return pwnedSuffixes.has(hashSuffix);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '../AuthPassword';
|
||||
|
||||
const PWNED_PASSWORD = 'fluxer-prefix-592';
|
||||
const SAFE_PASSWORD_SAME_PREFIX = 'fluxer-prefix-837';
|
||||
const HANGING_RESPONSE_MS = 8000;
|
||||
|
||||
const ctx = {} as unknown as ApiContext;
|
||||
|
||||
function sha1(password: string): string {
|
||||
return crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
function prefixOf(password: string): string {
|
||||
return sha1(password).slice(0, 5);
|
||||
}
|
||||
|
||||
function suffixOf(password: string): string {
|
||||
return sha1(password).slice(5);
|
||||
}
|
||||
|
||||
function rangeBody(pwnedSuffixes: Array<string>): string {
|
||||
const padded = ['0'.repeat(35), '1'.repeat(35)].map((suffix) => `${suffix}:0`);
|
||||
return [...pwnedSuffixes.map((suffix) => `${suffix}:42`), ...padded].join('\r\n');
|
||||
}
|
||||
|
||||
function rangeHandler(requestedPrefixes: Array<string>, pwnedSuffixes: Array<string>) {
|
||||
return http.get('https://api.pwnedpasswords.com/range/:prefix', ({params}) => {
|
||||
requestedPrefixes.push(String(params.prefix));
|
||||
return HttpResponse.text(rangeBody(pwnedSuffixes), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'text/plain; charset=utf-8'},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hangingRangeHandler() {
|
||||
return http.get('https://api.pwnedpasswords.com/range/:prefix', async () => {
|
||||
await delay(HANGING_RESPONSE_MS);
|
||||
return HttpResponse.text('');
|
||||
});
|
||||
}
|
||||
|
||||
describe('isPasswordPwned', () => {
|
||||
beforeEach(() => {
|
||||
resetPwnedPasswordCacheForTesting();
|
||||
});
|
||||
test('the fixture passwords are distinct but share a range prefix', () => {
|
||||
expect(PWNED_PASSWORD).not.toBe(SAFE_PASSWORD_SAME_PREFIX);
|
||||
expect(prefixOf(PWNED_PASSWORD)).toBe(prefixOf(SAFE_PASSWORD_SAME_PREFIX));
|
||||
});
|
||||
test('reports a breached password from the range response', async () => {
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
expect(requestedPrefixes).toEqual([prefixOf(PWNED_PASSWORD)]);
|
||||
});
|
||||
test('two passwords sharing a prefix trigger a single upstream call', async () => {
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
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);
|
||||
});
|
||||
test('fails open the same way when the lookup times out, without caching the prefix', async () => {
|
||||
server.use(hangingRangeHandler());
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '../utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '../utils/FetchUtils';
|
||||
|
||||
const NCMEC_REQUEST_TIMEOUT_MS = ms('2 minutes');
|
||||
|
||||
type NcmecOperation = 'report' | 'evidence' | 'fileinfo' | 'finish' | 'retract';
|
||||
type NcmecApiConfig =
|
||||
| {
|
||||
@@ -163,6 +166,7 @@ export class NcmecReporter implements NcmecApiClient {
|
||||
Authorization: basicAuth(cfg.username, cfg.password),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(NCMEC_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const text = await FetchUtils.streamToStringWithLimit(res.body, {
|
||||
maxBytes: EXTERNAL_RESPONSE_LIMITS.ncmecResponseBytes,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {ExplicitContentCannotBeSentError} from '@fluxer/errors/src/domains/moderation/ExplicitContentCannotBeSentError';
|
||||
import * as MediaProxyUtils from '@pkgs/media_proxy_utils/src/MediaProxyUtils';
|
||||
import {ms} from 'itty-time';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import * as FetchUtils from '../utils/FetchUtils';
|
||||
@@ -29,6 +30,7 @@ const MEDIA_PROXY_METADATA_WITH_BASE64_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const MEDIA_PROXY_ERROR_MAX_BYTES = 16 * 1024;
|
||||
const MEDIA_PROXY_THUMBNAIL_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const MEDIA_PROXY_FRAMES_MAX_BYTES = 512 * 1024;
|
||||
const MEDIA_PROXY_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||
|
||||
function isMediaProxyMetadataResponse(value: unknown): value is MediaProxyMetadataResponse {
|
||||
if (!isJsonRecord(value)) return false;
|
||||
@@ -191,6 +193,7 @@ export class MediaService extends IMediaService {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${Config.mediaProxy.secretKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(MEDIA_PROXY_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await FetchUtils.streamToStringWithLimit(response.body, {
|
||||
|
||||
Reference in New Issue
Block a user