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