Skip to content
Open
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,9 @@ ENABLE_GRAPHQL=false
ENABLE_SEARCH=true

# Infrastructure
ENABLE_RATE_LIMITING=true
# Rate limiting is ENABLED by default. Set DISABLE_RATE_LIMITING=true to disable it.
# WARNING: Disabling rate limiting exposes the API to DoS and credential-stuffing attacks.
DISABLE_RATE_LIMITING=false
ENABLE_OBSERVABILITY=true
ENABLE_CACHING=true
ENABLE_SECURITY=true
Expand Down
3 changes: 2 additions & 1 deletion .env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ ENABLE_BACKUP=true
ENABLE_GRAPHQL=false
ENABLE_SYNC=true
ENABLE_MIGRATIONS=true
ENABLE_RATE_LIMITING=true
# Rate limiting is ENABLED by default. Set DISABLE_RATE_LIMITING=true to disable.
# DISABLE_RATE_LIMITING=false
ENABLE_OBSERVABILITY=true
ENABLE_CACHING=true
ENABLE_FEATURE_FLAGS=true
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on:
jobs:
validate:
runs-on: ubuntu-latest
env:
DISABLE_RATE_LIMITING: false

steps:
- name: Checkout code
Expand Down
4 changes: 2 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const featureFlags = loadFeatureFlags();
SearchModule,
AnalyticsModule,
IndexOptimizationModule,
...(featureFlags.ENABLE_RATE_LIMITING ? [RateLimitingModule] : []),
...(!featureFlags.DISABLE_RATE_LIMITING ? [RateLimitingModule] : []),
DebuggingModule,
DataPipelineModule,
CanaryModule,
Expand Down Expand Up @@ -76,7 +76,7 @@ const featureFlags = loadFeatureFlags();
],
controllers: [AppController],
providers: [
...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []),
...(!featureFlags.DISABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []),
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
{ provide: APP_INTERCEPTOR, useClass: RoleVisibilityInterceptor },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
Expand Down
4 changes: 3 additions & 1 deletion src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@
ELASTICSEARCH_API_KEY: Joi.string().optional(),
ELASTICSEARCH_CA_FINGERPRINT: Joi.string().optional(),
ELASTICSEARCH_REQUEST_TIMEOUT: Joi.number().integer().default(30000),
ELASTICSEARCH_MAX_RETRIES: Joi.number().integer().default(3),
ELASTICSEARCH_MAX_RETRIES: Joi.number().integer().default(3),

Check failure on line 82 in src/config/env.validation.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `··`

// Rate Limiting
THROTTLE_TTL: Joi.number().default(60),
THROTTLE_LIMIT: Joi.number().default(10),
// DISABLE_RATE_LIMITING: Set to true to disable rate limiting (opt-out, not recommended)
DISABLE_RATE_LIMITING: Joi.boolean().default(false),

// Session Configuration
SESSION_SECRET: Joi.string().min(10).required(),
Expand Down
41 changes: 37 additions & 4 deletions src/config/feature-flags.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,20 @@
*/

export interface IFeatureFlagsConfig {
/**
* Explicit opt-out switch for rate limiting.
*
* Rate limiting is enabled by default and only disabled when
* DISABLE_RATE_LIMITING=true.
*/
DISABLE_RATE_LIMITING?: boolean;

/** Gates the AuthModule — controls user authentication and authorization.
* `true`: AuthModule is loaded at startup.
* `false`: AuthModule is skipped; all auth-gated endpoints become unavailable. */
ENABLE_AUTH: boolean;


Check failure on line 58 in src/config/feature-flags.config.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`
/** Gates the PaymentsModule — controls Stripe-based payment processing.
* `true`: PaymentsModule is loaded, payment endpoints available.
* `false`: PaymentsModule is skipped, payment endpoints unavailable. */
Expand Down Expand Up @@ -95,11 +104,15 @@
* `false`: MigrationModule is skipped. */
ENABLE_MIGRATIONS: boolean;

/** Gates the RateLimitingModule — controls per-route request throttling.
* `true`: RateLimitingModule is loaded, advanced rate-limit rules active.
* `false`: RateLimitingModule is skipped (basic ThrottlerModule still active). */
/**
* Gates the RateLimitingModule — controls per-route request throttling.
*
* Rate limiting is enabled by default.
* Only disabled when DISABLE_RATE_LIMITING=true.
*/
ENABLE_RATE_LIMITING: boolean;

Check failure on line 113 in src/config/feature-flags.config.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`


/** Gates the ObservabilityModule — controls distributed tracing and metrics.
* `true`: ObservabilityModule is loaded, traces and custom metrics exported.
* `false`: ObservabilityModule is skipped. */
Expand Down Expand Up @@ -187,6 +200,10 @@
* except AB_TESTING, DATA_WAREHOUSE, and GRAPHQL which are not yet GA
*/
export const defaultFeatureFlags: IFeatureFlagsConfig = {
// Rate limiting is enabled by default.
// It can only be disabled by explicitly setting DISABLE_RATE_LIMITING=true.
DISABLE_RATE_LIMITING: undefined,

ENABLE_AUTH: true,
ENABLE_PAYMENTS: true,
ENABLE_AB_TESTING: false,
Expand Down Expand Up @@ -216,12 +233,23 @@
ENABLE_LOCALIZATION: true,
ENABLE_ONBOARDING: true,
};

/**
* Load feature flags from environment variables
*/
export function loadFeatureFlags(): IFeatureFlagsConfig {
const disableRateLimiting = getBooleanEnv(
'DISABLE_RATE_LIMITING',
defaultFeatureFlags.DISABLE_RATE_LIMITING ?? false,
);

return {
// Explicit opt-out only (default: enabled)
DISABLE_RATE_LIMITING: disableRateLimiting,

// Legacy flag kept for compatibility, but not used for wiring below.
ENABLE_AUTH: getBooleanEnv('ENABLE_AUTH', defaultFeatureFlags.ENABLE_AUTH),

ENABLE_PAYMENTS: getBooleanEnv('ENABLE_PAYMENTS', defaultFeatureFlags.ENABLE_PAYMENTS),
ENABLE_AB_TESTING: getBooleanEnv('ENABLE_AB_TESTING', defaultFeatureFlags.ENABLE_AB_TESTING),
ENABLE_DATA_WAREHOUSE: getBooleanEnv(
Expand All @@ -240,10 +268,13 @@
ENABLE_GRAPHQL: getBooleanEnv('ENABLE_GRAPHQL', defaultFeatureFlags.ENABLE_GRAPHQL),
ENABLE_SYNC: getBooleanEnv('ENABLE_SYNC', defaultFeatureFlags.ENABLE_SYNC),
ENABLE_MIGRATIONS: getBooleanEnv('ENABLE_MIGRATIONS', defaultFeatureFlags.ENABLE_MIGRATIONS),
// Legacy flag kept for compatibility; rate limiting wiring is now controlled by
// DISABLE_RATE_LIMITING.
ENABLE_RATE_LIMITING: getBooleanEnv(
'ENABLE_RATE_LIMITING',
defaultFeatureFlags.ENABLE_RATE_LIMITING,
),

ENABLE_OBSERVABILITY: getBooleanEnv(
'ENABLE_OBSERVABILITY',
defaultFeatureFlags.ENABLE_OBSERVABILITY,
Expand Down Expand Up @@ -289,7 +320,9 @@
),
ENABLE_ONBOARDING: getBooleanEnv('ENABLE_ONBOARDING', defaultFeatureFlags.ENABLE_ONBOARDING),
};
}

Check failure on line 323 in src/config/feature-flags.config.ts

View workflow job for this annotation

GitHub Actions / validate

Delete `⏎`


/**
* Helper function to parse boolean environment variables
*/
Expand All @@ -316,7 +349,7 @@
if (flags.ENABLE_GRAPHQL) modules.push('GraphQLModule');
if (flags.ENABLE_SYNC) modules.push('SyncModule');
if (flags.ENABLE_MIGRATIONS) modules.push('MigrationModule');
if (flags.ENABLE_RATE_LIMITING) modules.push('RateLimitingModule');
if (!flags.DISABLE_RATE_LIMITING) modules.push('RateLimitingModule');
if (flags.ENABLE_OBSERVABILITY) modules.push('ObservabilityModule');
if (flags.ENABLE_CACHING) modules.push('CachingModule');
if (flags.ENABLE_FEATURE_FLAGS) modules.push('FeatureFlagsModule');
Expand Down
9 changes: 9 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Redis from 'ioredis';

import { AppModule } from './app.module';
import './tracing/opentelemetry';
import { loadFeatureFlags } from './config/feature-flags.config';

import { CorrelationIdMiddleware } from './middleware/correlation-id';
import { createSessionConfig } from './config/cache.config';
Expand Down Expand Up @@ -48,6 +49,14 @@ async function bootstrapWorker(): Promise<void> {
const logger = new Logger('Bootstrap');
const bootstrapStartTime = Date.now();

const flags = loadFeatureFlags();
if (flags.DISABLE_RATE_LIMITING) {
logger.warn(
'Rate limiting is DISABLED. This exposes the API to potential DoS and credential-stuffing attacks. ' +
'Set DISABLE_RATE_LIMITING=false or remove the variable to enable rate limiting.',
);
}

const requestBodyLimit = process.env.REQUEST_BODY_LIMIT || '1mb';

const fileUploadMaxBytes = parseInt(
Expand Down
118 changes: 118 additions & 0 deletions test/rate-limiting.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, Controller, Get, Post } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { QuotaGuard } from '../src/rate-limiting/guards/quota.guard';
import { Reflector } from '@nestjs/core';

Check failure on line 5 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

'@nestjs/core' import is duplicated
import { QuotaTrackingService, QuotaCheckResult } from '../src/rate-limiting/services/quota-tracking.service';

Check failure on line 6 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·QuotaTrackingService,·QuotaCheckResult·` with `⏎··QuotaTrackingService,⏎··QuotaCheckResult,⏎`
import supertest from 'supertest';

@Controller('rate-test')
class RateLimitTestController {
@Get('endpoint')
getEndpoint() {
return { success: true, message: 'Request succeeded' };
}

@Post('endpoint')
postEndpoint() {
return { success: true, message: 'POST request succeeded' };
}
}

describe('Rate Limiting (e2e)', () => {
let app: INestApplication;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [RateLimitTestController],
providers: [
Reflector,
{
provide: QuotaTrackingService,
useValue: {
checkAndIncrement: jest.fn().mockImplementation(async (): Promise<QuotaCheckResult> => ({

Check failure on line 33 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `⏎··············`
allowed: true,

Check failure on line 34 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `··`
remaining: { minute: 4, hour: 29, day: 99 },

Check failure on line 35 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `··············` with `················`
limit: { minute: 5, hour: 30, day: 100 },

Check failure on line 36 in test/rate-limiting.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / validate

Insert `··`
})),
},
},
{
provide: APP_GUARD,
useClass: QuotaGuard,
},
],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

afterAll(async () => {
if (app) {
await app.close();
}
});

it('should have RateLimitingModule loaded when DISABLE_RATE_LIMITING is not set', async () => {
const response = await supertest(app.getHttpServer()).get('/rate-test/endpoint');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});

it('should return rate limit headers on responses', async () => {
const response = await supertest(app.getHttpServer())
.get('/rate-test/endpoint')
.set('X-Forwarded-For', '192.168.1.100');

expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit-minute');
expect(response.headers).toHaveProperty('x-ratelimit-limit-hour');
expect(response.headers).toHaveProperty('x-ratelimit-limit-day');
});
});

describe('Rate Limiting - Over Limit (e2e)', () => {
let appOverLimit: INestApplication;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [RateLimitTestController],
providers: [
Reflector,
{
provide: QuotaTrackingService,
useValue: {
checkAndIncrement: jest.fn().mockImplementation(async (): Promise<QuotaCheckResult> => ({
allowed: false,
remaining: { minute: 0, hour: 0, day: 0 },
limit: { minute: 5, hour: 30, day: 100 },
retryAfter: 30,
})),
},
},
{
provide: APP_GUARD,
useClass: QuotaGuard,
},
],
}).compile();

appOverLimit = moduleFixture.createNestApplication();
await appOverLimit.init();
});

afterAll(async () => {
if (appOverLimit) {
await appOverLimit.close();
}
});

it('should return 429 when rate limit is exceeded', async () => {
const response = await supertest(appOverLimit.getHttpServer())
.get('/rate-test/endpoint')
.set('X-Forwarded-For', '192.168.1.200');

expect(response.status).toBe(429);
});
});
Loading