Skip to content

Commit 1e1c39b

Browse files
authored
Merge pull request #18864 from mozilla/support-rate-limit-allow-lists
Support rate limit allow lists
2 parents 291f689 + 0eb9884 commit 1e1c39b

8 files changed

Lines changed: 195 additions & 22 deletions

File tree

libs/accounts/rate-limit/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,18 @@ would replicate the pre-existing behavior:
3434
passwordForgotVerifyOtp : email : 10 attempts : 24 hours : 24 hours
3535
3636
```
37+
38+
### Testing & Development Considerations
39+
40+
When developing new features, running functional test suites locally, or running smoke tests remotely, rate-limiting behavior can be annoying — or even downright disruptive. To work around this, there are a few options:
41+
42+
#### Disable the rules:
43+
You can simply remove the rate-limiting rules. Our servers typically hold the rules as a string in the environment, e.g., RATE_LIMIT__RULES=['some','rules']. Override this value with an empty string, and rate limiting will effectively be disabled. Want to test a single rate limit? Just specify that one rule. It doesn’t get much simpler than that.
44+
45+
#### Exclude specific attributes like ip, email, or UID
46+
When running smoke tests against a remote server, the first approach may not work — so we also support excluding specific emails, user IDs (UIDs), or IP addresses from being checked by the rate limiter. To do this, define these values in your server's config file and pass them to the rate limiter.
47+
48+
- Email ignore values can use regex filters.
49+
- IP addresses and UIDs must be exact string matches.
50+
51+
Ideally, the IP filter should be used. This is the preferred method because all requests contain an IP address, making it the lowest common denominator for rate-limiting attributes. Other advantages of using the IP filter include its consistency and ease of configuration.

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('rate-limit', () => {
2424

2525
beforeEach(async () => {
2626
await redis.flushall();
27-
rateLimit = new RateLimit({}, redis, statsd);
27+
rateLimit = new RateLimit({ rules: {} }, redis, statsd);
2828
mockIncrement.mockReset();
2929
});
3030

@@ -35,7 +35,7 @@ describe('rate-limit', () => {
3535
for (const blockOn of ['ip', 'email', 'uid']) {
3636
it('should block on ' + blockOn, async () => {
3737
rateLimit = new RateLimit(
38-
parseConfigRules(['testBlock:ip:1:1s:1s']),
38+
{ rules: parseConfigRules(['testBlock:ip:1:1s:1s']) },
3939
redis,
4040
statsd
4141
);
@@ -57,7 +57,7 @@ describe('rate-limit', () => {
5757

5858
it(`should not block after window clears`, async () => {
5959
rateLimit = new RateLimit(
60-
parseConfigRules(['testWindowCleared:ip:1:1s:1s']),
60+
{ rules: parseConfigRules(['testWindowCleared:ip:1:1s:1s']) },
6161
redis,
6262
statsd
6363
);
@@ -76,7 +76,12 @@ describe('rate-limit', () => {
7676

7777
it('can block multiple rules on a single action', async () => {
7878
rateLimit = new RateLimit(
79-
parseConfigRules(['testMulti:ip:2:10s:2s', 'testMulti:email:2:10s:3s']),
79+
{
80+
rules: parseConfigRules([
81+
'testMulti:ip:2:10s:2s',
82+
'testMulti:email:2:10s:3s',
83+
]),
84+
},
8085
redis,
8186
statsd
8287
);
@@ -120,10 +125,12 @@ describe('rate-limit', () => {
120125

121126
it('can block a doubled up rule', async () => {
122127
rateLimit = new RateLimit(
123-
parseConfigRules([
124-
'testDouble:email:2:10s:2s',
125-
'testDouble:email:3:10s:4s',
126-
]),
128+
{
129+
rules: parseConfigRules([
130+
'testDouble:email:2:10s:2s',
131+
'testDouble:email:3:10s:4s',
132+
]),
133+
},
127134
redis,
128135
statsd
129136
);
@@ -153,7 +160,9 @@ describe('rate-limit', () => {
153160
* Note that once the ban kicks in the window disappears. So despite
154161
*/
155162
rateLimit = new RateLimit(
156-
parseConfigRules(['test:ip:1:20s:1s']),
163+
{
164+
rules: parseConfigRules(['test:ip:1:20s:1s']),
165+
},
157166
redis,
158167
statsd
159168
);
@@ -174,7 +183,9 @@ describe('rate-limit', () => {
174183

175184
it('can unblock', async () => {
176185
rateLimit = new RateLimit(
177-
parseConfigRules(['testBlock:ip:1:1s:1s']),
186+
{
187+
rules: parseConfigRules(['testBlock:ip:1:1s:1s']),
188+
},
178189
redis,
179190
statsd
180191
);

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ describe('rate-limit', () => {
2929
});
3030

3131
it('creates rate limiter', () => {
32-
rateLimit = new RateLimit({}, redis, statsd);
32+
rateLimit = new RateLimit({ rules: {} }, redis, statsd);
3333
expect(rateLimit).toBeDefined();
3434
});
3535

3636
it('can determine if action is supported', () => {
3737
rateLimit = new RateLimit(
38-
parseConfigRules(['test:ip:1:1s:1s']),
38+
{ rules: parseConfigRules(['test:ip:1:1s:1s']) },
3939
redis,
4040
statsd
4141
);
@@ -45,15 +45,15 @@ describe('rate-limit', () => {
4545
});
4646

4747
it('throws if no action can be found', async () => {
48-
rateLimit = new RateLimit({}, redis, statsd);
48+
rateLimit = new RateLimit({ rules: {} }, redis, statsd);
4949
expect(rateLimit.check('foo', {})).rejects.toThrow(
5050
`Could not locate the 'foo' action.`
5151
);
5252
});
5353

5454
it('throws error if option is missing', async () => {
5555
rateLimit = new RateLimit(
56-
parseConfigRules(['test:ip:1:1s:1s']),
56+
{ rules: parseConfigRules(['test:ip:1:1s:1s']) },
5757
redis,
5858
statsd
5959
);
@@ -98,6 +98,57 @@ describe('rate-limit', () => {
9898
);
9999
});
100100

101+
it('can ignore certain emails', () => {
102+
rateLimit = new RateLimit(
103+
{
104+
rules: {},
105+
ignoreEmails: ['@mozilla.com$', '[email protected]'],
106+
},
107+
redis,
108+
statsd
109+
);
110+
111+
expect(rateLimit.skip({ email: '[email protected]' })).toBeTruthy();
112+
expect(rateLimit.skip({ email: '[email protected]' })).toBeTruthy();
113+
expect(rateLimit.skip({ email: '[email protected] ' })).toBeFalsy();
114+
expect(rateLimit.skip({ email: '[email protected]' })).toBeTruthy();
115+
expect(rateLimit.skip({ email: '[email protected] ' })).toBeFalsy();
116+
expect(rateLimit.skip({})).toBeFalsy();
117+
expect(statsd.increment).toBeCalledWith('rate_limit.ignore.email');
118+
});
119+
120+
it('can ignore certain ips', () => {
121+
rateLimit = new RateLimit(
122+
{
123+
rules: {},
124+
ignoreIPs: ['127.0.0.1'],
125+
},
126+
redis,
127+
statsd
128+
);
129+
130+
expect(rateLimit.skip({ ip: '127.0.0.1' })).toBeTruthy();
131+
expect(rateLimit.skip({ ip: '0.0.0.0' })).toBeFalsy();
132+
expect(rateLimit.skip({})).toBeFalsy();
133+
expect(statsd.increment).toBeCalledWith('rate_limit.ignore.ip');
134+
});
135+
136+
it('can ignore certain uids', () => {
137+
rateLimit = new RateLimit(
138+
{
139+
rules: {},
140+
ignoreUIDs: ['000-000-000'],
141+
},
142+
redis,
143+
statsd
144+
);
145+
146+
expect(rateLimit.skip({ uid: '000-000-000' })).toBeTruthy();
147+
expect(rateLimit.skip({ uid: '000-000-001' })).toBeFalsy();
148+
expect(rateLimit.skip({})).toBeFalsy();
149+
expect(statsd.increment).toBeCalledWith('rate_limit.ignore.uid');
150+
});
151+
101152
describe('configuration rules', () => {
102153
it('parses rule set', () => {
103154
const ruleSet = parseConfigRules(`

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ export class RateLimit {
2121
* @param redis A Redis client
2222
*/
2323
constructor(
24-
public readonly rules: Record<string, Rule[]>,
24+
public readonly config: {
25+
rules: Record<string, Rule[]>;
26+
ignoreIPs?: Array<string>;
27+
ignoreEmails?: Array<string>;
28+
ignoreUIDs?: Array<string>;
29+
},
2530
private readonly redis: Redis,
2631
private readonly statsd?: StatsD
2732
) {}
@@ -32,8 +37,8 @@ export class RateLimit {
3237
* @returns Null if no block is found. Or a status containing info about the block.
3338
*/
3439
async unblock(opts: { ip?: string; email?: string; uid?: string }) {
35-
for (const action in this.rules) {
36-
for (const rule of this.rules[action]) {
40+
for (const action in this.config.rules) {
41+
for (const rule of this.config.rules[action]) {
3742
const blockedValue = opts[rule.blockingOn];
3843
if (blockedValue) {
3944
const attemptsKey = getKey('attempts', rule, blockedValue);
@@ -57,8 +62,34 @@ export class RateLimit {
5762
* @param action - Name of action
5863
* @returns - True if action has rules, otherwise false.
5964
*/
60-
supportsAction(action:string) {
61-
return this.rules[action] != null;
65+
supportsAction(action: string) {
66+
return this.config.rules[action] != null;
67+
}
68+
69+
/**
70+
* Determines if a check can be skipped, due to an ignored ip, email, or uid.
71+
* When testing and developing it's often helpful to disable customs rules for
72+
* certain users.
73+
* @param opts - The current properties being checked.
74+
* @returns
75+
*/
76+
skip(opts: { ip?: string; email?: string; uid?: string }) {
77+
if (opts.ip != null && this.config.ignoreIPs?.some((x) => opts.ip === x)) {
78+
this.statsd?.increment('rate_limit.ignore.ip');
79+
return true;
80+
}
81+
82+
if (opts.uid != null && this.config.ignoreUIDs?.some((x) => opts.uid === x)) {
83+
this.statsd?.increment('rate_limit.ignore.uid');
84+
return true;
85+
}
86+
87+
if (opts.email != null && this.config.ignoreEmails?.some((x) => opts.email?.match(x))) {
88+
this.statsd?.increment('rate_limit.ignore.email');
89+
return true;
90+
}
91+
92+
return false
6293
}
6394

6495
/**
@@ -77,7 +108,7 @@ export class RateLimit {
77108
}
78109
): Promise<BlockStatus | null> {
79110
// Make sure action actually exists
80-
const rules = this.rules[action];
111+
const rules = this.config.rules[action];
81112
if (!rules) {
82113
throw new ActionNotFound(action);
83114
}

packages/fxa-auth-server/bin/key_server.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,16 @@ async function run(config) {
227227
...config.redis,
228228
...config.redis.customs,
229229
});
230-
const rateLimit = new RateLimit(rules, rateLimitRedis, statsd);
230+
const rateLimit = new RateLimit(
231+
{
232+
rules,
233+
ignoreIPs: config.rateLimit.ignoreIPs,
234+
ignoreEmails: config.rateLimit.ignoreEmails,
235+
ignoreUIDs: config.rateLimit.ignoreUIDs,
236+
},
237+
rateLimitRedis,
238+
statsd
239+
);
231240
Container.set(RateLimit, rateLimit);
232241

233242
// Create Recovery Phone Service

packages/fxa-auth-server/config/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,24 @@ const convictConf = convict({
21612161
},
21622162
},
21632163
rateLimit: {
2164+
ignoreIPs: {
2165+
default: undefined,
2166+
doc: 'Set of IPs to ignore while rate limiting. Rules will not apply to these values.',
2167+
env: 'RATE_LIMIT__IGNORE_IPS',
2168+
format: Array,
2169+
},
2170+
ignoreUIDs: {
2171+
default: undefined,
2172+
doc: 'Set of UIDs to ignore while rate limiting. Rules will not apply to these values.',
2173+
env: 'RATE_LIMIT__IGNORE_UIDS',
2174+
format: Array,
2175+
},
2176+
ignoreEmails: {
2177+
default: undefined,
2178+
doc: 'Set of emails to ignore while rate limiting. Rules will not apply to these values. Values can be a string or parsable regex.',
2179+
env: 'RATE_LIMIT__IGNORE_EMAILS',
2180+
format: Array,
2181+
},
21642182
rules: {
21652183
default: [
21662184
'unblockEmail : email : 10 : 24 hours : 24 hours ',

packages/fxa-auth-server/lib/customs.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,18 @@ class CustomsClient {
306306
return false;
307307
}
308308

309+
// The config can specify that certain ips, emails, or uids should be excluded
310+
// from rate limit checks.
311+
const skip = this.rateLimit.skip(opts);
312+
if (skip) {
313+
this.statsd.increment(`${serviceName}.check.v2.skip`, [
314+
opts.ip ? 'ip':'',
315+
opts.email ? 'email':'',
316+
opts.uid ? 'uid':'',
317+
]);
318+
return true;
319+
}
320+
309321
// Otherwise, call the new nx lib instead of the legacy service
310322
this.statsd?.increment(`${serviceName}.check.v2`, [`action:${action}`]);
311323
const result = await this.rateLimit.check(action, opts);

packages/fxa-auth-server/test/local/customs.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,11 @@ describe('Customs', () => {
721721

722722
describe('customs v2', () => {
723723
const mockRateLimit = {
724-
supportsAction: sinon.spy(() => true),
725724
check: sinon.spy(),
725+
skip: sinon.spy(),
726+
supportsAction: sinon.spy(),
726727
};
728+
727729
const customs = new Customs(
728730
CUSTOMS_URL_REAL,
729731
log,
@@ -732,6 +734,12 @@ describe('Customs', () => {
732734
mockRateLimit
733735
);
734736

737+
beforeEach(() => {
738+
mockRateLimit.check = sinon.spy();
739+
mockRateLimit.skip = sinon.spy(() => false);
740+
mockRateLimit.supportsAction = sinon.spy(() => true);
741+
});
742+
735743
it('can allow checkAccountStatus with rate-limit lib', async () => {
736744
mockRateLimit.check = sandbox.spy(async () => {
737745
return await Promise.resolve(null);
@@ -763,6 +771,24 @@ describe('Customs', () => {
763771
'Client has sent too many requests'
764772
);
765773
});
774+
775+
it('can skip certain emails, ips, and uids', async () => {
776+
mockRateLimit.skip = sandbox.spy(() => true);
777+
mockRateLimit.check = sandbox.spy(async () => {
778+
return await Promise.resolve({
779+
retryAfter: 1000,
780+
reason: 'too-many-attempts',
781+
});
782+
});
783+
784+
// Should not throw despite, check being mocked to return an error. The
785+
// skip check should be called first, and since it indicates a skip should
786+
// occur, the actual customs check shouldn't ever happen.
787+
await customs.check(request, email, 'accountStatusCheck');
788+
789+
assert.calledWith(mockRateLimit.skip, { ip, email });
790+
assert.callCount(mockRateLimit.check, 0);
791+
});
766792
});
767793

768794
describe('statsd metrics', () => {

0 commit comments

Comments
 (0)