Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/trivy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v3

- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/trivy-action@0.28.0
uses: aquasecurity/trivy-action@master
with:
scan-type: "fs"
scan-ref: "${{ github.workspace }}"
Expand Down
4,273 changes: 1,786 additions & 2,487 deletions package-lock.json

Large diffs are not rendered by default.

72 changes: 37 additions & 35 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,52 +44,53 @@
"!*/__tests__"
],
"dependencies": {
"@loopback/boot": "^8.0.4",
"@loopback/context": "^8.0.3",
"@loopback/core": "^7.0.3",
"@loopback/repository": "^8.0.3",
"@loopback/rest": "^15.0.4",
"express-rate-limit": "^6.4.0",
"rate-limit-memcached": "^0.6.0",
"@loopback/boot": "^8.0.11",
"@loopback/context": "^8.0.10",
"@loopback/core": "^7.0.10",
"@loopback/repository": "^8.0.10",
"@loopback/rest": "^15.0.11",
"express-rate-limit": "^8.3.1",
"rate-limit-memcached": "^1.0.1",
"rate-limit-mongo": "^2.3.2",
"rate-limit-redis": "^3.0.1"
"rate-limit-redis": "^4.3.1"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@loopback/build": "^12.0.3",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@loopback/build": "^12.0.10",
"@loopback/eslint-config": "^16.0.1",
"@loopback/testlab": "^8.0.3",
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@loopback/testlab": "^8.0.10",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.0",
"@semantic-release/npm": "^13.1.1",
"@semantic-release/release-notes-generator": "^10.0.3",
"@types/express-rate-limit": "^5.0.0",
"@types/memcached": "^2.2.6",
"@types/node": "^18.11.9",
"@types/proxyquire": "^1.3.28",
"@types/rate-limit-redis": "^1.7.4",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@semantic-release/github": "^12.0.6",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"@types/express-rate-limit": "^6.0.2",
"@types/ioredis": "^4.28.10",
"@types/memcached": "^2.2.10",
"@types/node": "^25.5.0",
"@types/proxyquire": "^1.3.31",
"@types/rate-limit-redis": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"cz-conventional-changelog": "^3.3.0",
"cz-customizable": "^6.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"cz-customizable": "^7.5.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-eslint-plugin": "^5.5.1",
"eslint-plugin-mocha": "^10.4.3",
"fs-extra": "^11.2.0",
"eslint-plugin-mocha": "^10.5.0",
"fs-extra": "^11.3.4",
"git-release-notes": "^5.0.0",
"husky": "^7.0.4",
"jsdom": "^21.0.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"loopback-connector-kv-redis": "^4.0.0",
"memcached": "^2.2.2",
"proxyquire": "^2.1.3",
"semantic-release": "^25.0.1",
"simple-git": "^3.15.1",
"semantic-release": "^25.0.3",
"simple-git": "^3.33.0",
"source-map-support": "^0.5.21",
"typescript": "~5.2.2"
"typescript": "~5.5.4"
},
"overrides": {
"peerDependencies": {
Expand All @@ -102,7 +103,8 @@
"git-release-notes": {
"ejs": "^3.1.8",
"yargs": "^17.6.2"
}
},
"undici": "^6.24.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import path from 'path';
import {MySequence} from './sequence';
import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..';

import {StoreProvider} from '../../store.provider';
import {TestController} from '../../test.controller';
export {ApplicationConfig};
export class TestApplication extends BootMixin(
Expand All @@ -23,9 +22,7 @@ export class TestApplication extends BootMixin(

this.projectRoot = __dirname;
this.controller(TestController);
this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).toProvider(
StoreProvider,
);
this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).to(null);

this.bind(RateLimitSecurityBindings.CONFIG).to({
name: 'inMemory',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {setUpApplication} from './helper';
import {clearRateLimitCache} from '../../../providers/ratelimit-action.provider';

const OK_STATUS_CODE = 200;
const TOO_MANY_REQS_CODE = 429;
Expand All @@ -14,7 +14,7 @@ describe('Acceptance Test Cases', () => {
({app, client} = await setUpApplication());
});
afterEach(async () => {
await clearStore();
clearRateLimitCache();
});

after(async () => app.stop());
Expand Down Expand Up @@ -64,8 +64,4 @@ describe('Acceptance Test Cases', () => {
);
}, TIMEOUT);
});

async function clearStore() {
memoryStore.resetAll();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {RateLimiterComponent, RateLimitSecurityBindings} from '../../../..';
import {TestController} from '../../test.controller';

import {MySequence} from './middleware.sequence';
import {StoreProvider} from '../../store.provider';
export {ApplicationConfig};
export class TestApplication extends BootMixin(
ServiceMixin(RepositoryMixin(RestApplication)),
Expand All @@ -26,9 +25,7 @@ export class TestApplication extends BootMixin(

this.projectRoot = __dirname;
this.controller(TestController);
this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).toProvider(
StoreProvider,
);
this.bind(RateLimitSecurityBindings.DATASOURCEPROVIDER).to(null);

this.bind(RateLimitSecurityBindings.CONFIG).to({
name: 'inMemory',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {setUpApplication} from './helper';
import {clearRateLimitCache} from '../../../middleware/ratelimit.middleware';

describe('Acceptance Test Cases', () => {
let app: TestApplication;
let client: Client;
Expand All @@ -10,7 +11,7 @@ describe('Acceptance Test Cases', () => {
({app, client} = await setUpApplication());
});
afterEach(async () => {
await clearStore();
clearRateLimitCache();
});

after(async () => app.stop());
Expand Down Expand Up @@ -58,8 +59,4 @@ describe('Acceptance Test Cases', () => {
);
}, 2000);
});

async function clearStore() {
memoryStore.resetAll();
}
});
18 changes: 0 additions & 18 deletions src/__tests__/acceptance/store.provider.ts

This file was deleted.

48 changes: 44 additions & 4 deletions src/middleware/ratelimit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ import * as RateLimit from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitMetadata, RateLimitOptions} from '../types';
import {RatelimitActionMiddlewareGroup} from './middleware.enum';

// Cache for RateLimit instances to avoid store reuse error in v8
const rateLimitCache = new Map<string, RateLimit.RateLimitRequestHandler>();

function getRateLimiterKey(opts: Partial<RateLimitOptions>): string {
return JSON.stringify(opts);
}

// Export function to clear cache for testing
export function clearRateLimitCache(): void {
rateLimitCache.clear();
}

@injectable(
asMiddleware({
group: RatelimitActionMiddlewareGroup.RATELIMIT,
Expand All @@ -26,7 +39,7 @@ import {RatelimitActionMiddlewareGroup} from './middleware.enum';
export class RatelimitMiddlewareProvider implements Provider<Middleware> {
constructor(
@inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER)
private readonly getDatastore: Getter<RateLimit.Store>,
private readonly getDatastore: Getter<RateLimit.Store | null>,
@inject.getter(RateLimitSecurityBindings.METADATA)
private readonly getMetadata: Getter<RateLimitMetadata>,
@inject(CoreBindings.APPLICATION_INSTANCE)
Expand Down Expand Up @@ -60,17 +73,44 @@ export class RatelimitMiddlewareProvider implements Provider<Middleware> {
const operationMetadata = metadata ? metadata.options : {};

// Create options based on global config and method level config
const opts = {...this.config, ...operationMetadata};
const rawOpts = {...this.config, ...operationMetadata};

// Filter out unsupported options for express-rate-limit v8
// 'name' is no longer supported in v8
// 'client', 'type', 'uri', 'collectionName' are custom DataSourceConfig options
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
name,
client,
type,
uri,
collectionName,
store: originalStore,
...opts
} = rawOpts as RateLimitOptions & {store?: unknown};
/* eslint-enable @typescript-eslint/no-unused-vars */

// If dataStore is null or undefined, don't set the store property
// express-rate-limit v8 will create its own InMemoryStore
if (dataStore) {
opts.store = dataStore;
(opts as RateLimit.Options).store = dataStore;
}

opts.message = new HttpErrors.TooManyRequests(
opts.message?.toString() ?? 'Method rate limit reached !',
);

const limiter = RateLimit.default(opts);
// Get or create a RateLimit instance for this configuration
// This avoids the "store reuse" error in express-rate-limit v8
// Note: We exclude 'store' from cache key since each store instance is unique
const cacheKey = getRateLimiterKey(opts);
let limiter = rateLimitCache.get(cacheKey);

if (!limiter) {
limiter = RateLimit.default(opts);
rateLimitCache.set(cacheKey, limiter);
}

limiter(request, response, (err: unknown) => {
if (err) {
reject(err);
Expand Down
47 changes: 43 additions & 4 deletions src/providers/ratelimit-action.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ import * as RateLimit from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitAction, RateLimitMetadata, RateLimitOptions} from '../types';

// Cache for RateLimit instances to avoid store reuse error in v8
const rateLimitCache = new Map<string, RateLimit.RateLimitRequestHandler>();

function getRateLimiterKey(opts: Partial<RateLimitOptions>): string {
return JSON.stringify(opts);
}

// Export function to clear cache for testing
export function clearRateLimitCache(): void {
rateLimitCache.clear();
}

export class RatelimitActionProvider implements Provider<RateLimitAction> {
constructor(
@inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER)
private readonly getDatastore: Getter<RateLimit.Store>,
private readonly getDatastore: Getter<RateLimit.Store | null>,
@inject.getter(RateLimitSecurityBindings.METADATA)
private readonly getMetadata: Getter<RateLimitMetadata>,
@inject(CoreBindings.APPLICATION_INSTANCE)
Expand Down Expand Up @@ -39,17 +51,44 @@ export class RatelimitActionProvider implements Provider<RateLimitAction> {
const operationMetadata = metadata ? metadata.options : {};

// Create options based on global config and method level config
const opts = {...this.config, ...operationMetadata};
const rawOpts = {...this.config, ...operationMetadata};

// Filter out unsupported options for express-rate-limit v8
// 'name' is no longer supported in v8
// 'client', 'type', 'uri', 'collectionName' are custom DataSourceConfig options
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
name,
client,
type,
uri,
collectionName,
store: originalStore,
...opts
} = rawOpts as RateLimitOptions & {store?: unknown};
/* eslint-enable @typescript-eslint/no-unused-vars */

// If dataStore is null or undefined, don't set the store property
// express-rate-limit v8 will create its own InMemoryStore
if (dataStore) {
opts.store = dataStore;
(opts as RateLimit.Options).store = dataStore;
}

opts.message = new HttpErrors.TooManyRequests(
opts.message?.toString() ?? 'Method rate limit reached !',
);

const limiter = RateLimit.default(opts);
// Get or create a RateLimit instance for this configuration
// This avoids the "store reuse" error in express-rate-limit v8
// Note: We exclude 'store' from cache key since each store instance is unique
const cacheKey = getRateLimiterKey(opts);
let limiter = rateLimitCache.get(cacheKey);

if (!limiter) {
limiter = RateLimit.default(opts);
rateLimitCache.set(cacheKey, limiter);
}

limiter(request, response, (err: unknown) => {
if (err) {
reject(err);
Expand Down
Loading
Loading