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

feat: Adds rate limiting to metric POST endpoints #5075

Merged
merged 5 commits into from
Oct 18, 2023
Merged
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
6 changes: 6 additions & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ exports[`should create default config 1`] = `
"host": undefined,
"port": 4242,
},
"metricsRateLimiting": {
"clientMetricsMax": 6000,
"clientRegisterMax": 6000,
"frontendMetricsMax": 6000,
"frontendRegisterMax": 6000,
},
"preHook": undefined,
"preRouterHook": undefined,
"prometheusApi": undefined,
Expand Down
36 changes: 36 additions & 0 deletions src/lib/create-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ICspDomainConfig,
ICspDomainOptions,
IClientCachingOption,
IMetricsRateLimiting,
} from './types/option';
import { getDefaultLogProvider, LogLevel, validateLogProvider } from './logger';
import { defaultCustomAuthDenyAll } from './default-custom-auth-deny-all';
Expand Down Expand Up @@ -99,6 +100,38 @@ function loadClientCachingOptions(
]);
}

function loadMetricsRateLimitingConfig(
options: IUnleashOptions,
): IMetricsRateLimiting {
const clientMetricsMaxPerMinute = parseEnvVarNumber(
process.env.REGISTER_CLIENT_RATE_LIMIT_PER_MINUTE,
6000,
);
const clientRegisterMaxPerMinute = parseEnvVarNumber(
process.env.CLIENT_METRICS_RATE_LIMIT_PER_MINUTE,
6000,
);
const frontendRegisterMaxPerMinute = parseEnvVarNumber(
process.env.REGISTER_FRONTEND_RATE_LIMIT_PER_MINUTE,
6000,
);
const frontendMetricsMaxPerMinute = parseEnvVarNumber(
process.env.FRONTEND_METRICS_RATE_LIMIT_PER_MINUTE,
6000,
);
const defaultRateLimitOptions: IMetricsRateLimiting = {
clientMetricsMaxPerMinute: clientMetricsMaxPerMinute,
clientRegisterMaxPerMinute: clientRegisterMaxPerMinute,
frontendRegisterMaxPerMinute: frontendRegisterMaxPerMinute,
frontendMetricsMaxPerMinute: frontendMetricsMaxPerMinute,
};

return mergeAll([
defaultRateLimitOptions,
options.metricsRateLimiting ?? {},
]);
}

function loadUI(options: IUnleashOptions): IUIConfig {
const uiO = options.ui || {};
const ui: IUIConfig = {
Expand Down Expand Up @@ -490,6 +523,8 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
Boolean(options.enterpriseVersion) &&
ui.environment?.toLowerCase() !== 'pro';

const metricsRateLimiting = loadMetricsRateLimitingConfig(options);

return {
db,
session,
Expand Down Expand Up @@ -523,6 +558,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
publicFolder: options.publicFolder,
disableScheduler: options.disableScheduler,
isEnterprise: isEnterprise,
metricsRateLimiting,
};
}

Expand Down
9 changes: 9 additions & 0 deletions src/lib/routes/client-api/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';

export default class ClientMetricsController extends Controller {
logger: Logger;
Expand Down Expand Up @@ -61,6 +63,13 @@ export default class ClientMetricsController extends Controller {
204: emptyResponse,
},
}),
rateLimit({
windowMs: minutesToMilliseconds(1),
max: config.metricsRateLimiting.clientMetricsMaxPerMinute,
validate: false,
standardHeaders: true,
legacyHeaders: false,
}),
],
});
}
Expand Down
9 changes: 9 additions & 0 deletions src/lib/routes/client-api/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { OpenApiService } from '../../services/openapi-service';
import { emptyResponse } from '../../openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { ClientApplicationSchema } from '../../openapi/spec/client-application-schema';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';

export default class RegisterController extends Controller {
logger: Logger;
Expand Down Expand Up @@ -48,6 +50,13 @@ export default class RegisterController extends Controller {
requestBody: createRequestSchema('clientApplicationSchema'),
responses: { 202: emptyResponse },
}),
rateLimit({
windowMs: minutesToMilliseconds(1),
max: config.metricsRateLimiting.clientRegisterMaxPerMinute,
validate: false,
standardHeaders: true,
legacyHeaders: false,
}),
],
});
}
Expand Down
17 changes: 17 additions & 0 deletions src/lib/routes/proxy-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { enrichContextWithIp } from '../../proxy';
import { corsOriginMiddleware } from '../../middleware';
import NotImplementedError from '../../error/not-implemented-error';
import NotFoundError from '../../error/notfound-error';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';

interface ApiUserRequest<
PARAM = any,
Expand Down Expand Up @@ -112,6 +114,13 @@ export default class ProxyController extends Controller {
...getStandardResponses(400, 401, 404),
},
}),
rateLimit({
windowMs: minutesToMilliseconds(1),
max: config.metricsRateLimiting.frontendMetricsMaxPerMinute,
validate: false,
standardHeaders: true,
legacyHeaders: false,
}),
],
});

Expand All @@ -133,6 +142,14 @@ export default class ProxyController extends Controller {
...getStandardResponses(400, 401, 404),
},
}),
rateLimit({
windowMs: minutesToMilliseconds(1),
max: config.metricsRateLimiting
.frontendRegisterMaxPerMinute,
validate: false,
standardHeaders: true,
legacyHeaders: false,
}),
],
});

Expand Down
14 changes: 6 additions & 8 deletions src/lib/routes/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,12 @@ export const handleErrors: (
error: Error,
) => void = (res, logger, error) => {
if (createError.isHttpError(error)) {
return (
res
// @ts-expect-error http errors all have statuses, but there are no
// types provided
.status(error.status ?? 400)
.json({ message: error.message })
.end()
);
return res
.status(
// @ts-expect-error - The error object here is not guaranteed to contain status
error.status ?? 400,
)
.json({ message: error.message });
}

const finalError =
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface IUnleashOptions {
prometheusApi?: string;
publicFolder?: string;
disableScheduler?: boolean;
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
}

export interface IEmailOption {
Expand Down Expand Up @@ -185,6 +186,13 @@ interface IFrontendApi {
refreshIntervalInMs: number;
}

export interface IMetricsRateLimiting {
clientMetricsMaxPerMinute: number;
clientRegisterMaxPerMinute: number;
frontendMetricsMaxPerMinute: number;
frontendRegisterMaxPerMinute: number;
}

export interface IUnleashConfig {
db: IDBOption;
session: ISessionOption;
Expand Down Expand Up @@ -212,6 +220,7 @@ export interface IUnleashConfig {
inlineSegmentConstraints: boolean;
segmentValuesLimit: number;
strategySegmentsLimit: number;
metricsRateLimiting: IMetricsRateLimiting;
clientFeatureCaching: IClientCachingOption;
accessControlMaxAge: number;
prometheusApi?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/util/db-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test('should await other actions on lock', async () => {
await ms(100); // start fast action after slow action established DB connection
await lockedAnotherAction('second');

await expect(results).toStrictEqual(['first', 'second']);
expect(results).toStrictEqual(['first', 'second']);
});

test('should handle lock timeout', async () => {
Expand Down
6 changes: 5 additions & 1 deletion website/docs/reference/deploy/configuring-unleash.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ unleash.start(unleashOptions);
- **responseTimeWithAppNameKillSwitch** - use this to disable metrics with app names. This is enabled by default but may increase the cardinality of metrics causing Unleash memory usage to grow if your app name is randomly generated (which is not recommended). Overridable with the `UNLEASH_RESPONSE_TIME_WITH_APP_NAME_KILL_SWITCH` environment variable.
- **keepAliveTimeout** - Use this to tweak connection keepalive timeout in seconds. Useful for hosted situations where you need to make sure your connections are closed before terminating the instance. Defaults to `15`. Overridable with the `SERVER_KEEPALIVE_TIMEOUT` environment variable.
You can also set the environment variable `ENABLED_ENVIRONMENTS` to a comma delimited string of environment names to override environments.

- **metricsRateLimiting** - Use the following to tweak the rate limits for `/api/client/register`, `/api/client/metrics`, `/api/frontend/register` and `/api/frontend/metrics` POST endpoints
- `clientMetricsMaxPerMinute` - How many requests per minute is allowed against POST `/api/client/metrics` before returning 429. Set to 6000 by default (100rps) - Overridable with `REGISTER_CLIENT_RATE_LIMIT_PER_MINUTE` environment variable
- `clientRegisterMaxPerMinute` - How many requests per minute is allowed against POST `/api/client/register` before returning 429. Set to 6000 by default (100rps) - Overridable with `CLIENT_METRICS_RATE_LIMIT_PER_MINUTE` environment variable
- `frontendMetricsMaxPerMinute` - How many requests per minute is allowed against POST `/api/frontend/metrics` before returning 429. Set to 6000 by default (100rps) - Overridable with `FRONTEND_METRICS_RATE_LIMIT_PER_MINUTE` environment variable
- `frontendRegisterMaxPerMinute` - How many requests per minute is allowed against POST `/api/frontend/register` before returning 429. Set to 6000 by default (100rps) - Overridable with `REGISTER_FRONTEND_RATE_LIMIT_PER_MINUTE` environment variable
### Disabling Auto-Start {#disabling-auto-start}

If you're using Unleash as part of a larger express app, you can disable the automatic server start by calling `server.create`. It takes the same options as `server.start`, but will not begin listening for connections.
Expand Down
Loading