Skip to content

Commit

Permalink
Added RateLimit middleware (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
petruki committed Dec 17, 2023
1 parent 2761d2c commit 0cddcf0
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 11 deletions.
4 changes: 3 additions & 1 deletion .env.dev
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
APP_PORT=4000
APP_RATE_LIMIT=100
APP_RATE_LIMIT_WINDOW=3000
LOG_LEVEL=INFO # DEBUG, INFO, ERROR
# SSL_CERT=/data/certs/local/tls.crt
# SSL_KEY=/data/certs/local/tls.pem

# Switcher API
SWITCHER_URL=https://switcherapi.com/api
SWITCHER_URL=https://api.switcherapi.com
SWITCHER_API_KEY=
SWITCHER_ENVIRONMENT=test
SWITCHER_OFFLINE=true
Expand Down
4 changes: 3 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
APP_PORT=4000
APP_RATE_LIMIT=100
APP_RATE_LIMIT_WINDOW=3000
LOG_LEVEL=INFO # DEBUG, INFO, ERROR

# Switcher API
SWITCHER_URL=https://switcherapi.com/api
SWITCHER_URL=https://api.switcherapi.com
SWITCHER_API_KEY=
SWITCHER_ENVIRONMENT=test
SWITCHER_OFFLINE=true
Expand Down
44 changes: 41 additions & 3 deletions src/api-docs/paths/path-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,53 @@ export default {
properties: {
status: {
type: 'string',
example: 'ok',
enum: ['ok'],
},
releaseTime: {
type: 'string',
example: 'today',
description: 'Release time of the API.',
},
sslEnabled: {
type: 'boolean',
example: true,
description: 'Is SSL enabled?',
},
rateLimit: {
type: 'object',
properties: {
window: {
type: 'string',
description: 'Rate limit window.',
},
max: {
type: 'string',
description: 'Rate limit max.',
},
},
},
switcherSettings: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'Switcher API URL.',
},
environment: {
type: 'string',
description: 'Switcher environment.',
},
offline: {
type: 'string',
description: 'Switcher offline.',
},
snapshotAutoLoad: {
type: 'string',
description: 'Switcher snapshot auto load.',
},
snapshotUpdateInterval: {
type: 'string',
description: 'Switcher snapshot update interval.',
},
},
},
},
},
Expand Down
4 changes: 2 additions & 2 deletions src/api-docs/swagger-document.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import info from './swagger-info.ts';
import pathApi from './paths/path-api.ts';
import pathFeature from './paths/path-feature.ts';
import featureScehma from './schemas/feature.ts';
import featureSchema from './schemas/feature.ts';

export default {
openapi: '3.0.1',
Expand All @@ -18,7 +18,7 @@ export default {
],
components: {
schemas: {
...featureScehma,
...featureSchema,
},
},
paths: {
Expand Down
13 changes: 11 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { Application, load } from './deps.ts';
import { responseTime, responseTimeLog } from './middleware/index.ts';

await load({ export: true, envPath: getEnv('ENV_PATH', '.env') });

import Helmet from './middleware/helmet.ts';
import { getEnv } from './utils.ts';
import routerApi from './routes/api.ts';
import routerFeature from './routes/feature.ts';
import routerApidocs from './routes/api-docs.ts';
import RateLimit from './middleware/rate-limit.ts';
import FeatureService from './services/feature.ts';

await load({ export: true, envPath: getEnv('ENV_PATH', '.env') });

const app = new Application();
const rateLimit = new RateLimit();
const helmet = new Helmet();
const service = new FeatureService();

await service.initialize(getEnv('SWITCHER_SNAPSHOT_LOAD', true));

app.use(helmet.middleware());
app.use(rateLimit.middleware({
limit: Number(getEnv('APP_RATE_LIMIT', '1000')),
windowMs: Number(getEnv('APP_RATE_LIMIT_WINDOW', '60000')),
}));

app.use(responseTimeLog);
app.use(responseTime);
app.use(routerApi.routes());
Expand Down
79 changes: 79 additions & 0 deletions src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Context, Middleware, Next } from '../deps.ts';
import { responseError } from '../utils.ts';

class RequestStore {
private count: number;
private timestamp: number;

constructor(count: number, timestamp: number) {
this.count = count;
this.timestamp = timestamp;
}

public getCount(): number {
return this.count;
}

public getTimestamp(): number {
return this.timestamp;
}
}

interface RateLimitParams {
limit: number;
windowMs: number;
}

export default class RateLimit {
private map: Map<string, RequestStore>;

constructor() {
this.map = new Map();
}

public middleware(params: RateLimitParams): Middleware {
const { limit, windowMs } = params;

return async (context: Context, next: Next) => {
const ip = context.request.ip;
const timestamp = Date.now();

if (!this.map.has(ip)) {
this.updateRate(context, limit, limit - 1, windowMs, ip, 1, timestamp);
return await next();
}

const requestStore = this.map.get(ip) as RequestStore;
const diff = timestamp - requestStore.getTimestamp();

if (diff > windowMs) {
this.updateRate(context, limit, limit - 1, windowMs, ip, 1, timestamp);
return await next();
}

const count = requestStore.getCount() + 1;
if (count > limit) {
return responseError(context, new Error('Too many requests'), 429);
}

this.updateRate(context, limit, limit - count, windowMs, ip, count, timestamp);
return await next();
};
}

private updateRate(
context: Context,
limit: number,
remaining: number,
reset: number,
ip: string,
count: number,
timestamp: number,
) {
context.response.headers.set('X-RateLimit-Limit', limit.toString());
context.response.headers.set('X-RateLimit-Remaining', remaining.toString());
context.response.headers.set('X-RateLimit-Reset', reset.toString());

this.map.set(ip, new RequestStore(count, timestamp));
}
}
11 changes: 11 additions & 0 deletions src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ router.get('/api/check', ({ response }: Context) => {
status: 'ok',
releaseTime: getEnv('RELEASE_TIME', 'today'),
sslEnabled: Deno.env.has('SSL_CERT') && Deno.env.has('SSL_KEY'),
rateLimit: {
window: getEnv('APP_RATE_LIMIT_WINDOW', 'not set'),
max: getEnv('APP_RATE_LIMIT', 'not set'),
},
switcherSettings: {
url: getEnv('SWITCHER_URL', 'not set'),
environment: getEnv('SWITCHER_ENVIRONMENT', 'not set'),
offline: getEnv('SWITCHER_OFFLINE', 'not set'),
snapshotAutoLoad: getEnv('SWITCHER_SNAPSHOT_LOAD', 'not set'),
snapshotUpdateInterval: getEnv('SWITCHER_SNAPSHOT_UPDATE_INTERVAL', 'not set'),
},
};
});

Expand Down
49 changes: 49 additions & 0 deletions test/middleware/helmet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Helmet from '../../src/middleware/helmet.ts';
import { Context, Next } from '../../src/deps.ts';
import { assertEquals } from '../deps.ts';

const testTitle = (description: string) => `Helmet middleware - ${description}`;

const newRequest = () => {
return {
request: {
ip: 'localhost',
},
response: {
status: 0,
body: {},
headers: new Headers(),
},
};
};

Deno.test({
name: testTitle('it should set the headers'),
async fn() {
const helmet = new Helmet();
const middleware = helmet.middleware();

const next = () => {
return;
};

const req = newRequest();
await middleware(req as Context, next as Next);

assertEquals(req.response.headers.get('Server'), 'Deno');
assertEquals(req.response.headers.get('Access-Control-Allow-Origin'), '*');
assertEquals(
req.response.headers.get('Content-Security-Policy'),
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: https: http:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none';",
);
assertEquals(req.response.headers.get('X-Content-Type-Options'), 'nosniff');
assertEquals(req.response.headers.get('X-Frame-Options'), 'DENY');
assertEquals(req.response.headers.get('X-XSS-Protection'), '1; mode=block');
assertEquals(req.response.headers.get('Strict-Transport-Security'), 'max-age=31536000; includeSubDomains; preload');
assertEquals(req.response.headers.get('Referrer-Policy'), 'no-referrer');
assertEquals(
req.response.headers.get('Permissions-Policy'),
'geolocation=(), microphone=(), camera=(), payment=()',
);
},
});
73 changes: 73 additions & 0 deletions test/middleware/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import RateLimit from '../../src/middleware/rate-limit.ts';
import { Context, Next } from '../../src/deps.ts';
import { assertEquals } from '../deps.ts';

const testTitle = (description: string) => `RateLimit middleware - ${description}`;

const newRequest = () => {
return {
request: {
ip: 'localhost',
},
response: {
status: 0,
body: {},
headers: new Headers(),
},
};
};

Deno.test({
name: testTitle('it should return 429 error when limit is exceeded'),
async fn() {
const rateLimit = new RateLimit();
const middleware = rateLimit.middleware({
limit: 1,
windowMs: 1000,
});

const next = () => {
return;
};

const req1 = newRequest();
await middleware(req1 as Context, next as Next);
assertEquals(req1.response.status, 0);
assertEquals(req1.response.headers.get('X-RateLimit-Limit'), '1');
assertEquals(req1.response.headers.get('X-RateLimit-Remaining'), '0');

const req2 = newRequest();
await middleware(req2 as Context, next as Next);
assertEquals(req2.response.status, 429);
assertEquals(req2.response.body, { error: 'Too many requests' });
},
});

Deno.test({
name: testTitle('it should reset counter after windowMs'),
async fn() {
const rateLimit = new RateLimit();
const middleware = rateLimit.middleware({
limit: 1,
windowMs: 500,
});

const next = () => {
return;
};

const req1 = newRequest();
await middleware(req1 as Context, next as Next);
assertEquals(req1.response.status, 0);

const req2 = newRequest();
await middleware(req2 as Context, next as Next);
assertEquals(req2.response.status, 429);
assertEquals(req2.response.body, { error: 'Too many requests' });

await new Promise((resolve) => setTimeout(resolve, 1000));
const req3 = newRequest();
await middleware(req3 as Context, next as Next);
assertEquals(req3.response.status, 0);
},
});
18 changes: 16 additions & 2 deletions test/routes/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,22 @@ Deno.test({
const request = await superoak(app);
const response = await request.get('/api/check').expect(200);

assertEquals(response.body.status, 'ok');
assert(!response.body.sslEnabled);
assertEquals(response.body, {
status: 'ok',
releaseTime: 'today',
sslEnabled: false,
rateLimit: {
max: '100',
window: '3000',
},
switcherSettings: {
url: 'https://api.switcherapi.com',
environment: "test",
offline: "true",
snapshotAutoLoad: "false",
snapshotUpdateInterval: "not set"
},
});
},
});

Expand Down

0 comments on commit 0cddcf0

Please sign in to comment.