mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348)
This commit is contained in:
1 parent
4fb14e85e8
commit
5719dfe8a3
1 file changed
+29
-41
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac, randomBytes, randomUUID} from 'node:crypto';
|
||||
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Logger} from '../../Logger';
|
||||
@@ -19,7 +20,7 @@ interface PhoneAttemptRiskThresholds {
|
||||
userDistinctCountriesRequireInbound: number;
|
||||
ipDistinctPhonesHardBlock: number;
|
||||
ipDistinctUsersHardBlock: number;
|
||||
cidr24DistinctPhonesHardBlock: number;
|
||||
subnetDistinctPhonesHardBlock: number;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
|
||||
@@ -28,7 +29,7 @@ const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
|
||||
userDistinctCountriesRequireInbound: 3,
|
||||
ipDistinctPhonesHardBlock: 10,
|
||||
ipDistinctUsersHardBlock: 3,
|
||||
cidr24DistinctPhonesHardBlock: 30,
|
||||
subnetDistinctPhonesHardBlock: 30,
|
||||
};
|
||||
|
||||
type PhoneAttemptRiskDecision = 'allow' | 'require_captcha' | 'require_inbound' | 'hard_block';
|
||||
@@ -38,10 +39,10 @@ type PhoneAttemptRiskReason =
|
||||
| 'user_country_sweep'
|
||||
| 'ip_distinct_phones'
|
||||
| 'ip_user_pool'
|
||||
| 'cidr24_distinct_phones'
|
||||
| 'subnet_distinct_phones'
|
||||
| 'user_already_hard_blocked'
|
||||
| 'ip_already_hard_blocked'
|
||||
| 'cidr24_already_hard_blocked';
|
||||
| 'subnet_already_hard_blocked';
|
||||
|
||||
interface PhoneAttemptRiskEvaluation {
|
||||
decision: PhoneAttemptRiskDecision;
|
||||
@@ -53,7 +54,7 @@ interface PhoneAttemptRiskEvaluation {
|
||||
userDistinctCountries: number;
|
||||
ipDistinctPhones: number;
|
||||
ipDistinctUsers: number;
|
||||
cidr24DistinctPhones: number;
|
||||
subnetDistinctPhones: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ const ZERO_COUNTERS: PhoneAttemptRiskEvaluation['counters'] = {
|
||||
userDistinctCountries: 0,
|
||||
ipDistinctPhones: 0,
|
||||
ipDistinctUsers: 0,
|
||||
cidr24DistinctPhones: 0,
|
||||
subnetDistinctPhones: 0,
|
||||
};
|
||||
|
||||
interface PhoneAttemptRiskInput {
|
||||
@@ -105,23 +106,16 @@ function prefix6(phone: string): string {
|
||||
return phone.slice(0, 6);
|
||||
}
|
||||
|
||||
function cidr24For(ip: string): string | null {
|
||||
if (!ip.includes('.')) return null;
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
|
||||
}
|
||||
|
||||
function userKey(userId: string | bigint, suffix: string): string {
|
||||
return `${KEY_PREFIX}user:${String(userId)}:${suffix}`;
|
||||
}
|
||||
|
||||
function ipKey(ip: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ip:${ip}:${suffix}`;
|
||||
return `${KEY_PREFIX}ip:${getSameIpDecisionKey(ip) ?? ip}:${suffix}`;
|
||||
}
|
||||
|
||||
function cidr24Key(cidr: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ip24:${cidr}:${suffix}`;
|
||||
function subnetKey(subnet: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ipnet:${subnet}:${suffix}`;
|
||||
}
|
||||
|
||||
type PhoneAttemptHardBlockHook = (params: {
|
||||
@@ -166,10 +160,10 @@ export class PhoneAttemptRiskService {
|
||||
evaluation.reason = 'ip_already_hard_blocked';
|
||||
return evaluation;
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr && (await this.kv.exists(cidr24Key(cidr, 'hard_block'))) > 0) {
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
if ((await this.kv.exists(subnetKey(subnet, 'hard_block'))) > 0) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'cidr24_already_hard_blocked';
|
||||
evaluation.reason = 'subnet_already_hard_blocked';
|
||||
return evaluation;
|
||||
}
|
||||
}
|
||||
@@ -212,15 +206,13 @@ export class PhoneAttemptRiskService {
|
||||
evaluation.reason = 'ip_user_pool';
|
||||
return evaluation;
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
const cidr24DistinctPhones = await this.kv.scard(cidr24Key(cidr, 'phones'));
|
||||
evaluation.counters.cidr24DistinctPhones = cidr24DistinctPhones;
|
||||
if (cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'cidr24_distinct_phones';
|
||||
return evaluation;
|
||||
}
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
const subnetDistinctPhones = await this.kv.scard(subnetKey(subnet, 'phones'));
|
||||
evaluation.counters.subnetDistinctPhones = subnetDistinctPhones;
|
||||
if (subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'subnet_distinct_phones';
|
||||
return evaluation;
|
||||
}
|
||||
}
|
||||
return evaluation;
|
||||
@@ -248,16 +240,14 @@ export class PhoneAttemptRiskService {
|
||||
}
|
||||
let ipDistinctPhones = 0;
|
||||
let ipDistinctUsers = 0;
|
||||
let cidr24DistinctPhones = 0;
|
||||
let subnetDistinctPhones = 0;
|
||||
if (input.clientIp) {
|
||||
ipDistinctPhones = await this.bumpSet(ipKey(input.clientIp, 'phones'), hmac(input.phone, key));
|
||||
if (input.userId !== null && input.userId !== undefined && input.userId !== '') {
|
||||
ipDistinctUsers = await this.bumpSet(ipKey(input.clientIp, 'users'), hmac(String(input.userId), key));
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
cidr24DistinctPhones = await this.bumpSet(cidr24Key(cidr, 'phones'), hmac(input.phone, key));
|
||||
}
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
subnetDistinctPhones = await this.bumpSet(subnetKey(subnet, 'phones'), hmac(input.phone, key));
|
||||
}
|
||||
const counters = {
|
||||
userAttempts,
|
||||
@@ -266,7 +256,7 @@ export class PhoneAttemptRiskService {
|
||||
userDistinctCountries,
|
||||
ipDistinctPhones,
|
||||
ipDistinctUsers,
|
||||
cidr24DistinctPhones,
|
||||
subnetDistinctPhones,
|
||||
};
|
||||
const tripped = this.tripsAt(counters);
|
||||
if (tripped !== null) {
|
||||
@@ -290,8 +280,8 @@ export class PhoneAttemptRiskService {
|
||||
) {
|
||||
return 'ip_user_pool';
|
||||
}
|
||||
if (counters.cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
|
||||
return 'cidr24_distinct_phones';
|
||||
if (counters.subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
|
||||
return 'subnet_distinct_phones';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -306,11 +296,9 @@ export class PhoneAttemptRiskService {
|
||||
await this.kv.setex(userKey(input.userId, 'hard_block'), HARD_BLOCK_TTL_SECONDS, '1');
|
||||
} else if (reason === 'ip_user_pool' && input.clientIp) {
|
||||
await this.kv.setex(ipKey(input.clientIp, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
} else if (reason === 'cidr24_distinct_phones' && input.clientIp) {
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
await this.kv.setex(cidr24Key(cidr, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
}
|
||||
} else if (reason === 'subnet_distinct_phones' && input.clientIp) {
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
await this.kv.setex(subnetKey(subnet, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
}
|
||||
Logger.warn(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user