Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#155244960] add rate limiter #585

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ IDP_METADATA_URL=https://registry.spid.gov.it/metadata/idp/spid-entities-idps.xm
SHUTDOWN_SIGNALS="SIGINT SIGTERM"
SHUTDOWN_TIMEOUT_MILLIS=30000
GITHUB_TOKEN=<mygithubtoken>
RATE_LIMIT_DURATION_SECS=3600
RATE_LIMIT_POINTS=10000
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,13 @@ Those are all Environment variables needed by the application:
| SPID_AUTOLOGIN | The user used in the autologin feature, omit this to disable autologin | string |
| CIE_METADATA_URL | Url to download CIE metadata from | string |
| IDP_METADATA_URL | Url to download IDP metadata from | string |
| IDP_METADATA_REFRESH_INTERVAL_SECONDS | The number of seconds when the IDPs Metadata are refreshed | int |
| CACHE_MAX_AGE_SECONDS | The value in seconds for duration of in-memory api cache | int |
| IDP_METADATA_REFRESH_INTERVAL_SECONDS | The number of seconds when the IDPs Metadata are refreshed | int |
| CACHE_MAX_AGE_SECONDS | The value in seconds for duration of in-memory api cache | int |
| APICACHE_DEBUG | When is `true` enable the apicache debug mode | boolean |
| ALLOW_MULTIPLE_SESSIONS | When is `true` allow multiple sessions for an user (default `false`) | boolean |
| GITHUB_TOKEN | The value of your Github Api Key, used in build phase | string |
| GITHUB_TOKEN | The value of your Github Api Key, used in build phase | string |
| RATE_LIMIT_DURATION_SECS | Rate limiter span (period) in seconds | number |
| RATE_LIMIT_POINTS | Maximum number of calls to a single API endpoint over RATE_LIMIT_DURATION_SECS | number |

### Logs

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"passport-http-bearer": "^1.0.1",
"passport-strategy": "^1.0.0",
"range_check": "^1.4.0",
"rate-limiter-flexible": "^2.0.0",
"redis": "^2.8.0",
"redis-clustr": "^1.6.0",
"request-ip": "^2.1.1",
Expand Down
25 changes: 25 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
getClientProfileRedirectionUrl,
hubName,
PAGOPA_CLIENT,
RATE_LIMITER_DURATION_SECS,
RATE_LIMITER_POINTS,
REDIS_CLIENT,
samlConfig,
serviceProviderConfig,
Expand Down Expand Up @@ -58,6 +60,7 @@ import {
import { withSpid } from "@pagopa/io-spid-commons";
import { toError } from "fp-ts/lib/Either";
import { tryCatch } from "fp-ts/lib/TaskEither";
import { RateLimiterMemory, RateLimiterRedis } from "rate-limiter-flexible";
import { VersionPerPlatform } from "../generated/public/VersionPerPlatform";
import UserDataProcessingController from "./controllers/userDataProcessingController";
import MessagesService from "./services/messagesService";
Expand All @@ -67,6 +70,7 @@ import RedisSessionStorage from "./services/redisSessionStorage";
import RedisUserMetadataStorage from "./services/redisUserMetadataStorage";
import TokenService from "./services/tokenService";
import UserDataProcessingService from "./services/userDataProcessingService";
import { makeRateLimiterMiddleware } from "./utils/middleware/rateLimiter";

const defaultModule = {
newApp
Expand All @@ -84,6 +88,22 @@ const cachingMiddleware = apicache.options({
}
}).middleware;

const rateLimiterOpts = {
duration: RATE_LIMITER_DURATION_SECS,
keyPrefix: "rl-",
points: RATE_LIMITER_POINTS
};

const rateLimiterMiddleware = makeRateLimiterMiddleware(
REDIS_CLIENT
? new RateLimiterRedis({
...rateLimiterOpts,
storeClient: REDIS_CLIENT
})
: // useful for testing
new RateLimiterMemory(rateLimiterOpts)
);

export function newApp(
env: NodeEnvironment,
allowNotifyIPSourceRange: CIDR,
Expand Down Expand Up @@ -329,12 +349,14 @@ function registerAPIRoutes(

app.post(
`${basePath}/profile`,
rateLimiterMiddleware,
bearerTokenAuth,
toExpressHandler(profileController.updateProfile, profileController)
);

app.post(
`${basePath}/email-validation-process`,
rateLimiterMiddleware,
bearerTokenAuth,
toExpressHandler(
profileController.startEmailValidationProcess,
Expand All @@ -350,6 +372,7 @@ function registerAPIRoutes(

app.post(
`${basePath}/user-metadata`,
rateLimiterMiddleware,
bearerTokenAuth,
toExpressHandler(
userMetadataController.upsertMetadata,
Expand Down Expand Up @@ -412,6 +435,7 @@ function registerAPIRoutes(

app.put(
`${basePath}/installations/:id`,
rateLimiterMiddleware,
bearerTokenAuth,
toExpressHandler(
notificationController.createOrUpdateInstallation,
Expand Down Expand Up @@ -449,6 +473,7 @@ function registerAPIRoutes(

app.post(
`${basePath}/payment-activations`,
rateLimiterMiddleware,
bearerTokenAuth,
toExpressHandler(
pagoPAProxyController.activatePayment,
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export const CACHE_MAX_AGE_SECONDS: number = parseInt(
10
);

// Rate limiter configuration
export const RATE_LIMITER_DURATION_SECS = parseInt(
process.env.RATE_LIMIT_DURATION_SECS || "3600",
10
);
export const RATE_LIMITER_POINTS = parseInt(
process.env.RATE_LIMIT_POINTS || "10000",
10
);

// Private key used in SAML authentication to a SPID IDP.
const samlKey = () => {
return readFile(
Expand Down
38 changes: 38 additions & 0 deletions src/utils/middleware/__tests__/rateLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import mockReq from "../../../__mocks__/request";
import mockRes from "../../../__mocks__/response";
import { makeRateLimiterMiddleware } from "../rateLimiter";

import * as rip from "request-ip";
jest.spyOn(rip, "getClientIp").mockReturnValue("127.0.0.1");

describe("Rate limiter middleware", () => {
it("should apply rate limit and return 429 if limit is reached", async () => {
const rateLimiterMiddleware = makeRateLimiterMiddleware(
new RateLimiterMemory({
duration: 1,
points: 1
})
);
const next = jest.fn();
const aResponse = mockRes();
await rateLimiterMiddleware(mockReq(), aResponse, next);
await rateLimiterMiddleware(mockReq(), aResponse, next);
expect(aResponse.set).toHaveBeenCalledWith("Retry-After", "1");
expect(aResponse.status).toHaveBeenCalledWith(429);
});
it("should NOT apply rate limit if limit is NOT reached", async () => {
const rateLimiterMiddleware = makeRateLimiterMiddleware(
new RateLimiterMemory({
duration: 1,
points: 2
})
);
const next = jest.fn();
const aResponse = mockRes();
await rateLimiterMiddleware(mockReq(), aResponse, next);
await rateLimiterMiddleware(mockReq(), aResponse, next);
expect(aResponse.set).toHaveBeenCalledWith("X-RateLimit-Remaining", "1");
expect(aResponse.status).not.toHaveBeenCalledWith(429);
});
});
47 changes: 47 additions & 0 deletions src/utils/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextFunction, Request, Response } from "express";
import { RateLimiterStoreAbstract } from "rate-limiter-flexible";
import * as requestIp from "request-ip";

import { ProblemJson } from "italia-ts-commons/lib/responses";
import { log } from "../../utils/logger";

export const makeRateLimiterMiddleware = (
rateLimiter: RateLimiterStoreAbstract
) => async (req: Request, res: Response, next: NextFunction) => {
const ip = requestIp.getClientIp(req);
try {
const rl = await rateLimiter.consume(ip);
res
.set("X-RateLimit-Remaining", rl.remainingPoints.toString())
.set(
"X-RateLimit-Reset",
new Date(Date.now() + Number(rl.msBeforeNext)).toString()
)
.set(
"X-RateLimit-Limit",
(Number(rl.remainingPoints) + Number(rl.consumedPoints)).toString()
);
next();
} catch (_) {
const retryAfter = Math.ceil(_.msBeforeNext / 1000);
const problem: ProblemJson = {
detail: "Rate limit reached",
status: 429,
title: "Too Many requests"
};
log.warn("Rate limiter is blocking ip (%s)", ip);
res
.set("X-RateLimit-Remaining", _.remainingPoints.toString())
.set(
"X-RateLimit-Reset",
new Date(Date.now() + Number(_.msBeforeNext)).toString()
)
.set(
"X-RateLimit-Limit",
(Number(_.remainingPoints) + Number(_.consumedPoints)).toString()
)
.set("Retry-After", retryAfter.toString())
gunzip marked this conversation as resolved.
Show resolved Hide resolved
.status(429)
.json(problem);
}
};
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5647,6 +5647,11 @@ range_check@^1.4.0:
ip6 "0.0.4"
ipaddr.js "1.2"

rate-limiter-flexible@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-2.0.0.tgz#6b193fe302a279f2460f5caa3276b9d0b59f1e02"
integrity sha512-DjLeci3BuHWNr9LVVm+YPJ+Lrki/J9iDb9cEJFZELpa/ZBhXmfnfR+eCI5jzmsPJtpYGYhw/vyRfjjQjTzVtRg==

[email protected]:
version "2.4.0"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
Expand Down