Skip to content

Commit fdcb79c

Browse files
committed
0.3.2 changes
1 parent 9df468d commit fdcb79c

File tree

4 files changed

+68
-32
lines changed

4 files changed

+68
-32
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ Headlines: Added, Changed, Deprecated, Removed, Fixed, Security
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.3.2] - 2023-07-11
9+
10+
### Changed
11+
12+
- Removed `check` method from `RateLimiterStore` interface.
13+
14+
### Added
15+
16+
- `RateLimiterPlugin` can now return `null`, as an indeterminate result.
17+
18+
### Fixed
19+
20+
- `RateLimiter` plugin chain wasn't immutable.
21+
822
## [0.3.1] - 2023-07-02
923

1024
### Security

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
A modular rate limiter for password resets, account registration, etc. Use in your `page.server.ts` files, or `hooks.server.ts`.
44

5-
Uses an in-memory cache ([@isaacs/ttlcache](https://www.npmjs.com/package/@isaacs/ttlcache)), but can be swapped for something else. Same for limiters, which are plugins. See the [source file](https://github.com/ciscoheat/sveltekit-rate-limiter/blob/main/src/lib/server/index.ts#L24-L33) for interfaces.
5+
Uses an in-memory cache ([@isaacs/ttlcache](https://www.npmjs.com/package/@isaacs/ttlcache)), but can be swapped for something else. Same for limiters, which are plugins. The [source file](https://github.com/ciscoheat/sveltekit-rate-limiter/blob/main/src/lib/server/index.ts#L24-L32) lists both interfaces.
6+
7+
## How to use
68

79
```ts
810
import { error } from '@sveltejs/kit';
911
import { RateLimiter } from 'sveltekit-rate-limiter/server';
1012

1113
const limiter = new RateLimiter({
14+
// A rate is defined as [number, unit]
1215
rates: {
1316
IP: [10, 'h'], // IP address limiter
1417
IPUA: [5, 'm'], // IP + User Agent limiter
@@ -34,10 +37,10 @@ export const actions = {
3437
};
3538
```
3639

37-
The limiters will be called in smallest unit order, so in the example above:
40+
The limiters will be called in smallest unit and rate order, so in the example above:
3841

3942
```
40-
cookie (2/min) -> IPUA (5/min) -> IP(10/hour)
43+
cookie(2/min) IPUA(5/min) IP(10/hour)
4144
```
4245

4346
Valid units are, from smallest to largest:
@@ -52,15 +55,21 @@ Implement the `RateLimiterPlugin` interface:
5255

5356
```ts
5457
interface RateLimiterPlugin {
55-
hash: (event: RequestEvent) => Promise<string | boolean>;
58+
hash: (event: RequestEvent) => Promise<string | boolean | null>;
5659
get rate(): Rate;
5760
}
5861
```
5962

60-
In `hash`, return a string based on a [RequestEvent](https://kit.svelte.dev/docs/types#public-types-requestevent), which will be counted and checked against the rate, or a boolean to short-circuit the plugin chain and make the request fail (`false`) or succeed (`true`) no matter the current rate.
63+
In `hash`, return one of the following:
64+
65+
- A `string` based on a [RequestEvent](https://kit.svelte.dev/docs/types#public-types-requestevent), which will be counted and checked against the rate.
66+
- A `boolean`, to short-circuit the plugin chain and make the request fail (`false`) or succeed (`true`) no matter the current rate.
67+
- Or `null`, to signify an indeterminate result and move to the next plugin in the chain, or fail the request if it's the last one.
68+
69+
### String hash rules
6170

62-
- The string will be hashed later, so you don't need to use any hash function.
63-
- The string cannot be empty, in that case an exception will be thrown.
71+
- The string will be hashed later, so you don't need to use a hash function.
72+
- The string cannot be empty, in which case an exception will be thrown.
6473

6574
### Example
6675

src/index.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class ShortCircuitPlugin implements RateLimiterPlugin {
1818
}
1919

2020
async hash() {
21-
return this.value ?? false;
21+
return this.value;
2222
}
2323
}
2424

@@ -214,7 +214,7 @@ describe('Basic rate limiter', async () => {
214214
event = mockEvent() as RequestEvent;
215215
});
216216

217-
it.only('should always allow the request when true is returned and the plugin is first in the chain', async () => {
217+
it('should always allow the request when true is returned and the plugin is first in the chain', async () => {
218218
const limiter = new RateLimiter({
219219
plugins: [new ShortCircuitPlugin(true, [1, 'm'])],
220220
rates: {
@@ -227,7 +227,7 @@ describe('Basic rate limiter', async () => {
227227
expect(await limiter.isLimited(event)).toEqual(false);
228228
});
229229

230-
it.only('should always deny the request when false is returned and the plugin is first in the chain', async () => {
230+
it('should always deny the request when false is returned and the plugin is first in the chain', async () => {
231231
const limiter = new RateLimiter({
232232
plugins: [new ShortCircuitPlugin(false, [1, 'm'])],
233233
rates: {
@@ -240,7 +240,7 @@ describe('Basic rate limiter', async () => {
240240
expect(await limiter.isLimited(event)).toEqual(true);
241241
});
242242

243-
it.only('should deny the request when it is returning false further down the chain, and the first plugin is ok', async () => {
243+
it('should deny the request when it is returning false further down the chain, and the first plugin is ok', async () => {
244244
const limiter = new RateLimiter({
245245
plugins: [new ShortCircuitPlugin(false, [5, 'm'])],
246246
rates: {
@@ -253,7 +253,7 @@ describe('Basic rate limiter', async () => {
253253
expect(await limiter.isLimited(event)).toEqual(true);
254254
});
255255

256-
it.only('should allow the request when it is returning true further down the chain, until the first plugin is limiting', async () => {
256+
it('should allow the request when it is returning true further down the chain, until the first plugin is limiting', async () => {
257257
const limiter = new RateLimiter({
258258
plugins: [new ShortCircuitPlugin(true, [5, 'm'])],
259259
rates: {
@@ -265,5 +265,21 @@ describe('Basic rate limiter', async () => {
265265
expect(await limiter.isLimited(event)).toEqual(false);
266266
expect(await limiter.isLimited(event)).toEqual(true);
267267
});
268+
269+
it('should deny the request when it is returning null further down the chain, until any other plugin is limiting', async () => {
270+
const limiter = new RateLimiter({
271+
plugins: [new ShortCircuitPlugin(null, [3, 'm'])],
272+
rates: {
273+
IP: [5, 'm']
274+
}
275+
});
276+
277+
expect(await limiter.isLimited(event)).toEqual(false);
278+
expect(await limiter.isLimited(event)).toEqual(false);
279+
expect(await limiter.isLimited(event)).toEqual(false);
280+
expect(await limiter.isLimited(event)).toEqual(false);
281+
expect(await limiter.isLimited(event)).toEqual(false);
282+
expect(await limiter.isLimited(event)).toEqual(true);
283+
});
268284
});
269285
});

src/lib/server/index.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ export type Rate = [number, RateUnit];
2222
///// Interfaces /////////////////////////////////////////////////////////////
2323

2424
export interface RateLimiterStore {
25-
check: (hash: string, unit: RateUnit) => Promise<number>;
2625
add: (hash: string, unit: RateUnit) => Promise<number>;
2726
clear: () => Promise<void>;
2827
}
2928

3029
export interface RateLimiterPlugin {
31-
hash: (event: RequestEvent) => Promise<string | boolean>;
30+
hash: (event: RequestEvent) => Promise<string | boolean | null>;
3231
get rate(): Rate;
3332
}
3433

@@ -50,23 +49,19 @@ class TTLStore implements RateLimiterStore {
5049
});
5150
}
5251

53-
set(hash: string, rate: number, unit: RateUnit): number {
54-
this.cache.set(hash, rate, { ttl: RateLimiter.TTLTime(unit) });
55-
return rate;
56-
}
57-
5852
async clear() {
5953
return this.cache.clear();
6054
}
6155

62-
async check(hash: string) {
63-
return this.cache.get(hash) ?? 0;
64-
}
65-
6656
async add(hash: string, unit: RateUnit) {
67-
const currentRate = await this.check(hash);
57+
const currentRate = this.cache.get(hash) ?? 0;
6858
return this.set(hash, currentRate + 1, unit);
6959
}
60+
61+
private set(hash: string, rate: number, unit: RateUnit): number {
62+
this.cache.set(hash, rate, { ttl: RateLimiter.TTLTime(unit) });
63+
return rate;
64+
}
7065
}
7166

7267
///// Plugins /////////////////////////////////////////////////////////////////
@@ -167,20 +162,20 @@ class CookieRateLimiter implements RateLimiterPlugin {
167162

168163
///// Main class //////////////////////////////////////////////////////////////
169164

170-
export type RateLimiterOptions = {
171-
plugins?: RateLimiterPlugin[];
172-
store?: RateLimiterStore;
173-
maxItems?: number;
174-
onLimited?: (
165+
export type RateLimiterOptions = Partial<{
166+
plugins: RateLimiterPlugin[];
167+
store: RateLimiterStore;
168+
maxItems: number;
169+
onLimited: (
175170
event: RequestEvent,
176171
reason: 'rate' | 'rejected'
177172
) => Promise<void | boolean> | void | boolean;
178-
rates?: {
173+
rates: {
179174
IP?: Rate;
180175
IPUA?: Rate;
181176
cookie?: CookieRateLimiterOptions;
182177
};
183-
};
178+
}>;
184179

185180
export class RateLimiter {
186181
private readonly store: RateLimiterStore;
@@ -225,6 +220,8 @@ export class RateLimiter {
225220
return true;
226221
} else if (id === true) {
227222
return false;
223+
} else if (id === null) {
224+
continue;
228225
}
229226

230227
if (!id) {
@@ -249,7 +246,7 @@ export class RateLimiter {
249246
}
250247

251248
constructor(options: RateLimiterOptions = {}) {
252-
this.plugins = options.plugins ?? [];
249+
this.plugins = [...(options.plugins ?? [])];
253250
this.onLimited = options.onLimited;
254251

255252
if (options.rates?.IP)

0 commit comments

Comments
 (0)