feat(api): add env toggles for automatic phone flagging (#2927)

This commit is contained in:
Hampus
2026-09-24 03:36:35 +02:00
committed by GitHub
parent 590b1f36fd
commit dcd5f09d6a
16 changed files with 268 additions and 6 deletions
+4
View File
@@ -476,6 +476,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
+4 -2
View File
@@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
@@ -362,7 +362,9 @@ export async function register(
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
@@ -9,6 +9,7 @@ import {
loginAccount,
registerUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
@@ -33,7 +34,7 @@ import {
SuspiciousActivityFlags,
} from '@fluxer/constants/src/UserConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
return {
@@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => {
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
describe('with phone flagging disabled', () => {
const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging};
afterEach(() => {
Config.abusePolicy.phoneFlagging = originalPhoneFlagging;
});
it('sets no phone requirement and no deferral at registration', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off'),
username: createUniqueUsername('flagging_off'),
global_name: 'Flagging Off',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
});
it('keeps an existing deferral dormant on a qualifying join', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({
deferred_phone_gate_enabled: true,
deferred_phone_gate_member_threshold: 1,
deferred_phone_gate_window_hours: 24,
});
const {inviteCode} = await createGuildWithInvite(harness);
const filler = await createTestAccount(harness);
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off-join'),
username: createUniqueUsername('flagging_off_join'),
global_name: 'Flagging Off Join',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
});
describe('phone gate escape', () => {
async function configurePhoneGate(
overrides: {
+4
View File
@@ -336,6 +336,10 @@ export interface APIConfig {
};
abusePolicy: {
inboundPhoneCountryCodes: Array<string>;
phoneFlagging: {
enabled: boolean;
exemptCountryCodes: Array<string>;
};
phoneVerification: {
inboundRequiredPrefixes: Array<string>;
};
@@ -3,6 +3,7 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
import {createChannelID, createRoleID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {assertMutableUserId} from '@app/api/constants/Core';
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
@@ -421,6 +422,13 @@ export class GuildMemberOperationsService {
memberCount: guild.memberCount,
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
};
if (
!Config.abusePolicy.phoneFlagging.enabled &&
(getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0
) {
Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled');
return;
}
if (status !== 'ok') {
const undeferredFlags = getEffectiveSuspiciousFlags({
...user,
+42
View File
@@ -1,6 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray<readonly [number, number]> = [
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
];
const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS);
function withoutPhoneOfferingFlags(flagBits: number): number {
return EMAIL_ONLY_EQUIVALENTS.reduce(
(next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next),
flagBits & ~PHONE_OFFERING_FLAGS,
);
}
function normalizeCountryCode(countryCode: string | null | undefined): string | null {
const trimmed = countryCode?.trim();
@@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu
return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized);
}
export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean {
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (!enabled) return false;
const normalized = normalizeCountryCode(countryCode);
if (!normalized) return true;
return !configuredCountrySet(exemptCountryCodes).has(normalized);
}
export async function stripDisallowedPhoneFlags(
flagBits: number,
resolveCountryCode: () => Promise<string | null>,
): Promise<number> {
if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits;
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (enabled && exemptCountryCodes.length === 0) return flagBits;
if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits;
return withoutPhoneOfferingFlags(flagBits);
}
export function phoneRequiresInboundVerification(
phone: string,
prefixes: ReadonlyArray<string> = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes,
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const PHONE_AND_EMAIL =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION;
describe('phone flagging policy', () => {
const original = {...Config.abusePolicy.phoneFlagging};
beforeEach(() => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []};
});
afterEach(() => {
Config.abusePolicy.phoneFlagging = original;
});
it('keeps phone flags by default without resolving the country', async () => {
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('strips only phone flags when disabled', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(false);
expect(phoneFlaggingAllowedForCountry(null)).toBe(false);
});
it('strips phone flags for exempt countries only', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']};
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL);
expect(phoneFlaggingAllowedForCountry('pt')).toBe(false);
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('replaces email or phone flags with their email only equivalent', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => 'BR',
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
});
it('skips the country lookup when no phone flags are present', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
const resolveCountryCode = vi.fn(async () => 'BR');
expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
});
});
@@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy';
import {
createRpcTimingNode,
RpcTimingRecorder,
@@ -311,6 +311,17 @@ export class RpcSessionStartService {
) {
return null;
}
if (
!timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () =>
phoneFlaggingAllowedForCountry(geoipCountryIso),
)
) {
Logger.info(
{userId: user.id.toString(), countryIso: geoipCountryIso},
'Skipping configured-country inbound phone requirement: phone flagging disabled for this country',
);
return null;
}
if (
timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n)
) {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {User} from '@app/api/models/User';
import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService';
import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => {
});
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
});
it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => {
setCachedDeferredPhoneGateEnabled(false);
const original = {...Config.abusePolicy.phoneFlagging};
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
try {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
});
expect(getRequiredActions(user)).toEqual([]);
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
} finally {
Config.abusePolicy.phoneFlagging = original;
}
});
it('suppresses a deferred phone requirement so the account is not locked out', () => {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
+1 -1
View File
@@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
return rawFlags;
}
if (getCachedDeferredPhoneGateEnabled() === false) {
if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) {
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
@@ -12,6 +12,7 @@ import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
@@ -42,6 +43,7 @@ import {
mapUserToPrivateResponse,
mapUserToProfileResponse,
} from '@app/api/user/UserMappers';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
@@ -302,7 +304,11 @@ export class UserAccountRequestService {
action: emailSetRecommendedAction,
},
});
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
const policyFlagBits = await stripDisallowedPhoneFlags(
policyDecision.flagBits,
async () => (await lookupGeoip(request)).countryCode,
);
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyFlagBits);
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
user = await this.userRepository.patchUpsert(
user.id,
@@ -991,6 +991,14 @@ No default. The account risk policy. JSON. Malformed JSON fails startup, and an
Default empty. Allowed inbound phone countries. Comma separated, passed through unvalidated.
#### `FLUXER_ABUSE_PHONE_FLAGGING_ENABLED`
Default `true`. Automatic phone requirements. With this off, registration, setting an email and gateway session start never add a requirement that offers phone verification. An email or phone requirement becomes its email only form. A deferred phone requirement stays dormant on a community join. Requirements already on an account and flags set by an Admin are untouched.
#### `FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES`
Default empty. Countries that never get an automatic phone requirement, matched against the request's GeoIP country. An email or phone requirement becomes its email only form. Comma separated, unvalidated.
#### `FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES`
Default empty. Required inbound prefixes. Comma separated.
+4
View File
@@ -277,6 +277,10 @@ function defaultConfig(): MasterConfig {
},
abuse_policy: {
inbound_phone_country_codes: [],
phone_flagging: {
enabled: true,
exempt_country_codes: [],
},
phone_verification: {
inbound_required_prefixes: [],
},
+4
View File
@@ -341,6 +341,10 @@ export interface MasterConfig {
};
abuse_policy: {
inbound_phone_country_codes: Array<string>;
phone_flagging: {
enabled: boolean;
exempt_country_codes: Array<string>;
};
phone_verification: {
inbound_required_prefixes: Array<string>;
};
@@ -464,6 +464,8 @@ describe('ConfigLoader', () => {
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: 'https://status.example/history',
FLUXER_INSTANCE_SETUP_CONFIGURED: 'true',
FLUXER_ABUSE_INBOUND_PHONE_COUNTRY_CODES: 'AA,BB',
FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: 'false',
FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: 'CC,DD',
FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: '+101,+202',
FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ENABLED: 'true',
FLUXER_ABUSE_DIRECT_CONTACT_SPAM_COUNTRY_CODES: 'AA,BB',
@@ -492,6 +494,10 @@ describe('ConfigLoader', () => {
expect(config.instance.setup.configured).toBe(true);
expect(config.instance.abuse_policy).toEqual({
inbound_phone_country_codes: ['AA', 'BB'],
phone_flagging: {
enabled: false,
exempt_country_codes: ['CC', 'DD'],
},
phone_verification: {
inbound_required_prefixes: ['+101', '+202'],
},
@@ -369,6 +369,14 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
path: ['instance', 'abuse_policy', 'inbound_phone_country_codes'],
parse: parseCsv,
},
FLUXER_ABUSE_PHONE_FLAGGING_ENABLED: {
path: ['instance', 'abuse_policy', 'phone_flagging', 'enabled'],
parse: parseBoolean,
},
FLUXER_ABUSE_PHONE_FLAGGING_EXEMPT_COUNTRY_CODES: {
path: ['instance', 'abuse_policy', 'phone_flagging', 'exempt_country_codes'],
parse: parseCsv,
},
FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES: {
path: ['instance', 'abuse_policy', 'phone_verification', 'inbound_required_prefixes'],
parse: parseCsv,