diff --git a/api/.env.template b/api/.env.template index 637b94ffb5..537c1333fb 100644 --- a/api/.env.template +++ b/api/.env.template @@ -44,3 +44,7 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"] CORS_REGEX=["test1", "test2"] # controls the repetition of the temp file clearing cron job TEMP_FILE_CLEAR_CRON_STRING=0 * * * +# how long we maintain our request time outs (60 * 60 * 1000 ms) +THROTTLE_TTL=3600000 +# how many requests before we throttle +THROTTLE_LIMIT=100 diff --git a/api/package.json b/api/package.json index 612f6b3b23..3833cae39c 100644 --- a/api/package.json +++ b/api/package.json @@ -43,11 +43,12 @@ "@nestjs/platform-express": "^10.3.2", "@nestjs/schedule": "^4.0.1", "@nestjs/swagger": "~7.1.12", + "@nestjs/throttler": "^5.1.2", "@prisma/client": "^5.0.0", "@sendgrid/mail": "7.7.0", + "@turf/boolean-point-in-polygon": "6.5.0", "@turf/buffer": "6.5.0", "@turf/helpers": "6.5.0", - "@turf/boolean-point-in-polygon": "6.5.0", "@turf/points-within-polygon": "6.5.0", "@types/archiver": "^6.0.2", "archiver": "^6.0.1", diff --git a/api/src/controllers/ami-chart.controller.ts b/api/src/controllers/ami-chart.controller.ts index e5047d4565..199bd370aa 100644 --- a/api/src/controllers/ami-chart.controller.ts +++ b/api/src/controllers/ami-chart.controller.ts @@ -28,12 +28,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/amiCharts') @ApiTags('amiCharts') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('amiChart') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) @ApiExtraModels(AmiChartQueryParams) export class AmiChartController { constructor(private readonly AmiChartService: AmiChartService) {} diff --git a/api/src/controllers/app.controller.ts b/api/src/controllers/app.controller.ts index 7f5c557e80..03f831174f 100644 --- a/api/src/controllers/app.controller.ts +++ b/api/src/controllers/app.controller.ts @@ -18,8 +18,10 @@ import { PermissionAction } from '../decorators/permission-action.decorator'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { AppService } from '../services/app.service'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller() +@UseGuards(ThrottleGuard) @ApiExtraModels(SuccessDTO) @ApiTags('root') export class AppController { diff --git a/api/src/controllers/application-flagged-set.controller.ts b/api/src/controllers/application-flagged-set.controller.ts index 761a0e98a6..b6b4703e82 100644 --- a/api/src/controllers/application-flagged-set.controller.ts +++ b/api/src/controllers/application-flagged-set.controller.ts @@ -32,11 +32,12 @@ import { mapTo } from '../utilities/mapTo'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/applicationFlaggedSets') @ApiExtraModels(SuccessDTO) @ApiTags('applicationFlaggedSets') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('applicationFlaggedSet') @UsePipes( new ValidationPipe({ diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 4f612b49c0..2e277d45d9 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -49,6 +49,7 @@ import { ApplicationCsvExporterService } from '../services/application-csv-expor import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('applications') @ApiTags('applications') @@ -59,7 +60,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; }), ) @ApiExtraModels(IdDTO, AddressInput, BooleanInput, TextInput) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('application') @UseInterceptors(ActivityLogInterceptor) export class ApplicationController { diff --git a/api/src/controllers/asset.controller.ts b/api/src/controllers/asset.controller.ts index dbb66c04f7..ae21f50683 100644 --- a/api/src/controllers/asset.controller.ts +++ b/api/src/controllers/asset.controller.ts @@ -19,6 +19,7 @@ import { CreatePresignedUploadMetadataResponse } from '../dtos/assets/create-pre import { CreatePresignedUploadMetadata } from '../dtos/assets/create-presigned-upload-meta.dto'; import { AssetService } from '../services/asset.service'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('assets') @ApiTags('assets') @@ -28,7 +29,7 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi CreatePresignedUploadMetadataResponse, ) @PermissionTypeDecorator('asset') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class AssetController { constructor(private readonly assetService: AssetService) {} diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 3861e1c807..966e8b4848 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -32,6 +32,7 @@ import { User } from '../dtos/users/user.dto'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; import { SingleUseCodeAuthGuard } from '../guards/single-use-code.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('auth') @ApiTags('auth') @@ -43,7 +44,7 @@ export class AuthController { @ApiOperation({ summary: 'Login', operationId: 'login' }) @ApiOkResponse({ type: SuccessDTO }) @ApiBody({ type: Login }) - @UseGuards(MfaAuthGuard) + @UseGuards(ThrottleGuard, MfaAuthGuard) async login( @Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse, diff --git a/api/src/controllers/jurisdiction.controller.ts b/api/src/controllers/jurisdiction.controller.ts index e7263240c3..2e0433cb18 100644 --- a/api/src/controllers/jurisdiction.controller.ts +++ b/api/src/controllers/jurisdiction.controller.ts @@ -27,13 +27,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('jurisdictions') @ApiTags('jurisdictions') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(JurisdictionCreate, JurisdictionUpdate, IdDTO) @PermissionTypeDecorator('jurisdiction') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class JurisdictionController { constructor(private readonly jurisdictionService: JurisdictionService) {} diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts index 8fae0b99fc..d661a434a5 100644 --- a/api/src/controllers/listing.controller.ts +++ b/api/src/controllers/listing.controller.ts @@ -53,6 +53,7 @@ import { ListingCsvExporterService } from '../services/listing-csv-export.servic import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; import { PermissionGuard } from '../guards/permission.guard'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('listings') @ApiTags('listings') @@ -63,7 +64,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; PaginationAllowsAllQueryParams, IdDTO, ) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('listing') @ActivityLogMetadata([{ targetPropertyName: 'status', propertyPath: 'status' }]) @UseInterceptors(ActivityLogInterceptor) diff --git a/api/src/controllers/map-layer.controller.ts b/api/src/controllers/map-layer.controller.ts index 205e53dcb9..36c498debf 100644 --- a/api/src/controllers/map-layer.controller.ts +++ b/api/src/controllers/map-layer.controller.ts @@ -14,10 +14,11 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/mapLayers') @ApiTags('mapLayers') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('mapLayers') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) export class MapLayersController { diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts index 4a578f30bf..0b642e612b 100644 --- a/api/src/controllers/multiselect-question.controller.ts +++ b/api/src/controllers/multiselect-question.controller.ts @@ -33,6 +33,7 @@ import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('multiselectQuestions') @ApiTags('multiselectQuestions') @@ -46,7 +47,7 @@ import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor IdDTO, ) @PermissionTypeDecorator('multiselectQuestion') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class MultiselectQuestionController { constructor( private readonly multiselectQuestionService: MultiselectQuestionService, diff --git a/api/src/controllers/reserved-community-type.controller.ts b/api/src/controllers/reserved-community-type.controller.ts index a8d936e2e4..1925b3bff1 100644 --- a/api/src/controllers/reserved-community-type.controller.ts +++ b/api/src/controllers/reserved-community-type.controller.ts @@ -28,13 +28,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('reservedCommunityTypes') @ApiTags('reservedCommunityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(ReservedCommunityTypeQueryParams) @PermissionTypeDecorator('reservedCommunityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class ReservedCommunityTypeController { constructor( private readonly ReservedCommunityTypeService: ReservedCommunityTypeService, diff --git a/api/src/controllers/unit-accessibility-priority-type.controller.ts b/api/src/controllers/unit-accessibility-priority-type.controller.ts index a185a240ab..14d82739b8 100644 --- a/api/src/controllers/unit-accessibility-priority-type.controller.ts +++ b/api/src/controllers/unit-accessibility-priority-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitAccessibilityPriorityTypes') @ApiTags('unitAccessibilityPriorityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(IdDTO) @PermissionTypeDecorator('unitAccessibilityPriorityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitAccessibilityPriorityTypeController { constructor( private readonly unitAccessibilityPriorityTypeService: UnitAccessibilityPriorityTypeService, diff --git a/api/src/controllers/unit-rent-type.controller.ts b/api/src/controllers/unit-rent-type.controller.ts index b7035ec142..ee8901afa1 100644 --- a/api/src/controllers/unit-rent-type.controller.ts +++ b/api/src/controllers/unit-rent-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitRentTypes') @ApiTags('unitRentTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(UnitRentTypeCreate, UnitRentTypeUpdate, IdDTO) @PermissionTypeDecorator('unitRentType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitRentTypeController { constructor(private readonly unitRentTypeService: UnitRentTypeService) {} diff --git a/api/src/controllers/unit-type.controller.ts b/api/src/controllers/unit-type.controller.ts index ac23f9c3fa..04e9cdcc72 100644 --- a/api/src/controllers/unit-type.controller.ts +++ b/api/src/controllers/unit-type.controller.ts @@ -21,12 +21,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitTypes') @ApiTags('unitTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('unitType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitTypeController { constructor(private readonly unitTypeService: UnitTypeService) {} diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index eb0de49ef4..3afa5259ec 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -49,8 +49,10 @@ import { PermissionTypeDecorator } from '../decorators/permission-type.decorator import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; import { UserCsvExporterService } from '../services/user-csv-export.service'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('user') +@UseGuards(ThrottleGuard) @ApiTags('user') @PermissionTypeDecorator('user') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) diff --git a/api/src/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts new file mode 100644 index 0000000000..e5530cdc4d --- /dev/null +++ b/api/src/guards/throttler.guard.ts @@ -0,0 +1,19 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ThrottlerLimitDetail } from '@nestjs/throttler/dist/throttler.guard.interface'; + +@Injectable() +export class ThrottleGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + console.log('7:', req.ips.length ? req.ips : req.ip); + return req.ips.length ? req.ips[0] : req.ip; + } + + protected async throwThrottlingException( + context: ExecutionContext, + throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { + console.error(`IP Address: ${throttlerLimitDetail.tracker} was throttled`); + await super.throwThrottlingException(context, throttlerLimitDetail); + } +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index d8c57952e3..787f233909 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -17,6 +17,9 @@ import { UserModule } from './user.module'; import { AuthModule } from './auth.module'; import { ApplicationFlaggedSetModule } from './application-flagged-set.module'; import { MapLayerModule } from './map-layer.module'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Module({ imports: [ @@ -35,9 +38,23 @@ import { MapLayerModule } from './map-layer.module'; AuthModule, ApplicationFlaggedSetModule, MapLayerModule, + ThrottlerModule.forRoot([ + { + ttl: Number(process.env.THROTTLE_TTL), + limit: Number(process.env.THROTTLE_LIMIT), + }, + ]), ], controllers: [AppController], - providers: [AppService, Logger, SchedulerRegistry], + providers: [ + AppService, + Logger, + SchedulerRegistry, + { + provide: APP_GUARD, + useClass: ThrottleGuard, + }, + ], exports: [ ListingModule, AmiChartModule, diff --git a/api/yarn.lock b/api/yarn.lock index c7fd783cd7..47c2c1a949 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1102,6 +1102,11 @@ dependencies: tslib "2.6.2" +"@nestjs/throttler@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-5.1.2.tgz#dc65634153c8b887329b1cc6061db2e556517dcb" + integrity sha512-60MqhSLYUqWOgc38P6C6f76JIpf6mVjly7gpuPBCKtVd0p5e8Fq855j7bJuO4/v25vgaOo1OdVs0U1qtgYioGw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"