diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31a3bdbc..8c5a472c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Node CI on: push: - branches: [ master ] + branches: [ "*" ] pull_request: - branches: [ master ] + branches: [ "*" ] jobs: build: diff --git a/README.md b/README.md index aa6cd348..cc5acab7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Better understand your network routing, fix anycast issues, monitor your CDN and DNS performance,
do uptime monitoring and build your own network tools for personal or public use.
-
+

diff --git a/config/default.cjs b/config/default.cjs index a0a8e283..72ab53a7 100644 --- a/config/default.cjs +++ b/config/default.cjs @@ -15,7 +15,7 @@ module.exports = { }, geoip: { cache: { - ttl: 3 * 24 * 60 * 60 * 1000, // 24hrs + ttl: 3 * 24 * 60 * 60 * 1000, // 3 days }, }, maxmind: { @@ -29,7 +29,8 @@ module.exports = { fetchSocketsCacheTTL: 1000, }, measurement: { - rateLimit: 300, + rateLimit: 100000, + rateLimitReset: 3600, maxInProgressProbes: 5, // Timeout after which measurement will be marked as finished even if not all probes respond timeout: 30, // 30 seconds diff --git a/config/test.cjs b/config/test.cjs index c0912512..d10d5eb3 100644 --- a/config/test.cjs +++ b/config/test.cjs @@ -7,6 +7,11 @@ module.exports = { admin: { key: 'admin', }, + geoip: { + cache: { + ttl: 1, // 1 ms ttl here to disable redis cache in tests + }, + }, ws: { fetchSocketsCacheTTL: 0, }, diff --git a/src/lib/cache/redis-cache.ts b/src/lib/cache/redis-cache.ts index df1177df..fcebd361 100644 --- a/src/lib/cache/redis-cache.ts +++ b/src/lib/cache/redis-cache.ts @@ -5,7 +5,7 @@ export default class RedisCache implements CacheInterface { constructor (private readonly redis: RedisClient) {} async set (key: string, value: unknown, ttl?: number): Promise { - await this.redis.set(this.buildCacheKey(key), JSON.stringify(value), { EX: ttl ? ttl / 1000 : 0 }); + await this.redis.set(this.buildCacheKey(key), JSON.stringify(value), { PX: ttl ?? 0 }); } async get (key: string): Promise { diff --git a/src/lib/geoip/client.ts b/src/lib/geoip/client.ts index 7c7473c1..cd6170e2 100644 --- a/src/lib/geoip/client.ts +++ b/src/lib/geoip/client.ts @@ -63,7 +63,7 @@ export default class GeoipClient { const resultsWithCities = results.filter(s => s.city); - if (resultsWithCities.length < 2 && resultsWithCities[0]?.provider === 'fastly') { + if (resultsWithCities.length === 0 || (resultsWithCities.length === 1 && resultsWithCities[0]?.provider === 'fastly')) { throw new InternalError(`unresolvable geoip: ${addr}`, true); } diff --git a/src/lib/geoip/providers/maxmind.ts b/src/lib/geoip/providers/maxmind.ts index 03bc5701..adfcf9bf 100644 --- a/src/lib/geoip/providers/maxmind.ts +++ b/src/lib/geoip/providers/maxmind.ts @@ -15,7 +15,8 @@ export const isMaxmindError = (error: unknown): error is WebServiceClientError = const query = async (addr: string, retryCounter = 0): Promise => { try { - return await client.city(addr); + const city = await client.city(addr); + return city; } catch (error: unknown) { if (isMaxmindError(error)) { if (error.code === 'SERVER_ERROR' && retryCounter < 3) { @@ -27,7 +28,7 @@ const query = async (addr: string, retryCounter = 0): Promise => { } } - throw new Error('no maxmind data'); + throw error; } }; diff --git a/src/lib/http/middleware/ratelimit.ts b/src/lib/http/middleware/ratelimit.ts index 75310b72..b05028e9 100644 --- a/src/lib/http/middleware/ratelimit.ts +++ b/src/lib/http/middleware/ratelimit.ts @@ -1,8 +1,9 @@ import type { Context, Next } from 'koa'; import requestIp from 'request-ip'; import type { RateLimiterRes } from 'rate-limiter-flexible'; - -import rateLimiter from '../../ratelimiter.js'; +import createHttpError from 'http-errors'; +import rateLimiter, { defaultState } from '../../ratelimiter.js'; +import type { MeasurementRequest } from '../../../measurement/types.js'; const setResponseHeaders = (ctx: Context, response: RateLimiterRes) => { ctx.set('X-RateLimit-Reset', `${Math.round(response.msBeforeNext / 1000)}`); @@ -10,24 +11,30 @@ const setResponseHeaders = (ctx: Context, response: RateLimiterRes) => { ctx.set('X-RateLimit-Remaining', `${response.remainingPoints}`); }; -const methodsWhitelist = new Set([ 'GET', 'HEAD', 'OPTIONS' ]); - export const rateLimitHandler = () => async (ctx: Context, next: Next) => { - const { method, isAdmin } = ctx; + const { isAdmin } = ctx; + const clientIp = requestIp.getClientIp(ctx.req) ?? ''; + const request = ctx.request.body as MeasurementRequest; + const limit = request.locations.some(l => l.limit) ? request.locations.reduce((sum, { limit = 1 }) => sum + limit, 0) : request.limit; - if (methodsWhitelist.has(method) || isAdmin) { + if (isAdmin) { return next(); } - try { - const response = await rateLimiter.consume(requestIp.getClientIp(ctx.req) ?? ''); - setResponseHeaders(ctx, response); - } catch (error: unknown) { // Ts requires 'unknown' for errors - setResponseHeaders(ctx, error as RateLimiterRes); - ctx.status = 429; - ctx.body = 'Too Many Requests'; - return; + const currentState = await rateLimiter.get(clientIp) ?? defaultState as RateLimiterRes; + + if (currentState.remainingPoints < limit) { + setResponseHeaders(ctx, currentState); + throw createHttpError(429, 'Too Many Probes Requested', { type: 'too_many_probes' }); } await next(); + const response = ctx.response.body as object & { probesCount?: number }; + + if (!('probesCount' in response) || typeof response.probesCount !== 'number') { + throw new Error('Missing probesCount field in response object'); + } + + const newState = await rateLimiter.penalty(clientIp, response.probesCount); + setResponseHeaders(ctx, newState); }; diff --git a/src/lib/http/server.ts b/src/lib/http/server.ts index e4549797..e091c192 100644 --- a/src/lib/http/server.ts +++ b/src/lib/http/server.ts @@ -15,7 +15,6 @@ import { registerGetMeasurementRoute } from '../../measurement/route/get-measure import { registerCreateMeasurementRoute } from '../../measurement/route/create-measurement.js'; import { registerHealthRoute } from '../../health/route/get.js'; import { errorHandler } from './error-handler.js'; -import { rateLimitHandler } from './middleware/ratelimit.js'; import { errorHandlerMw } from './middleware/error-handler.js'; import { corsHandler } from './middleware/cors.js'; import { isAdminMw } from './middleware/is-admin.js'; @@ -40,8 +39,7 @@ rootRouter.get('/', (ctx) => { const apiRouter = new Router({ strict: true, sensitive: true }); apiRouter.prefix('/v1') - .use(isAdminMw) - .use(rateLimitHandler()); + .use(isAdminMw); // POST /measurements registerCreateMeasurementRoute(apiRouter); diff --git a/src/lib/ratelimiter.ts b/src/lib/ratelimiter.ts index 8715eb26..f82871fd 100644 --- a/src/lib/ratelimiter.ts +++ b/src/lib/ratelimiter.ts @@ -4,11 +4,18 @@ import { createRedisClient } from './redis/client.js'; const redisClient = await createRedisClient({ legacyMode: true }); -export const rateLimiter = new RateLimiterRedis({ +export const defaultState = { + remainingPoints: config.get('measurement.rateLimit'), + msBeforeNext: config.get('measurement.rateLimitReset') * 1000, + consumedPoints: 0, + isFirstInDuration: true, +}; + +const rateLimiter = new RateLimiterRedis({ storeClient: redisClient, keyPrefix: 'rate', points: config.get('measurement.rateLimit'), - duration: 60, + duration: config.get('measurement.rateLimitReset'), }); export default rateLimiter; diff --git a/src/lib/redis/scripts.ts b/src/lib/redis/scripts.ts index 67886e36..ea841b69 100644 --- a/src/lib/redis/scripts.ts +++ b/src/lib/redis/scripts.ts @@ -4,8 +4,8 @@ import type { MeasurementRecord, MeasurementResultMessage } from '../../measurem type CountScript = { NUMBER_OF_KEYS: number; SCRIPT: string; - transformArguments (this: void, key: string): Array; - transformReply (this: void, reply: number): number; + transformArguments (key: string): Array; + transformReply (reply: number): number; } & { SHA1: string; }; @@ -13,8 +13,8 @@ type CountScript = { export type RecordResultScript = { NUMBER_OF_KEYS: number; SCRIPT: string; - transformArguments (this: void, measurementId: string, testId: string, data: MeasurementResultMessage['result']): string[]; - transformReply (this: void, reply: string): MeasurementRecord | null; + transformArguments (measurementId: string, testId: string, data: MeasurementResultMessage['result']): string[]; + transformReply (reply: string): MeasurementRecord | null; } & { SHA1: string; }; diff --git a/src/lib/ws/helper/error-handler.ts b/src/lib/ws/helper/error-handler.ts index e6151fd5..d6ca3833 100644 --- a/src/lib/ws/helper/error-handler.ts +++ b/src/lib/ws/helper/error-handler.ts @@ -19,7 +19,7 @@ type NextArgument = NextConnectArgument | NextMwArgument; const isError = (error: unknown): error is Error => Boolean(error as Error['message']); // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const errorHandler = (next: NextArgument) => (socket: Socket, mwNext?: (error?: any) => void | undefined) => { +export const errorHandler = (next: NextArgument) => (socket: Socket, mwNext?: (error?: any) => void) => { next(socket, mwNext!).catch((error) => { // eslint-disable-line @typescript-eslint/no-non-null-assertion const clientIp = getProbeIp(socket.request) ?? ''; const reason = isError(error) ? error.message : 'unknown'; diff --git a/src/lib/ws/helper/reconnect-probes.ts b/src/lib/ws/helper/reconnect-probes.ts index ae989ddb..89bf6be1 100644 --- a/src/lib/ws/helper/reconnect-probes.ts +++ b/src/lib/ws/helper/reconnect-probes.ts @@ -1,8 +1,8 @@ -import { fetchSockets } from '../server.js'; +import type { ThrottledFetchSockets } from '../server'; const TIME_TO_RECONNECT_PROBES = 60_000; -export const reconnectProbes = async () => { +export const reconnectProbes = async (fetchSockets: ThrottledFetchSockets) => { // passing fetchSockets in arguments to avoid cycle dependency const sockets = await fetchSockets(); for (const socket of sockets) { diff --git a/src/lib/ws/server.ts b/src/lib/ws/server.ts index 0de9945d..50c9cf1d 100644 --- a/src/lib/ws/server.ts +++ b/src/lib/ws/server.ts @@ -46,7 +46,7 @@ export const initWsServer = async () => { ); setTimeout(() => { - reconnectProbes().catch(error => logger.error(error)); + reconnectProbes(fetchSockets).catch(error => logger.error(error)); }, TIME_UNTIL_VM_BECOMES_HEALTHY); }; @@ -67,3 +67,5 @@ export const fetchSockets = async () => { return sockets; }; + +export type ThrottledFetchSockets = typeof fetchSockets; diff --git a/src/measurement/route/create-measurement.ts b/src/measurement/route/create-measurement.ts index f0d4a9ae..6f66adad 100644 --- a/src/measurement/route/create-measurement.ts +++ b/src/measurement/route/create-measurement.ts @@ -6,6 +6,7 @@ import type { MeasurementRequest } from '../types.js'; import { bodyParser } from '../../lib/http/middleware/body-parser.js'; import { validate } from '../../lib/http/middleware/validate.js'; import { schema } from '../schema/global-schema.js'; +import { rateLimitHandler } from '../../lib/http/middleware/ratelimit.js'; const hostConfig = config.get('server.host'); const runner = getMeasurementRunner(); @@ -24,5 +25,5 @@ const handle = async (ctx: Context): Promise => { }; export const registerCreateMeasurementRoute = (router: Router): void => { - router.post('/measurements', bodyParser(), validate(schema), handle); + router.post('/measurements', bodyParser(), validate(schema), rateLimitHandler(), handle); }; diff --git a/src/measurement/schema/location-schema.ts b/src/measurement/schema/location-schema.ts index eac65e80..2a18c0c0 100644 --- a/src/measurement/schema/location-schema.ts +++ b/src/measurement/schema/location-schema.ts @@ -29,5 +29,6 @@ export const schema = Joi.array().items(Joi.object().keys({ limit: Joi.number().min(1).max(measurementConfig.limits.location).when(Joi.ref('/limit'), { is: Joi.exist(), then: Joi.forbidden().messages({ 'any.unknown': 'limit per location is not allowed when a global limit is set' }), + otherwise: Joi.number().default(1), }), }).or('continent', 'region', 'country', 'state', 'city', 'network', 'asn', 'magic', 'tags')).default(GLOBAL_DEFAULTS.locations); diff --git a/src/probe/builder.ts b/src/probe/builder.ts index 40f33cb5..bf60a5e9 100644 --- a/src/probe/builder.ts +++ b/src/probe/builder.ts @@ -18,18 +18,20 @@ import getProbeIp from '../lib/get-probe-ip.js'; import { getRegion } from '../lib/ip-ranges.js'; import type { Probe, ProbeLocation, Tag } from './types.js'; -const fakeIpForDebug = () => _.sample([ - '18.200.0.1', // aws-eu-west-1 - '34.140.0.10', // gcp-europe-west1 - '95.155.94.127', - '65.49.22.66', - '185.229.226.83', - '51.158.22.211', - '131.255.7.26', - '213.136.174.80', - '94.214.253.78', - '79.205.97.254', -]); +const fakeIpForDebug = () => { + return _.sample([ + '18.200.0.1', // aws-eu-west-1 + '34.140.0.10', // gcp-europe-west1 + '95.155.94.127', + '65.49.22.66', + '185.229.226.83', + '51.158.22.211', + '131.255.7.26', + '213.136.174.80', + '94.214.253.78', + '79.205.97.254', + ]); +}; const geoipClient = createGeoipClient(); diff --git a/src/probe/handler/dns.ts b/src/probe/handler/dns.ts index d4817d31..00719ac1 100644 --- a/src/probe/handler/dns.ts +++ b/src/probe/handler/dns.ts @@ -1,4 +1,4 @@ -import type { Probe } from '../../probe/types.js'; +import type { Probe } from '../types.js'; export const handleDnsUpdate = (probe: Probe) => (list: string[]): void => { probe.resolvers = list; diff --git a/src/probe/handler/stats.ts b/src/probe/handler/stats.ts index c6bc4df5..39e8ab9d 100644 --- a/src/probe/handler/stats.ts +++ b/src/probe/handler/stats.ts @@ -1,7 +1,4 @@ -import type { - Probe, - ProbeStats, -} from '../../probe/types.js'; +import type { Probe, ProbeStats } from '../types.js'; export const handleStatsReport = (probe: Probe) => (report: ProbeStats): void => { probe.stats = report; diff --git a/src/probe/handler/status.ts b/src/probe/handler/status.ts index 282c610d..100abc4b 100644 --- a/src/probe/handler/status.ts +++ b/src/probe/handler/status.ts @@ -1,4 +1,4 @@ -import type { Probe } from '../../probe/types.js'; +import type { Probe } from '../types.js'; export const handleStatusUpdate = (probe: Probe) => (status: Probe['status']): void => { probe.status = status; diff --git a/test/mocks/redis-cache.ts b/test/mocks/redis-cache.ts deleted file mode 100644 index 779b8816..00000000 --- a/test/mocks/redis-cache.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class RedisCacheMock { - set = () => Promise.resolve(); - get = () => Promise.resolve(); - delete = () => Promise.resolve(); -} diff --git a/test/tests/integration/measurement/create-measurement.test.ts b/test/tests/integration/measurement/create-measurement.test.ts index f6e6fa95..a1c3609c 100644 --- a/test/tests/integration/measurement/create-measurement.test.ts +++ b/test/tests/integration/measurement/create-measurement.test.ts @@ -5,7 +5,6 @@ import request, { type SuperTest, type Test } from 'supertest'; import * as td from 'testdouble'; import nock from 'nock'; import type { Socket } from 'socket.io-client'; -import RedisCacheMock from '../../../mocks/redis-cache.js'; const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; @@ -16,7 +15,6 @@ describe('Create measurement', () => { let requestAgent: SuperTest; before(async () => { - await td.replaceEsm('../../../../src/lib/cache/redis-cache.ts', {}, RedisCacheMock); await td.replaceEsm('../../../../src/lib/ip-ranges.ts', { getRegion: () => 'gcp-us-west4', populateMemList: () => Promise.resolve() }); ({ getTestServer, addFakeProbe, deleteFakeProbe } = await import('../../../utils/server.js')); const app = await getTestServer(); diff --git a/test/tests/integration/measurement/probe-communication.test.ts b/test/tests/integration/measurement/probe-communication.test.ts index d2222b06..85b1fa9d 100644 --- a/test/tests/integration/measurement/probe-communication.test.ts +++ b/test/tests/integration/measurement/probe-communication.test.ts @@ -5,7 +5,6 @@ import nock from 'nock'; import type { Socket } from 'socket.io-client'; import * as sinon from 'sinon'; import { expect } from 'chai'; -import RedisCacheMock from '../../../mocks/redis-cache.js'; const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; @@ -22,7 +21,6 @@ describe('Create measurement request', () => { before(async () => { await td.replaceEsm('crypto-random-string', {}, cryptoRandomString); - await td.replaceEsm('../../../../src/lib/cache/redis-cache.ts', {}, RedisCacheMock); await td.replaceEsm('../../../../src/lib/ip-ranges.ts', { getRegion: () => 'gcp-us-west4', populateMemList: () => Promise.resolve() }); ({ getTestServer, addFakeProbe, deleteFakeProbe } = await import('../../../utils/server.js')); const app = await getTestServer(); diff --git a/test/tests/integration/measurement/timeout-result.test.ts b/test/tests/integration/measurement/timeout-result.test.ts index e2446029..659f80e6 100644 --- a/test/tests/integration/measurement/timeout-result.test.ts +++ b/test/tests/integration/measurement/timeout-result.test.ts @@ -5,7 +5,6 @@ import nock from 'nock'; import type { Socket } from 'socket.io-client'; import * as sinon from 'sinon'; import { expect } from 'chai'; -import RedisCacheMock from '../../../mocks/redis-cache.js'; const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; @@ -23,7 +22,6 @@ describe('Timeout results', () => { sandbox = sinon.createSandbox({ useFakeTimers: true }); await td.replaceEsm('@jcoreio/async-throttle', null, (f: any) => f); await td.replaceEsm('crypto-random-string', {}, cryptoRandomString); - await td.replaceEsm('../../../../src/lib/cache/redis-cache.ts', {}, RedisCacheMock); await td.replaceEsm('../../../../src/lib/ip-ranges.ts', { getRegion: () => 'gcp-us-west4', populateMemList: () => Promise.resolve() }); ({ getTestServer, addFakeProbe, deleteFakeProbe } = await import('../../../utils/server.js')); const app = await getTestServer(); diff --git a/test/tests/integration/middleware/compress.test.ts b/test/tests/integration/middleware/compress.test.ts index 16029dc5..97ec4485 100644 --- a/test/tests/integration/middleware/compress.test.ts +++ b/test/tests/integration/middleware/compress.test.ts @@ -1,26 +1,19 @@ import fs from 'node:fs'; import request, { type Response } from 'supertest'; import { expect } from 'chai'; -import * as td from 'testdouble'; import nock from 'nock'; import type { Socket } from 'socket.io-client'; -import RedisCacheMock from '../../../mocks/redis-cache.js'; +import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../../utils/server.js'; const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; describe('compression', () => { - let addFakeProbe: () => Promise; - let deleteFakeProbe: (socket: Socket) => Promise; let requestAgent: any; let probes: Socket[] = []; describe('headers', () => { before(async () => { - await td.replaceEsm('../../../../src/lib/cache/redis-cache.ts', {}, RedisCacheMock); - const http = await import('../../../utils/server.js'); - addFakeProbe = http.addFakeProbe; - deleteFakeProbe = http.deleteFakeProbe; - const app = await http.getTestServer(); + const app = await getTestServer(); requestAgent = request(app); }); @@ -39,7 +32,6 @@ describe('compression', () => { probe.emit('probe:status:update', 'ready'); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent .get('/v1/probes') .set('accept-encoding', '*') diff --git a/test/tests/integration/middleware/cors.test.ts b/test/tests/integration/middleware/cors.test.ts index 47051665..d7fe9de1 100644 --- a/test/tests/integration/middleware/cors.test.ts +++ b/test/tests/integration/middleware/cors.test.ts @@ -15,14 +15,12 @@ describe('cors', () => { describe('Access-Control-Allow-Origin header', () => { it('should include the header with value of *', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent.get('/v1/').set('Origin', 'elocast.com').send() as Response; expect(response.headers['access-control-allow-origin']).to.equal('*'); }); it('should include the header at root', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent.get('/').send() as Response; expect(response.headers['access-control-allow-origin']).to.equal('*'); diff --git a/test/tests/integration/middleware/domain-redirect.test.ts b/test/tests/integration/middleware/domain-redirect.test.ts index 3d1da517..81cbd01c 100644 --- a/test/tests/integration/middleware/domain-redirect.test.ts +++ b/test/tests/integration/middleware/domain-redirect.test.ts @@ -15,7 +15,6 @@ describe('domain redirect', () => { describe('http requests', () => { it('should be redirected to "https://jsdelivr.com/globalping" if Host is "globalping.io"', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call await requestAgent .get('/v1/probes') .send() @@ -27,7 +26,6 @@ describe('domain redirect', () => { }); it('should not be redirected to if Host is not "globalping.io"', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call await requestAgent .get('/v1/probes') .send() diff --git a/test/tests/integration/middleware/etag.test.ts b/test/tests/integration/middleware/etag.test.ts index 4487cfe3..aa514b11 100644 --- a/test/tests/integration/middleware/etag.test.ts +++ b/test/tests/integration/middleware/etag.test.ts @@ -15,7 +15,6 @@ describe('etag', () => { describe('ETag header', () => { it('should include the header', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent.get('/v1/probes').send() as Response; expect(response.headers.etag).to.exist; @@ -24,7 +23,6 @@ describe('etag', () => { describe('conditional get', () => { it('should redirect to cache', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent .get('/v1/probes') .set('if-none-match', 'W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w"') diff --git a/test/tests/integration/middleware/ratelimit.test.ts b/test/tests/integration/middleware/ratelimit.test.ts index e1c95447..8acb4b16 100644 --- a/test/tests/integration/middleware/ratelimit.test.ts +++ b/test/tests/integration/middleware/ratelimit.test.ts @@ -1,21 +1,26 @@ +import fs from 'node:fs'; import type { Server } from 'node:http'; import request, { type Response } from 'supertest'; import requestIp from 'request-ip'; import type { RateLimiterRedis } from 'rate-limiter-flexible'; import { expect } from 'chai'; -import { getTestServer } from '../../../utils/server.js'; +import nock from 'nock'; +import type { Socket } from 'socket.io-client'; +import { getTestServer, addFakeProbe, deleteFakeProbe } from '../../../utils/server.js'; + +const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; describe('rate limiter', () => { let app: Server; let requestAgent: any; let clientIpv6: string; let rateLimiterInstance: RateLimiterRedis; + let probe: Socket; before(async () => { app = await getTestServer(); requestAgent = request(app); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const httpResponse = await requestAgent.post('/v1/').send() as Response & {req: any}; // Supertest renders request as ipv4 const clientIp = requestIp.getClientIp(httpResponse.req); @@ -24,15 +29,25 @@ describe('rate limiter', () => { const rateLimiter = await import('../../../../src/lib/ratelimiter.js'); rateLimiterInstance = rateLimiter.default; + + nock('https://globalping-geoip.global.ssl.fastly.net').get(/.*/).reply(200, nockMocks['01.00'].fastly); + nock('https://ipinfo.io').get(/.*/).reply(200, nockMocks['01.00'].ipinfo); + nock('https://geoip.maxmind.com/geoip/v2.1/city/').get(/.*/).reply(200, nockMocks['01.00'].maxmind); + probe = await addFakeProbe(); + probe.emit('probe:status:update', 'ready'); }); + afterEach(async () => { await rateLimiterInstance.delete(clientIpv6); }); + after(async () => { + await deleteFakeProbe(probe); + }); + describe('headers', () => { it('should NOT include headers (GET)', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent.get('/v1/').send() as Response; expect(response.headers['x-ratelimit-limit']).to.not.exist; @@ -40,25 +55,43 @@ describe('rate limiter', () => { expect(response.headers['x-ratelimit-reset']).to.not.exist; }); - it('should include headers (POST)', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + it('should NOT include headers if body is not valid (POST)', async () => { const response = await requestAgent.post('/v1/measurements').send() as Response; + expect(response.headers['x-ratelimit-limit']).to.not.exist; + expect(response.headers['x-ratelimit-remaining']).to.not.exist; + expect(response.headers['x-ratelimit-reset']).to.not.exist; + }); + + it('should include headers (POST)', async () => { + const response = await requestAgent.post('/v1/measurements').send({ + type: 'ping', + target: 'jsdelivr.com', + }) as Response; + expect(response.headers['x-ratelimit-limit']).to.exist; expect(response.headers['x-ratelimit-remaining']).to.exist; expect(response.headers['x-ratelimit-reset']).to.exist; }); - it('should change values on next request (5) (POST)', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const requestPromise = () => requestAgent.post('/v1/measurements').send() as Response; - const responseList = await Promise.all(Array.from({ length: 5 }).map(() => requestPromise())); + it('should change values on multiple requests (POST)', async () => { + const response = await requestAgent.post('/v1/measurements').send({ + type: 'ping', + target: 'jsdelivr.com', + }) as Response; + + expect(response.headers['x-ratelimit-limit']).to.equal('100000'); + expect(response.headers['x-ratelimit-remaining']).to.equal('99999'); + expect(response.headers['x-ratelimit-reset']).to.equal('3600'); - const firstResponse = responseList[0]; - const lastResponse = responseList[4]; + const response2 = await requestAgent.post('/v1/measurements').send({ + type: 'ping', + target: 'jsdelivr.com', + }) as Response; - expect(responseList).to.have.lengthOf(5); - expect(firstResponse?.headers?.['x-ratelimit-remaining']).to.not.equal(lastResponse?.headers?.['x-ratelimit-remaining']); + expect(response2.headers['x-ratelimit-limit']).to.equal('100000'); + expect(response2.headers['x-ratelimit-remaining']).to.equal('99998'); + expect(response2.headers['x-ratelimit-reset']).to.equal('3600'); }); }); @@ -66,17 +99,21 @@ describe('rate limiter', () => { it('should succeed (limit not reached)', async () => { await rateLimiterInstance.set(clientIpv6, 0, 0); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const response = await requestAgent.post('/v1/measurements').send() as Response; + const response = await requestAgent.post('/v1/measurements').send({ + type: 'ping', + target: 'jsdelivr.com', + }) as Response; - expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(299); + expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(99999); }); it('should fail (limit reached) (start at 100)', async () => { - await rateLimiterInstance.set(clientIpv6, 300, 0); + await rateLimiterInstance.set(clientIpv6, 100000, 0); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const response = await requestAgent.post('/v1/measurements').send() as Response; + const response = await requestAgent.post('/v1/measurements').send({ + type: 'ping', + target: 'jsdelivr.com', + }) as Response; expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(0); expect(response.statusCode).to.equal(429); diff --git a/test/tests/integration/middleware/responsetime.test.ts b/test/tests/integration/middleware/responsetime.test.ts index 70826606..2d0937dc 100644 --- a/test/tests/integration/middleware/responsetime.test.ts +++ b/test/tests/integration/middleware/responsetime.test.ts @@ -16,7 +16,6 @@ describe('response time', () => { describe('X-Response-Time header', () => { describe('should include the header', (): void => { it('should succeed', async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const response = await requestAgent.get('/v1/').send() as Response; expect(response.headers['x-response-time']).to.exist; diff --git a/test/tests/integration/probes/get-probes.test.ts b/test/tests/integration/probes/get-probes.test.ts index 1a1def5b..409d06cb 100644 --- a/test/tests/integration/probes/get-probes.test.ts +++ b/test/tests/integration/probes/get-probes.test.ts @@ -5,28 +5,23 @@ import { expect } from 'chai'; import request, { type SuperTest, type Test } from 'supertest'; import * as td from 'testdouble'; import type { Socket } from 'socket.io-client'; -import RedisCacheMock from '../../../mocks/redis-cache.js'; +import { getTestServer, addFakeProbe as addProbe, deleteFakeProbe } from '../../../utils/server.js'; const nockMocks = JSON.parse(fs.readFileSync('./test/mocks/nock-geoip.json').toString()) as Record; describe('Get Probes', () => { - let addFakeProbe: () => Promise; - let deleteFakeProbe: (socket: Socket) => Promise; let requestAgent: SuperTest; + let addFakeProbe: () => Promise; const probes: Socket[] = []; before(async () => { - await td.replaceEsm('../../../../src/lib/cache/redis-cache.ts', {}, RedisCacheMock); - const http = await import('../../../utils/server.js'); - deleteFakeProbe = http.deleteFakeProbe; - addFakeProbe = async () => { - const probe = await http.addFakeProbe(); + const probe = await addProbe(); probes.push(probe); return probe; }; - const app = await http.getTestServer(); + const app = await getTestServer(); requestAgent = request(app); }); diff --git a/test/tests/unit/middleware/ratelimit.test.ts b/test/tests/unit/middleware/ratelimit.test.ts new file mode 100644 index 00000000..e6a0ff30 --- /dev/null +++ b/test/tests/unit/middleware/ratelimit.test.ts @@ -0,0 +1,159 @@ +import * as sinon from 'sinon'; +import { expect } from 'chai'; +import rateLimiter from '../../../../src/lib/ratelimiter.js'; +import { rateLimitHandler } from '../../../../src/lib/http/middleware/ratelimit.js'; +import createHttpError from 'http-errors'; + +describe('rate limit middleware', () => { + const defaultCtx: any = { + set: sinon.stub(), + req: {}, + request: { + body: {}, + }, + response: {}, + }; + let ctx = { ...defaultCtx }; + + beforeEach(async () => { + defaultCtx.set.reset(); + ctx = { ...defaultCtx }; + await rateLimiter.delete(''); + }); + + it('should set rate limit headers based on the "probesCount" field value', async () => { + ctx.request.body = { + limit: 10, + locations: [], + }; + + const next: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 5, + }; + }; + + await rateLimitHandler()(ctx, next); + + expect(ctx.set.callCount).to.equal(3); + expect(ctx.set.firstCall.args[0]).to.equal('X-RateLimit-Reset'); + expect(ctx.set.secondCall.args).to.deep.equal([ 'X-RateLimit-Limit', '100000' ]); + expect(ctx.set.thirdCall.args).to.deep.equal([ 'X-RateLimit-Remaining', '99995' ]); + }); + + it('should throw an error if response body doesn\'t have "probesCount" field', async () => { + ctx.request.body = { + limit: 10, + locations: [], + }; + + const next: any = () => { + ctx.response.body = {}; + }; + + const err = await rateLimitHandler()(ctx, next).catch(err => err); + expect(err).to.deep.equal(new Error('Missing probesCount field in response object')); + }); + + it('should NOT set rate limit headers for admin', async () => { + ctx.request.body = { + limit: 10, + locations: [], + }; + + ctx.isAdmin = true; + + const next: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 10, + }; + }; + + await rateLimitHandler()(ctx, next); + expect(ctx.set.callCount).to.equal(0); + }); + + it('should validate request based on the "limit" field value', async () => { + ctx.request.body = { + limit: 60000, + locations: [], + }; + + const next: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 60000, + }; + }; + + await rateLimitHandler()(ctx, next); + expect(ctx.set.args[2]).to.deep.equal([ 'X-RateLimit-Remaining', '40000' ]); + + const err = await rateLimitHandler()(ctx, next).catch(err => err); // 60000 > 40000 so another request with the same body fails + expect(err).to.deep.equal(createHttpError(429, 'Too Many Probes Requested', { type: 'too_many_probes' })); + expect(ctx.set.args[5]).to.deep.equal([ 'X-RateLimit-Remaining', '40000' ]); + + ctx.request.body = { + limit: 40000, + locations: [], + }; + + const next2: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 40000, + }; + }; + + await rateLimitHandler()(ctx, next2); // 40000 === 40000 so request with the updated body works + expect(ctx.set.args[8]).to.deep.equal([ 'X-RateLimit-Remaining', '0' ]); + }); + + it('should validate request based on the "location.limit" field value', async () => { + ctx.request.body = { + locations: [{ + continent: 'EU', + limit: 45000, + }, { + continent: 'NA', + limit: 45000, + }], + }; + + const next: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 90000, + }; + }; + + await rateLimitHandler()(ctx, next); + expect(ctx.set.args[2]).to.deep.equal([ 'X-RateLimit-Remaining', '10000' ]); + + const err = await rateLimitHandler()(ctx, next).catch(err => err); // only 10000 points remaining so another request with the same body fails + expect(err).to.deep.equal(createHttpError(429, 'Too Many Probes Requested', { type: 'too_many_probes' })); + expect(ctx.set.args[5]).to.deep.equal([ 'X-RateLimit-Remaining', '10000' ]); + + ctx.request.body = { + locations: [{ + continent: 'EU', + limit: 5000, + }, { + continent: 'NA', + limit: 5000, + }], + }; + + const next2: any = () => { + ctx.response.body = { + id: 'id', + probesCount: 10000, + }; + }; + + await rateLimitHandler()(ctx, next2); // request with 10000 probes will work fine + expect(ctx.set.args[8]).to.deep.equal([ 'X-RateLimit-Remaining', '0' ]); + }); +}); diff --git a/test/utils/server.ts b/test/utils/server.ts index af36b9f4..edec9f5d 100644 --- a/test/utils/server.ts +++ b/test/utils/server.ts @@ -2,6 +2,7 @@ import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { io, type Socket } from 'socket.io-client'; import { createServer } from '../../src/lib/server.js'; +import { getRedisClient } from '../../src/lib/redis/client.js'; let app: Server; let url: string; @@ -12,6 +13,8 @@ export const getTestServer = async (): Promise => { app.listen(0); const { port } = app.address() as AddressInfo; url = `http://127.0.0.1:${port}/probes`; + const redis = getRedisClient(); + await redis.flushDb(); } return app;