sveltekit-rate-limiter
Advanced tools
Comparing version 0.5.2 to 0.6.0
@@ -1,1 +0,1 @@ | ||
export type { Rate, RateUnit } from './server/index.js'; | ||
export type { Rate, RateUnit } from './server/rate.js'; |
@@ -1,117 +0,8 @@ | ||
import type { Cookies, RequestEvent, MaybePromise } from '@sveltejs/kit'; | ||
export type RateUnit = 'ms' | '100ms' | '250ms' | '500ms' | 's' | '2s' | '5s' | '10s' | '15s' | '30s' | '45s' | 'm' | '2m' | '5m' | '10m' | '15m' | '30m' | '45m' | 'h' | '2h' | '6h' | '12h' | 'd'; | ||
export type Rate = [number, RateUnit]; | ||
export interface RateLimiterStore { | ||
add: (hash: string, unit: RateUnit) => MaybePromise<number>; | ||
clear: () => MaybePromise<void>; | ||
} | ||
export interface RateLimiterPlugin<Extra = never> { | ||
hash: (event: RequestEvent, extraData: Extra) => MaybePromise<string | boolean | null>; | ||
get rate(): Rate; | ||
} | ||
type CookieSerializeOptions = NonNullable<Parameters<Cookies['set']>[2]>; | ||
type CookieRateLimiterOptions = { | ||
name: string; | ||
secret: string; | ||
rate: Rate; | ||
preflight: boolean; | ||
serializeOptions?: CookieSerializeOptions; | ||
hashFunction?: HashFunction; | ||
}; | ||
declare class CookieRateLimiter implements RateLimiterPlugin { | ||
readonly rate: Rate; | ||
private readonly cookieOptions; | ||
private readonly secret; | ||
private readonly requirePreflight; | ||
private readonly cookieId; | ||
private readonly hashFunction; | ||
constructor(options: CookieRateLimiterOptions); | ||
hash(event: RequestEvent): Promise<string | false>; | ||
preflight(event: RequestEvent): Promise<string>; | ||
private userIdFromCookie; | ||
} | ||
type HashFunction = (input: string) => MaybePromise<string>; | ||
export type RateLimiterOptions = Partial<{ | ||
plugins: RateLimiterPlugin[]; | ||
store: RateLimiterStore; | ||
maxItems: number; | ||
onLimited: (event: RequestEvent, reason: 'rate' | 'rejected') => MaybePromise<void | boolean>; | ||
/** | ||
* @deprecated Add the IP/IPUA/cookie rates to the main object, no need for "rates". | ||
*/ | ||
rates: { | ||
/** | ||
* @deprecated Add the IP option to the main object, no need for "rates". | ||
*/ | ||
IP?: Rate; | ||
/** | ||
* @deprecated Add the IPUA option to the main object, no need for "rates". | ||
*/ | ||
IPUA?: Rate; | ||
/** | ||
* @deprecated Add cookie option to the main object, no need for "rates". | ||
*/ | ||
cookie?: CookieRateLimiterOptions; | ||
}; | ||
IP: Rate; | ||
IPUA: Rate; | ||
cookie: CookieRateLimiterOptions; | ||
hashFunction: HashFunction; | ||
}>; | ||
export declare class RateLimiter<Extra = never> { | ||
private readonly store; | ||
private readonly plugins; | ||
private readonly onLimited; | ||
private readonly hashFunction; | ||
readonly cookieLimiter: CookieRateLimiter | undefined; | ||
static TTLTime(unit: RateUnit): number; | ||
/** | ||
* Check if a request event is rate limited. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<boolean>} true if request is limited, false otherwise | ||
*/ | ||
isLimited(event: [Extra] extends [never] ? RequestEvent : { | ||
missing_extraData: Extra; | ||
}): Promise<boolean>; | ||
/** | ||
* Check if a request event is rate limited, supplying extra data that will be available for plugins. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<boolean>} true if request is limited, false otherwise | ||
*/ | ||
isLimited(event: RequestEvent, extraData: Extra): Promise<boolean>; | ||
/** | ||
* Clear all rate limits. | ||
*/ | ||
clear(): Promise<void>; | ||
/** | ||
* Check if a request event is rate limited. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<boolean>} true if request is limited, false otherwise | ||
*/ | ||
protected _isLimited(event: RequestEvent, extraData: Extra): Promise<{ | ||
limited: boolean; | ||
hash: string | null; | ||
unit: RateUnit; | ||
}>; | ||
constructor(options?: RateLimiterOptions); | ||
} | ||
export declare class RetryAfterRateLimiter<Extra = never> extends RateLimiter<Extra> { | ||
private readonly retryAfter; | ||
constructor(options?: RateLimiterOptions, retryAfterStore?: RateLimiterStore); | ||
private static toSeconds; | ||
private static unitToSeconds; | ||
/** | ||
* Clear all rate limits. | ||
*/ | ||
clear(): Promise<void>; | ||
/** | ||
* Check if a request event is rate limited. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<limited: boolean, retryAfter: number>} Rate limit status for the event. | ||
*/ | ||
check(event: RequestEvent, extraData?: Extra): Promise<{ | ||
limited: boolean; | ||
retryAfter: number; | ||
}>; | ||
} | ||
export {}; | ||
export { RateLimiter } from './rateLimiter.js'; | ||
export { RetryAfterRateLimiter } from './retryAfterRateLimiter.js'; | ||
export { defaultHashFunction } from './hashFunction.js'; | ||
export { TTLTime } from './rate.js'; | ||
export type { RateLimiterPlugin } from './limiters/rateLimiterPlugin.js'; | ||
export type { RateLimiterStore } from './stores/index.js'; | ||
export type { HashFunction } from './hashFunction.js'; | ||
export type { Rate, RateUnit } from './rate.js'; |
@@ -1,323 +0,4 @@ | ||
import { nanoid } from 'nanoid'; | ||
import TTLCache from '@isaacs/ttlcache'; | ||
///// Plugins ///////////////////////////////////////////////////////////////// | ||
class IPRateLimiter { | ||
rate; | ||
constructor(rate) { | ||
this.rate = rate; | ||
} | ||
async hash(event) { | ||
return event.getClientAddress(); | ||
} | ||
} | ||
class IPUserAgentRateLimiter { | ||
rate; | ||
constructor(rate) { | ||
this.rate = rate; | ||
} | ||
async hash(event) { | ||
const ua = event.request.headers.get('user-agent'); | ||
if (!ua) | ||
return false; | ||
return event.getClientAddress() + ua; | ||
} | ||
} | ||
class CookieRateLimiter { | ||
rate; | ||
cookieOptions; | ||
secret; | ||
requirePreflight; | ||
cookieId; | ||
hashFunction; | ||
constructor(options) { | ||
this.cookieId = options.name; | ||
this.secret = options.secret; | ||
this.rate = options.rate; | ||
this.requirePreflight = options.preflight; | ||
this.hashFunction = options.hashFunction ?? defaultHashFunction; | ||
this.cookieOptions = { | ||
path: '/', | ||
httpOnly: true, | ||
maxAge: 60 * 60 * 24 * 7, | ||
sameSite: 'strict', | ||
...options.serializeOptions | ||
}; | ||
} | ||
async hash(event) { | ||
const currentId = await this.userIdFromCookie(event.cookies.get(this.cookieId), event); | ||
return currentId ? currentId : false; | ||
} | ||
async preflight(event) { | ||
const data = event.cookies.get(this.cookieId); | ||
if (data) { | ||
const userId = await this.userIdFromCookie(data, event); | ||
if (userId) | ||
return userId; | ||
} | ||
const userId = nanoid(); | ||
event.cookies.set(this.cookieId, userId + ';' + (await this.hashFunction(this.secret + userId)), this.cookieOptions); | ||
return userId; | ||
} | ||
async userIdFromCookie(cookie, event) { | ||
const empty = () => { | ||
return this.requirePreflight ? null : this.preflight(event); | ||
}; | ||
if (!cookie) | ||
return empty(); | ||
const [userId, secretHash] = cookie.split(';'); | ||
if (!userId || !secretHash) | ||
return empty(); | ||
if ((await this.hashFunction(this.secret + userId)) != secretHash) { | ||
return empty(); | ||
} | ||
return userId; | ||
} | ||
} | ||
let defaultHashFunction; | ||
if (globalThis?.crypto?.subtle) { | ||
defaultHashFunction = _subtleSha256; | ||
} | ||
async function _subtleSha256(str) { | ||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); | ||
return [...new Uint8Array(digest)] | ||
.map((b) => b.toString(16).padStart(2, '0')) | ||
.join(''); | ||
} | ||
export class RateLimiter { | ||
store; | ||
plugins; | ||
onLimited; | ||
hashFunction; | ||
cookieLimiter; | ||
static TTLTime(unit) { | ||
switch (unit) { | ||
case 's': | ||
return 1000; | ||
case 'm': | ||
return 60000; | ||
case 'h': | ||
return 60 * 60000; | ||
case '2s': | ||
return 2000; | ||
case '5s': | ||
return 5000; | ||
case '10s': | ||
return 10000; | ||
case '15s': | ||
return 15000; | ||
case '30s': | ||
return 30000; | ||
case '45s': | ||
return 45000; | ||
case '2m': | ||
return 2 * 60000; | ||
case '5m': | ||
return 5 * 60000; | ||
case '10m': | ||
return 10 * 60000; | ||
case '15m': | ||
return 15 * 60000; | ||
case '30m': | ||
return 30 * 60000; | ||
case '45m': | ||
return 45 * 60000; | ||
case '100ms': | ||
return 100; | ||
case '250ms': | ||
return 250; | ||
case '500ms': | ||
return 500; | ||
case '2h': | ||
return 2 * 60 * 60000; | ||
case '6h': | ||
return 6 * 60 * 60000; | ||
case '12h': | ||
return 12 * 60 * 60000; | ||
case 'd': | ||
return 24 * 60 * 60000; | ||
case 'ms': | ||
return 1; | ||
} | ||
throw new Error('Invalid unit for TTLTime: ' + unit); | ||
} | ||
async isLimited(event, extraData) { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
return (await this._isLimited(event, extraData)) | ||
.limited; | ||
} | ||
/** | ||
* Clear all rate limits. | ||
*/ | ||
async clear() { | ||
return await this.store.clear(); | ||
} | ||
/** | ||
* Check if a request event is rate limited. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<boolean>} true if request is limited, false otherwise | ||
*/ | ||
async _isLimited(event, extraData) { | ||
let limited = undefined; | ||
for (const plugin of this.plugins) { | ||
const rate = plugin.rate; | ||
const id = await plugin.hash(event, extraData); | ||
if (id === false) { | ||
if (this.onLimited) { | ||
const status = await this.onLimited(event, 'rejected'); | ||
if (status === true) | ||
return { limited: false, hash: null, unit: rate[1] }; | ||
} | ||
return { limited: true, hash: null, unit: rate[1] }; | ||
} | ||
else if (id === null) { | ||
if (limited === undefined) | ||
limited = true; | ||
continue; | ||
} | ||
else { | ||
limited = false; | ||
} | ||
if (!id) { | ||
throw new Error('Empty hash returned from rate limiter ' + plugin.constructor.name); | ||
} | ||
if (id === true) { | ||
return { limited: false, hash: null, unit: rate[1] }; | ||
} | ||
const hash = await this.hashFunction(id); | ||
const currentRate = await this.store.add(hash, rate[1]); | ||
if (currentRate > rate[0]) { | ||
if (this.onLimited) { | ||
const status = await this.onLimited(event, 'rate'); | ||
if (status === true) | ||
return { limited: false, hash, unit: rate[1] }; | ||
} | ||
return { limited: true, hash, unit: rate[1] }; | ||
} | ||
} | ||
return { | ||
limited: limited ?? false, | ||
hash: null, | ||
unit: this.plugins[this.plugins.length - 1].rate[1] | ||
}; | ||
} | ||
constructor(options = {}) { | ||
this.plugins = [...(options.plugins ?? [])]; | ||
this.onLimited = options.onLimited; | ||
this.hashFunction = options.hashFunction ?? defaultHashFunction; | ||
if (!this.hashFunction) { | ||
throw new Error('No RateLimiter hash function found. Please set one with the hashFunction option.'); | ||
} | ||
const IPRates = options.IP ?? options.rates?.IP; | ||
if (IPRates) | ||
this.plugins.push(new IPRateLimiter(IPRates)); | ||
const IPUARates = options.IPUA ?? options.rates?.IPUA; | ||
if (IPUARates) | ||
this.plugins.push(new IPUserAgentRateLimiter(IPUARates)); | ||
const cookieRates = options.cookie ?? options.rates?.cookie; | ||
if (cookieRates) { | ||
this.plugins.push((this.cookieLimiter = new CookieRateLimiter({ | ||
hashFunction: this.hashFunction, | ||
...cookieRates | ||
}))); | ||
} | ||
if (!this.plugins.length) { | ||
throw new Error('No plugins set for RateLimiter!'); | ||
} | ||
// Sort plugins by rate, if early cancelling | ||
this.plugins.sort((a, b) => { | ||
const diff = RateLimiter.TTLTime(a.rate[1]) - RateLimiter.TTLTime(b.rate[1]); | ||
return diff == 0 ? a.rate[0] - b.rate[0] : diff; | ||
}); | ||
const maxTTL = this.plugins.reduce((acc, plugin) => { | ||
const rate = plugin.rate[1]; | ||
if (rate == 'ms') { | ||
console.warn('RateLimiter: The "ms" unit is not reliable due to OS timing issues.'); | ||
} | ||
const time = RateLimiter.TTLTime(rate); | ||
return Math.max(time, acc); | ||
}, 0); | ||
this.store = options.store ?? new TTLStore(maxTTL, options.maxItems); | ||
} | ||
} | ||
export class RetryAfterRateLimiter extends RateLimiter { | ||
retryAfter; | ||
constructor(options = {}, retryAfterStore) { | ||
super(options); | ||
this.retryAfter = retryAfterStore ?? new RetryAfterStore(); | ||
} | ||
static toSeconds(rateMs) { | ||
return Math.max(0, Math.floor(rateMs / 1000)); | ||
} | ||
static unitToSeconds(unit) { | ||
return RetryAfterRateLimiter.toSeconds(RateLimiter.TTLTime(unit)); | ||
} | ||
/** | ||
* Clear all rate limits. | ||
*/ | ||
async clear() { | ||
await this.retryAfter.clear(); | ||
return await super.clear(); | ||
} | ||
/** | ||
* Check if a request event is rate limited. | ||
* @param {RequestEvent} event | ||
* @returns {Promise<limited: boolean, retryAfter: number>} Rate limit status for the event. | ||
*/ | ||
async check(event, extraData) { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const result = await this._isLimited(event, extraData); | ||
if (!result.limited) | ||
return { limited: false, retryAfter: 0 }; | ||
if (result.hash === null) { | ||
return { | ||
limited: true, | ||
retryAfter: RetryAfterRateLimiter.unitToSeconds(result.unit) | ||
}; | ||
} | ||
const retryAfter = RetryAfterRateLimiter.toSeconds((await this.retryAfter.add(result.hash, result.unit)) - Date.now()); | ||
return { limited: true, retryAfter }; | ||
} | ||
} | ||
///// Stores /////////////////////////////////////////////////////////////////// | ||
class TTLStore { | ||
cache; | ||
constructor(maxTTL, maxItems = Infinity) { | ||
this.cache = new TTLCache({ | ||
ttl: maxTTL, | ||
max: maxItems, | ||
noUpdateTTL: true | ||
}); | ||
} | ||
async clear() { | ||
return this.cache.clear(); | ||
} | ||
async add(hash, unit) { | ||
const currentRate = this.cache.get(hash) ?? 0; | ||
return this.set(hash, currentRate + 1, unit); | ||
} | ||
set(hash, rate, unit) { | ||
this.cache.set(hash, rate, { ttl: RateLimiter.TTLTime(unit) }); | ||
return rate; | ||
} | ||
} | ||
class RetryAfterStore { | ||
cache; | ||
constructor(maxItems = Infinity) { | ||
this.cache = new TTLCache({ | ||
max: maxItems, | ||
noUpdateTTL: true | ||
}); | ||
} | ||
async clear() { | ||
return this.cache.clear(); | ||
} | ||
async add(hash, unit) { | ||
const currentRate = this.cache.get(hash); | ||
if (currentRate) | ||
return this.cache.get(hash) ?? 0; | ||
const ttl = RateLimiter.TTLTime(unit); | ||
const retryAfter = Date.now() + ttl; | ||
this.cache.set(hash, retryAfter, { ttl }); | ||
return retryAfter; | ||
} | ||
} | ||
export { RateLimiter } from './rateLimiter.js'; | ||
export { RetryAfterRateLimiter } from './retryAfterRateLimiter.js'; | ||
export { defaultHashFunction } from './hashFunction.js'; | ||
export { TTLTime } from './rate.js'; |
{ | ||
"name": "sveltekit-rate-limiter", | ||
"version": "0.5.2", | ||
"version": "0.6.0", | ||
"author": "Andreas Söderlund <ciscoheat@gmail.com> (https://blog.encodeart.dev)", | ||
@@ -27,2 +27,10 @@ "description": "A modular rate limiter for SvelteKit. Use in password resets, account registration, etc.", | ||
"svelte": "./dist/server/index.js" | ||
}, | ||
"./server/limiters": { | ||
"types": "./dist/server/limiters/index.d.ts", | ||
"svelte": "./dist/server/limiters/index.js" | ||
}, | ||
"./server/stores": { | ||
"types": "./dist/server/stores/index.d.ts", | ||
"svelte": "./dist/server/stores/index.js" | ||
} | ||
@@ -29,0 +37,0 @@ }, |
@@ -100,2 +100,6 @@ # sveltekit-rate-limiter | ||
## Multiple limits | ||
You can specify the rates as an array, to handle multiple rates per limiter, like "Max 1 per second and 100 per hour": `[[1, 's'], [100, 'h']]`. | ||
## Retry-After limiter | ||
@@ -156,3 +160,3 @@ | ||
hash: (event: RequestEvent) => MaybePromise<string | boolean | null>; | ||
get rate(): Rate; | ||
get rate(): Rate | Rate[]; | ||
} | ||
@@ -181,5 +185,5 @@ ``` | ||
class IPUserAgentRateLimiter implements RateLimiterPlugin { | ||
readonly rate: Rate; | ||
readonly rate: Rate | Rate[]; | ||
constructor(rate: Rate) { | ||
constructor(rate: Rate | Rate[]) { | ||
this.rate = rate; | ||
@@ -222,2 +226,3 @@ } | ||
async hash(_: RequestEvent, extraData: { email: string }) { | ||
// Return true to bypass the rest of the plugin chain | ||
return extraData.email.endsWith(this.allowedDomain) ? true : null; | ||
@@ -224,0 +229,0 @@ } |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
31062
33
534
243
1