Skip to content

Commit 21eb71a

Browse files
authored
feat(core-p2p): rate limit plugin (#4193)
1 parent a238981 commit 21eb71a

File tree

3 files changed

+144
-0
lines changed

3 files changed

+144
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Server } from "@hapi/hapi";
2+
import Joi from "@hapi/joi";
3+
import { Container } from "@arkecosystem/core-kernel";
4+
5+
import { RateLimitPlugin } from "@arkecosystem/core-p2p/src/socket-server/plugins/rate-limit";
6+
import * as utils from "@arkecosystem/core-p2p/src/utils/build-rate-limiter";
7+
8+
afterEach(() => {
9+
jest.clearAllMocks();
10+
});
11+
12+
describe("RateLimitPlugin", () => {
13+
let rateLimitPlugin: RateLimitPlugin;
14+
15+
const container = new Container.Container();
16+
17+
const responsePayload = { status: "ok" };
18+
const mockRouteByPath = {
19+
"/p2p/peer/mockroute": {
20+
id: "p2p.peer.getPeers",
21+
handler: () => responsePayload,
22+
validation: Joi.object().max(0),
23+
},
24+
};
25+
const mockRoute = {
26+
method: "POST",
27+
path: "/p2p/peer/mockroute",
28+
config: {
29+
id: mockRouteByPath["/p2p/peer/mockroute"].id,
30+
handler: mockRouteByPath["/p2p/peer/mockroute"].handler,
31+
},
32+
};
33+
34+
const app = {
35+
resolve: jest.fn().mockReturnValue({ getRoutesConfigByPath: () => mockRouteByPath }),
36+
};
37+
const pluginConfiguration = { getOptional: (id, defaultValue) => defaultValue };
38+
const rateLimiter = {
39+
hasExceededRateLimit: jest.fn().mockReturnValue(false),
40+
};
41+
42+
beforeAll(() => {
43+
container.unbindAll();
44+
container.bind(Container.Identifiers.Application).toConstantValue(app);
45+
container.bind(Container.Identifiers.PluginConfiguration).toConstantValue(pluginConfiguration);
46+
47+
jest.spyOn(utils, "buildRateLimiter").mockReturnValue(rateLimiter as any);
48+
});
49+
50+
beforeEach(() => {
51+
rateLimitPlugin = container.resolve<RateLimitPlugin>(RateLimitPlugin);
52+
});
53+
54+
it("should register the plugin", async () => {
55+
const server = new Server({ port: 4100 });
56+
server.route(mockRoute);
57+
58+
const spyExt = jest.spyOn(server, "ext");
59+
60+
rateLimitPlugin.register(server);
61+
62+
expect(spyExt).toBeCalledWith(expect.objectContaining({ type: "onPreAuth" }));
63+
64+
// try the route with a valid payload
65+
const remoteAddress = "187.166.55.44";
66+
const responseValid = await server.inject({
67+
method: "POST",
68+
url: "/p2p/peer/mockroute",
69+
payload: {},
70+
remoteAddress,
71+
});
72+
expect(JSON.parse(responseValid.payload)).toEqual(responsePayload);
73+
expect(responseValid.statusCode).toBe(200);
74+
expect(rateLimiter.hasExceededRateLimit).toBeCalledTimes(1);
75+
});
76+
77+
it("should return a tooManyRequests error when exceeded rate limit", async () => {
78+
const server = new Server({ port: 4100 });
79+
server.route(mockRoute);
80+
rateLimitPlugin.register(server);
81+
82+
rateLimiter.hasExceededRateLimit.mockReturnValueOnce(true);
83+
84+
// try the route with a valid payload
85+
const remoteAddress = "187.166.55.44";
86+
const responseForbidden = await server.inject({
87+
method: "POST",
88+
url: "/p2p/peer/mockroute",
89+
payload: {},
90+
remoteAddress,
91+
});
92+
expect(responseForbidden.statusCode).toBe(429);
93+
expect(rateLimiter.hasExceededRateLimit).toBeCalledTimes(1);
94+
});
95+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Container, Contracts, Providers } from "@arkecosystem/core-kernel";
2+
import Boom from "@hapi/boom";
3+
import { RateLimiter } from "../../rate-limiter";
4+
import { buildRateLimiter } from "../../utils/build-rate-limiter";
5+
import { BlocksRoute } from "../routes/blocks";
6+
import { InternalRoute } from "../routes/internal";
7+
import { PeerRoute } from "../routes/peer";
8+
import { TransactionsRoute } from "../routes/transactions";
9+
10+
@Container.injectable()
11+
export class RateLimitPlugin {
12+
@Container.inject(Container.Identifiers.Application)
13+
protected readonly app!: Contracts.Kernel.Application;
14+
15+
@Container.inject(Container.Identifiers.PluginConfiguration)
16+
@Container.tagged("plugin", "@arkecosystem/core-p2p")
17+
private readonly configuration!: Providers.PluginConfiguration;
18+
19+
private rateLimiter!: RateLimiter;
20+
21+
public register(server) {
22+
this.rateLimiter = buildRateLimiter({
23+
whitelist: [],
24+
remoteAccess: this.configuration.getOptional<Array<string>>("remoteAccess", []),
25+
rateLimit: this.configuration.getOptional<number>("rateLimit", 100),
26+
});
27+
28+
const allRoutesConfigByPath = {
29+
...this.app.resolve(InternalRoute).getRoutesConfigByPath(),
30+
...this.app.resolve(PeerRoute).getRoutesConfigByPath(),
31+
...this.app.resolve(BlocksRoute).getRoutesConfigByPath(),
32+
...this.app.resolve(TransactionsRoute).getRoutesConfigByPath(),
33+
};
34+
35+
server.ext({
36+
type: "onPreAuth",
37+
method: async (request, h) => {
38+
const endpoint = allRoutesConfigByPath[request.path].id;
39+
40+
if (await this.rateLimiter.hasExceededRateLimit(request.info.remoteAddress, endpoint)) {
41+
return Boom.tooManyRequests("Rate limit exceeded");
42+
}
43+
return h.continue;
44+
},
45+
});
46+
}
47+
}

packages/core-p2p/src/socket-server/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { BlocksRoute } from "./routes/blocks";
1111
import { InternalRoute } from "./routes/internal";
1212
import { PeerRoute } from "./routes/peer";
1313
import { TransactionsRoute } from "./routes/transactions";
14+
import { RateLimitPlugin } from "./plugins/rate-limit";
1415

1516
// todo: review the implementation
1617
@Container.injectable()
@@ -76,6 +77,7 @@ export class Server {
7677

7778
// onPreAuth
7879
this.app.resolve(WhitelistForgerPlugin).register(this.server);
80+
this.app.resolve(RateLimitPlugin).register(this.server);
7981

8082
// onPostAuth
8183
this.app.resolve(CodecPlugin).register(this.server);

0 commit comments

Comments
 (0)