Skip to content

Commit

Permalink
0.3.2 changes
Browse files Browse the repository at this point in the history
  • Loading branch information
ciscoheat committed Jul 11, 2023
1 parent 9df468d commit fdcb79c
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 32 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ Headlines: Added, Changed, Deprecated, Removed, Fixed, Security
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.2] - 2023-07-11

### Changed

- Removed `check` method from `RateLimiterStore` interface.

### Added

- `RateLimiterPlugin` can now return `null`, as an indeterminate result.

### Fixed

- `RateLimiter` plugin chain wasn't immutable.

## [0.3.1] - 2023-07-02

### Security
Expand Down
23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

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

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.
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.

## How to use

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

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

The limiters will be called in smallest unit order, so in the example above:
The limiters will be called in smallest unit and rate order, so in the example above:

```
cookie (2/min) -> IPUA (5/min) -> IP(10/hour)
cookie(2/min) IPUA(5/min) IP(10/hour)
```

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

```ts
interface RateLimiterPlugin {
hash: (event: RequestEvent) => Promise<string | boolean>;
hash: (event: RequestEvent) => Promise<string | boolean | null>;
get rate(): Rate;
}
```

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.
In `hash`, return one of the following:

- A `string` based on a [RequestEvent](https://kit.svelte.dev/docs/types#public-types-requestevent), which will be counted and checked against the rate.
- A `boolean`, to short-circuit the plugin chain and make the request fail (`false`) or succeed (`true`) no matter the current rate.
- 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.

### String hash rules

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

### Example

Expand Down
26 changes: 21 additions & 5 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class ShortCircuitPlugin implements RateLimiterPlugin {
}

async hash() {
return this.value ?? false;
return this.value;
}
}

Expand Down Expand Up @@ -214,7 +214,7 @@ describe('Basic rate limiter', async () => {
event = mockEvent() as RequestEvent;
});

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

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

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

it.only('should allow the request when it is returning true further down the chain, until the first plugin is limiting', async () => {
it('should allow the request when it is returning true further down the chain, until the first plugin is limiting', async () => {
const limiter = new RateLimiter({
plugins: [new ShortCircuitPlugin(true, [5, 'm'])],
rates: {
Expand All @@ -265,5 +265,21 @@ describe('Basic rate limiter', async () => {
expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(true);
});

it('should deny the request when it is returning null further down the chain, until any other plugin is limiting', async () => {
const limiter = new RateLimiter({
plugins: [new ShortCircuitPlugin(null, [3, 'm'])],
rates: {
IP: [5, 'm']
}
});

expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(false);
expect(await limiter.isLimited(event)).toEqual(true);
});
});
});
37 changes: 17 additions & 20 deletions src/lib/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@ export type Rate = [number, RateUnit];
///// Interfaces /////////////////////////////////////////////////////////////

export interface RateLimiterStore {
check: (hash: string, unit: RateUnit) => Promise<number>;
add: (hash: string, unit: RateUnit) => Promise<number>;
clear: () => Promise<void>;
}

export interface RateLimiterPlugin {
hash: (event: RequestEvent) => Promise<string | boolean>;
hash: (event: RequestEvent) => Promise<string | boolean | null>;
get rate(): Rate;
}

Expand All @@ -50,23 +49,19 @@ class TTLStore implements RateLimiterStore {
});
}

set(hash: string, rate: number, unit: RateUnit): number {
this.cache.set(hash, rate, { ttl: RateLimiter.TTLTime(unit) });
return rate;
}

async clear() {
return this.cache.clear();
}

async check(hash: string) {
return this.cache.get(hash) ?? 0;
}

async add(hash: string, unit: RateUnit) {
const currentRate = await this.check(hash);
const currentRate = this.cache.get(hash) ?? 0;
return this.set(hash, currentRate + 1, unit);
}

private set(hash: string, rate: number, unit: RateUnit): number {
this.cache.set(hash, rate, { ttl: RateLimiter.TTLTime(unit) });
return rate;
}
}

///// Plugins /////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -167,20 +162,20 @@ class CookieRateLimiter implements RateLimiterPlugin {

///// Main class //////////////////////////////////////////////////////////////

export type RateLimiterOptions = {
plugins?: RateLimiterPlugin[];
store?: RateLimiterStore;
maxItems?: number;
onLimited?: (
export type RateLimiterOptions = Partial<{
plugins: RateLimiterPlugin[];
store: RateLimiterStore;
maxItems: number;
onLimited: (
event: RequestEvent,
reason: 'rate' | 'rejected'
) => Promise<void | boolean> | void | boolean;
rates?: {
rates: {
IP?: Rate;
IPUA?: Rate;
cookie?: CookieRateLimiterOptions;
};
};
}>;

export class RateLimiter {
private readonly store: RateLimiterStore;
Expand Down Expand Up @@ -225,6 +220,8 @@ export class RateLimiter {
return true;
} else if (id === true) {
return false;
} else if (id === null) {
continue;
}

if (!id) {
Expand All @@ -249,7 +246,7 @@ export class RateLimiter {
}

constructor(options: RateLimiterOptions = {}) {
this.plugins = options.plugins ?? [];
this.plugins = [...(options.plugins ?? [])];
this.onLimited = options.onLimited;

if (options.rates?.IP)
Expand Down

0 comments on commit fdcb79c

Please sign in to comment.