Skip to content

Commit 89961be

Browse files
authored
Merge pull request #20868 from mozilla/fix-current-attempts-logic
bug(rate-limitting): bqWriter missing current attempts
2 parents 53c7324 + bf1d02c commit 89961be

2 files changed

Lines changed: 141 additions & 3 deletions

File tree

libs/accounts/rate-limit/src/lib/rate-limit.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { BlockRecord, Rule, TOO_MANY_ATTEMPTS } from './models';
99
import { calculateRetryAfter, getKey } from './util';
1010
import { StatsD } from 'hot-shots';
1111

12+
const MOCK_NOW = 1_700_000_000_000;
13+
1214
describe('rate-limit', () => {
1315
let redis: Redis;
1416
let mockIncrement: jest.Mock;
@@ -857,6 +859,126 @@ describe('rate-limit', () => {
857859
);
858860
});
859861

862+
it('reports currentAttempts as the tallied count when no block is triggered', async () => {
863+
mockIncr.mockResolvedValue(3); // under maxAttempts of 5
864+
865+
rateLimit = new RateLimit(
866+
{ rules: parseConfigRules(['test:ip:5:1 minute:5 minutes:block']) },
867+
redis,
868+
statsd,
869+
{ write: mockWrite }
870+
);
871+
872+
await rateLimit.check('test', { ip: '1.2.3.4' });
873+
874+
expect(mockWrite).toHaveBeenCalledWith(
875+
expect.objectContaining({
876+
wasBlocked: false,
877+
currentAttempts: 3,
878+
})
879+
);
880+
});
881+
882+
it('reports currentAttempts as the tallied count when a new block is triggered', async () => {
883+
mockIncr.mockResolvedValue(6); // exceeds maxAttempts of 5
884+
redis.set = jest.fn().mockResolvedValue('OK');
885+
886+
rateLimit = new RateLimit(
887+
{ rules: parseConfigRules(['test:ip:5:1 minute:5 minutes:block']) },
888+
redis,
889+
statsd,
890+
{ write: mockWrite }
891+
);
892+
893+
await rateLimit.check('test', { ip: '1.2.3.4' });
894+
895+
expect(mockWrite).toHaveBeenCalledWith(
896+
expect.objectContaining({
897+
wasBlocked: true,
898+
currentAttempts: 6,
899+
})
900+
);
901+
});
902+
903+
it('reports currentAttempts as maxAttempts when short-circuiting on a pre-existing block', async () => {
904+
// A block already exists in Redis, so check() never tallies attempts.
905+
// The stored record's own attempt count (99) must NOT be used; the
906+
// reported value falls back to the rule's maxAttempts (5).
907+
const existingBlock = {
908+
action: 'test',
909+
usedDefaultRule: false,
910+
blockingOn: 'ip',
911+
blockedValue: '1.2.3.4',
912+
startTime: MOCK_NOW,
913+
duration: 300,
914+
attempts: 99,
915+
reason: 'too-many-attempts',
916+
policy: 'block',
917+
};
918+
// Bans are checked first via redis.get; return null for those, and the
919+
// block record for the block key.
920+
mockGet.mockImplementation((key: string) =>
921+
Promise.resolve(
922+
key.includes(':block:') ? JSON.stringify(existingBlock) : null
923+
)
924+
);
925+
926+
rateLimit = new RateLimit(
927+
{ rules: parseConfigRules(['test:ip:5:1 minute:5 minutes:block']) },
928+
redis,
929+
statsd,
930+
{ write: mockWrite }
931+
);
932+
933+
await rateLimit.check('test', { ip: '1.2.3.4' });
934+
935+
expect(mockIncr).not.toHaveBeenCalled();
936+
expect(mockWrite).toHaveBeenCalledWith(
937+
expect.objectContaining({
938+
wasBlocked: true,
939+
currentAttempts: 5,
940+
})
941+
);
942+
});
943+
944+
it('reports currentAttempts as undefined when a ban short-circuits the check', async () => {
945+
// A ban has no associated rule (rules is empty), so the number of
946+
// attempts cannot be determined.
947+
const existingBan = {
948+
action: 'test',
949+
usedDefaultRule: false,
950+
blockingOn: 'ip',
951+
blockedValue: '1.2.3.4',
952+
startTime: MOCK_NOW,
953+
duration: 300,
954+
attempts: 42,
955+
reason: 'too-many-attempts',
956+
policy: 'ban',
957+
};
958+
mockGet.mockImplementation((key: string) =>
959+
Promise.resolve(
960+
key.includes(':ban:') ? JSON.stringify(existingBan) : null
961+
)
962+
);
963+
964+
rateLimit = new RateLimit(
965+
{ rules: parseConfigRules(['test:ip:5:1 minute:5 minutes:ban']) },
966+
redis,
967+
statsd,
968+
{ write: mockWrite }
969+
);
970+
971+
await rateLimit.check('test', { ip: '1.2.3.4' });
972+
973+
expect(mockIncr).not.toHaveBeenCalled();
974+
expect(mockWrite).toHaveBeenCalledWith(
975+
expect.objectContaining({
976+
wasBlocked: true,
977+
currentAttempts: undefined,
978+
})
979+
);
980+
});
981+
860982
it('calls bqWriter.write on skip() with wasSkipped true', () => {
861983
rateLimit = new RateLimit(
862984
{ rules: {}, ignoreIPs: ['127.0.0.1'] },

libs/accounts/rate-limit/src/lib/rate-limit.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export class RateLimit {
249249
}
250250

251251
const firstRule = rules[0];
252+
252253
this.bqWriter.write({
253254
timestamp: now,
254255
action,
@@ -259,9 +260,24 @@ export class RateLimit {
259260
ruleMaxAttempts: firstRule?.rule.maxAttempts,
260261
ruleWindowSeconds: firstRule?.rule.windowDurationInSeconds,
261262
ruleBlockSeconds: firstRule?.rule.blockDurationInSeconds,
262-
currentAttempts: attemptCounts?.get(
263-
firstRule?.rule.blockingOn
264-
),
263+
currentAttempts: (() => {
264+
// When attempt counts were tallied on this check, they hold the real
265+
// per-rule attempt total, so prefer them.
266+
if (attemptCounts && firstRule) {
267+
return attemptCounts.get(firstRule.rule.blockingOn);
268+
}
269+
270+
// Without attempt counts we may be short-circuiting on a pre-existing
271+
// block. In that case the user has, at minimum, reached maxAttempts, so
272+
// report that as the best available approximation.
273+
if (result != null && firstRule) {
274+
return firstRule.rule.maxAttempts;
275+
}
276+
277+
// Otherwise (e.g. a ban, where no rule applies) we can't say how many
278+
// attempts occurred.
279+
return undefined;
280+
})(),
265281
wasBlocked: result != null,
266282
blockPolicy: result?.policy,
267283
blockDurationSeconds: result

0 commit comments

Comments
 (0)