diff --git a/cli/magic-config.ts b/cli/magic-config.ts index 6fdbd624c..974e7d8d2 100644 --- a/cli/magic-config.ts +++ b/cli/magic-config.ts @@ -206,6 +206,8 @@ const embeddingModels = [ options.advancedMonitoring = config.advancedMonitoring; options.createVpcEndpoints = config.vpc?.createVpcEndpoints; options.logRetention = config.logRetention; + options.rateLimitPerAIP = config.rateLimitPerIP; + options.llmRateLimitPerIP = config.llms.rateLimitPerIP; options.privateWebsite = config.privateWebsite; options.certificate = config.certificate; options.domain = config.domain; @@ -294,7 +296,7 @@ async function processCreateOptions(options: any): Promise { name: "createCMKs", message: "Do you want to create KMS Customer Managed Keys (CMKs)? (It will be used to encrypt the data at rest.)", - initial: true, + initial: options.createCMKs ?? true, hint: "It is recommended but enabling it on an existing environment will cause the re-creation of some of the resources (for example Aurora cluster, Open Search collection). To prevent data loss, it is recommended to use it on a new environment or at least enable retain on cleanup (needs to be deployed before enabling the use of CMK). For more information on Aurora migration, please refer to the documentation.", }, { @@ -302,7 +304,7 @@ async function processCreateOptions(options: any): Promise { name: "retainOnDelete", message: "Do you want to retain data stores on cleanup of the project (Logs, S3, Tables, Indexes, Cognito User pools)?", - initial: true, + initial: options.retainOnDelete ?? true, hint: "It reduces the risk of deleting data. It will however not delete all the resources on cleanup (would require manual removal if relevant)", }, { @@ -828,6 +830,38 @@ async function processCreateOptions(options: any): Promise { const models: any = await enquirer.prompt(modelsPrompts); const advancedSettingsPrompts = [ + { + type: "input", + name: "llmRateLimitPerIP", + message: + "What is the allowed rate per IP for Gen AI calls (over 10 minutes)? This is used by the SendQuery mutation only", + initial: options.llmRateLimitPerIP + ? String(options.llmRateLimitPerIP) + : "100", + validate(value: string) { + if (Number(value) >= 10) { + return true; + } else { + return "Should be more than 10"; + } + }, + }, + { + type: "input", + name: "rateLimitPerIP", + message: + "What the allowed per IP for all calls (over 10 minutes)? This is used by the all the AppSync APIs and CloudFront", + initial: options.rateLimitPerAIP + ? String(options.rateLimitPerAIP) + : "400", + validate(value: string) { + if (Number(value) >= 10) { + return true; + } else { + return "Should be more than 10"; + } + }, + }, { type: "input", name: "logRetention", @@ -874,7 +908,7 @@ async function processCreateOptions(options: any): Promise { name: "customPublicDomain", message: "Do you want to provide a custom domain name and corresponding certificate arn for the public website ?", - initial: options.customPublicDomain || false, + initial: options.domain ? true : false, skip(): boolean { return (this as any).state.answers.privateWebsite; }, @@ -1137,6 +1171,9 @@ async function processCreateOptions(options: any): Promise { logRetention: advancedSettings.logRetention ? Number(advancedSettings.logRetention) : undefined, + rateLimitPerAIP: advancedSettings?.rateLimitPerIP + ? Number(advancedSettings?.rateLimitPerIP) + : undefined, certificate: advancedSettings.certificate, domain: advancedSettings.domain, cognitoFederation: advancedSettings.cognitoFederationEnabled @@ -1182,6 +1219,9 @@ async function processCreateOptions(options: any): Promise { } : undefined, llms: { + rateLimitPerAIP: advancedSettings?.llmRateLimitPerIP + ? Number(advancedSettings?.llmRateLimitPerIP) + : undefined, sagemaker: answers.sagemakerModels, huggingfaceApiSecretArn: answers.huggingfaceApiSecretArn, sagemakerSchedule: answers.enableSagemakerModelsSchedule diff --git a/lib/aws-genai-llm-chatbot-stack.ts b/lib/aws-genai-llm-chatbot-stack.ts index 34ad9d5eb..c73837895 100644 --- a/lib/aws-genai-llm-chatbot-stack.ts +++ b/lib/aws-genai-llm-chatbot-stack.ts @@ -158,6 +158,7 @@ export class AwsGenAILLMChatbotStack extends cdk.Stack { userPoolClientId: authentication.userPoolClient.userPoolClientId, api: chatBotApi, chatbotFilesBucket: chatBotApi.filesBucket, + uploadBucket: ragEngines?.uploadBucket, crossEncodersEnabled: typeof ragEngines?.sageMakerRagModels?.model !== "undefined", sagemakerEmbeddingsEnabled: diff --git a/lib/chatbot-api/index.ts b/lib/chatbot-api/index.ts index c207ebee0..88adda71f 100644 --- a/lib/chatbot-api/index.ts +++ b/lib/chatbot-api/index.ts @@ -5,6 +5,7 @@ import * as sqs from "aws-cdk-lib/aws-sqs"; import * as sns from "aws-cdk-lib/aws-sns"; import * as ssm from "aws-cdk-lib/aws-ssm"; import * as iam from "aws-cdk-lib/aws-iam"; +import * as wafv2 from "aws-cdk-lib/aws-wafv2"; import * as cdk from "aws-cdk-lib"; import * as path from "path"; import { Construct } from "constructs"; @@ -95,6 +96,27 @@ export class ChatBotApi extends Construct { : appsync.Visibility.GLOBAL, }); + if (props.shared.webACLRules.length > 0) { + new wafv2.CfnWebACLAssociation(this, "WebACLAssociation", { + webAclArn: new wafv2.CfnWebACL(this, "WafAppsync", { + defaultAction: { allow: {} }, + scope: "REGIONAL", + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "WafAppsync", + sampledRequestsEnabled: true, + }, + description: "WAFv2 ACL for APPSync", + name: "WafAppsync", + rules: [ + ...props.shared.webACLRules, + ...this.createWafRules(props.config.llms.rateLimitPerIP ?? 100), + ], + }).attrArn, + resourceArn: api.arn, + }); + } + const apiResolvers = new ApiResolvers(this, "RestApi", { ...props, sessionsTable: chatTables.sessionsTable, @@ -152,4 +174,74 @@ export class ChatBotApi extends Construct { }, ]); } + + private createWafRules(llmRatePerIP: number): wafv2.CfnWebACL.RuleProperty[] { + /** + * The rate limit is the maximum number of requests from a + * single IP address that are allowed in a ten-minute period. + * The IP address is automatically unblocked after it falls below the limit. + */ + const ruleLimitRequests: wafv2.CfnWebACL.RuleProperty = { + name: "LimitLLMRequestsPerIP", + priority: 1, + action: { + block: { + customResponse: { + responseCode: 429, + }, + }, + }, + statement: { + rateBasedStatement: { + limit: llmRatePerIP, + evaluationWindowSec: 60 * 10, + aggregateKeyType: "IP", + scopeDownStatement: { + andStatement: { + statements: [ + { + byteMatchStatement: { + searchString: "/graphql", + fieldToMatch: { + uriPath: {}, + }, + textTransformations: [ + { + priority: 0, + type: "NONE", + }, + ], + positionalConstraint: "EXACTLY", + }, + }, + { + byteMatchStatement: { + searchString: "mutation SendQuery(", + fieldToMatch: { + body: { + oversizeHandling: "MATCH", + }, + }, + textTransformations: [ + { + priority: 0, + type: "NONE", + }, + ], + positionalConstraint: "CONTAINS", + }, + }, + ], + }, + }, + }, + }, + visibilityConfig: { + sampledRequestsEnabled: true, + cloudWatchMetricsEnabled: true, + metricName: "LimitRequestsPerIP", + }, + }; + return [ruleLimitRequests]; + } } diff --git a/lib/shared/index.ts b/lib/shared/index.ts index 291d25d95..1f342a62f 100644 --- a/lib/shared/index.ts +++ b/lib/shared/index.ts @@ -5,6 +5,7 @@ import * as lambda from "aws-cdk-lib/aws-lambda"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ssm from "aws-cdk-lib/aws-ssm"; import * as logs from "aws-cdk-lib/aws-logs"; +import * as wafv2 from "aws-cdk-lib/aws-wafv2"; import { Construct } from "constructs"; import * as path from "path"; import { Layer } from "../layer"; @@ -36,6 +37,7 @@ export class Shared extends Construct { readonly powerToolsLayer: lambda.ILayerVersion; readonly sharedCode: SharedAssetBundler; readonly s3vpcEndpoint: ec2.InterfaceVpcEndpoint; + readonly webACLRules: wafv2.CfnWebACL.RuleProperty[] = []; constructor(scope: Construct, id: string, props: SharedProps) { super(scope, id); @@ -250,6 +252,8 @@ export class Shared extends Construct { } } + this.webACLRules = this.createWafRules(props.config.rateLimitPerIP ?? 400); + const configParameter = new ssm.StringParameter(this, "Config", { stringValue: JSON.stringify(props.config), }); @@ -316,4 +320,32 @@ export class Shared extends Construct { { id: "AwsSolutions-SMG4", reason: "Secret value is blank." }, ]); } + + private createWafRules(ratePerIP: number): wafv2.CfnWebACL.RuleProperty[] { + /** + * The rate limit is the maximum number of requests from a + * single IP address that are allowed in a ten-minute period. + * The IP address is automatically unblocked after it falls below the limit. + */ + const ruleLimitRequests: wafv2.CfnWebACL.RuleProperty = { + name: "LimitRequestsPerIP", + priority: 10, + action: { + block: {}, + }, + statement: { + rateBasedStatement: { + limit: ratePerIP, + evaluationWindowSec: 60 * 10, + aggregateKeyType: "IP", + }, + }, + visibilityConfig: { + sampledRequestsEnabled: true, + cloudWatchMetricsEnabled: true, + metricName: "LimitRequestsPerIP", + }, + }; + return [ruleLimitRequests]; + } } diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 1e339846a..74ca0acee 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -84,6 +84,7 @@ export interface SystemConfig { certificate?: string; domain?: string; privateWebsite?: boolean; + rateLimitPerIP?: number; cognitoFederation?: { enabled?: boolean; autoRedirect?: boolean; @@ -113,6 +114,7 @@ export interface SystemConfig { }; }; llms: { + rateLimitPerIP?: number; sagemaker: SupportedSageMakerModels[]; huggingfaceApiSecretArn?: string; sagemakerSchedule?: { diff --git a/lib/user-interface/index.ts b/lib/user-interface/index.ts index 21d78e6d7..efbe40db4 100644 --- a/lib/user-interface/index.ts +++ b/lib/user-interface/index.ts @@ -25,6 +25,7 @@ export interface UserInterfaceProps { readonly userPoolClient: cognito.UserPoolClient; readonly api: ChatBotApi; readonly chatbotFilesBucket: s3.Bucket; + readonly uploadBucket?: s3.Bucket; readonly crossEncodersEnabled: boolean; readonly sagemakerEmbeddingsEnabled: boolean; } @@ -56,8 +57,12 @@ export class UserInterface extends Construct { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, autoDeleteObjects: true, bucketName: props.config.privateWebsite ? props.config.domain : undefined, - websiteIndexDocument: "index.html", - websiteErrorDocument: "index.html", + websiteIndexDocument: props.config.privateWebsite + ? "index.html" + : undefined, + websiteErrorDocument: props.config.privateWebsite + ? "index.html" + : undefined, enforceSSL: true, serverAccessLogsBucket: uploadLogsBucket, // Cloudfront with OAI only supports S3 Managed Key (would need to migrate to OAC) @@ -80,6 +85,8 @@ export class UserInterface extends Construct { const publicWebsite = new PublicWebsite(this, "PublicWebsite", { ...props, websiteBucket: websiteBucket, + chatbotFilesBucket: props.chatbotFilesBucket, + uploadBucket: props.uploadBucket, }); this.cloudFrontDistribution = publicWebsite.distribution; this.publishedDomain = props.config.domain diff --git a/lib/user-interface/public-website.ts b/lib/user-interface/public-website.ts index 68f1bc7dd..39693cf81 100644 --- a/lib/user-interface/public-website.ts +++ b/lib/user-interface/public-website.ts @@ -1,5 +1,7 @@ import * as cdk from "aws-cdk-lib"; +import * as appsync from "aws-cdk-lib/aws-appsync"; import * as cf from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import { Construct } from "constructs"; @@ -17,10 +19,12 @@ export interface PublicWebsiteProps { readonly crossEncodersEnabled: boolean; readonly sagemakerEmbeddingsEnabled: boolean; readonly websiteBucket: s3.Bucket; + readonly chatbotFilesBucket: s3.Bucket; + readonly uploadBucket?: s3.Bucket; } export class PublicWebsite extends Construct { - readonly distribution: cf.CloudFrontWebDistribution; + readonly distribution: cf.Distribution; constructor(scope: Construct, id: string, props: PublicWebsiteProps) { super(scope, id); @@ -54,60 +58,124 @@ export class PublicWebsite extends Construct { } ); - const distribution = new cf.CloudFrontWebDistribution( + const fileBucketURLs = [ + `https://${props.chatbotFilesBucket.bucketName}.s3-accelerate.amazonaws.com`, + `https://${props.chatbotFilesBucket.bucketName}.s3.amazonaws.com`, + ]; + if (props.uploadBucket) { + // Bucket used to upload documents to workspaces + fileBucketURLs.push( + `https://${props.uploadBucket.bucketName}.s3-accelerate.amazonaws.com` + ); + fileBucketURLs.push( + `https://${props.uploadBucket.bucketName}.s3.amazonaws.com` + ); + } + + const websocketURL = ( + props.api.graphqlApi.node.findChild("Resource") as appsync.CfnGraphQLApi + ).attrRealtimeUrl; + const congnitoFederationDomain = props.config.cognitoFederation + ? `${props.config.cognitoFederation.cognitoDomain}.auth.${cdk.Aws.REGION}.amazoncognito.com` + : undefined; + const responseHeadersPolicy = new cf.ResponseHeadersPolicy( this, - "Distribution", + "ResponseHeadersPolicy", { - // CUSTOM DOMAIN FOR PUBLIC WEBSITE - // REQUIRES: - // 1. ACM Certificate ARN in us-east-1 and Domain of website to be input during 'npm run config': - // "privateWebsite" : false, - // "certificate" : "arn:aws:acm:us-east-1:1234567890:certificate/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXX", - // "domain" : "sub.example.com" - // 2. After the deployment, in your Route53 Hosted Zone, add an "A Record" that points to the Cloudfront Alias (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-cloudfront-distribution.html) - ...(props.config.certificate && - props.config.domain && { - viewerCertificate: cf.ViewerCertificate.fromAcmCertificate( - acm.Certificate.fromCertificateArn( - this, - "CloudfrontAcm", - props.config.certificate - ), - { - aliases: [props.config.domain], - securityPolicy: cf.SecurityPolicyProtocol.TLS_V1_2_2021, - } - ), - }), - viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - priceClass: cf.PriceClass.PRICE_CLASS_ALL, - httpVersion: cf.HttpVersion.HTTP2_AND_3, - loggingConfig: { - bucket: distributionLogsBucket, - }, - originConfigs: [ - { - behaviors: [{ isDefaultBehavior: true }], - s3OriginSource: { - s3BucketSource: props.websiteBucket, - originAccessIdentity, - }, + securityHeadersBehavior: { + contentSecurityPolicy: { + contentSecurityPolicy: + "default-src 'self';" + + `connect-src 'self' https://cognito-idp.${ + cdk.Stack.of(scope).region + }.amazonaws.com/ ` + + (congnitoFederationDomain ? `${congnitoFederationDomain} ` : "") + + `${websocketURL} ${fileBucketURLs.join(" ")} ${ + props.api.graphqlApi.graphqlUrl + };` + + "font-src 'self' data:; " + // Fonts are inline in the CSS files + `img-src 'self' ${fileBucketURLs.join(" ")} blob:; ` + + "style-src 'self' 'unsafe-inline';", // React uses inline style + override: true, }, - ], - geoRestriction: cfGeoRestrictEnable - ? cf.GeoRestriction.allowlist(...cfGeoRestrictList) - : undefined, - errorConfigurations: [ - { - errorCode: 404, - errorCachingMinTtl: 0, - responseCode: 200, - responsePagePath: "/index.html", + contentTypeOptions: { override: true }, + frameOptions: { + frameOption: cf.HeadersFrameOption.DENY, + override: true, }, - ], + referrerPolicy: { + referrerPolicy: cf.HeadersReferrerPolicy.NO_REFERRER, + override: true, + }, + strictTransportSecurity: { + accessControlMaxAge: cdk.Duration.seconds(47304000), + includeSubdomains: true, + override: true, + }, + xssProtection: { protection: true, modeBlock: false, override: true }, + }, } ); + const distribution = new cf.Distribution(this, "Distribution", { + // CUSTOM DOMAIN FOR PUBLIC WEBSITE + // REQUIRES: + // 1. ACM Certificate ARN in us-east-1 and Domain of website to be input during 'npm run config': + // "privateWebsite" : false, + // "certificate" : "arn:aws:acm:us-east-1:1234567890:certificate/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXX", + // "domain" : "sub.example.com" + // 2. After the deployment, in your Route53 Hosted Zone, add an "A Record" that points to the Cloudfront Alias (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-cloudfront-distribution.html) + ...(props.config.certificate && + props.config.domain && { + certificate: acm.Certificate.fromCertificateArn( + this, + "CloudfrontAcm", + props.config.certificate + ), + domainNames: [props.config.domain], + }), + + priceClass: cf.PriceClass.PRICE_CLASS_ALL, + httpVersion: cf.HttpVersion.HTTP2_AND_3, + minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021, + enableLogging: true, + logBucket: distributionLogsBucket, + logIncludesCookies: false, + defaultRootObject: "index.html", + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessIdentity( + props.websiteBucket, + { + connectionTimeout: cdk.Duration.seconds(10), + connectionAttempts: 3, + originAccessIdentity: originAccessIdentity, + } + ), + cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED, + viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + responseHeadersPolicy: responseHeadersPolicy, + }, + geoRestriction: cfGeoRestrictEnable + ? cf.GeoRestriction.allowlist(...cfGeoRestrictList) + : undefined, + errorResponses: [ + { + httpStatus: 404, + ttl: cdk.Duration.minutes(0), + responseHttpStatus: 200, + responsePagePath: "/index.html", + }, + ], + }); + + // Set the CFN resource id to prevent re-creating a new resource and change the URL + // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront-readme.html#migrating-from-the-original-cloudfrontwebdistribution-to-the-newer-distribution-construct + const cfnDistribution = distribution.node + .defaultChild as cf.CfnDistribution; + cfnDistribution.overrideLogicalId( + "UserInterfacePublicWebsiteDistributionCFDistribution17DC8E4E" + ); + this.distribution = distribution; // ################################################### diff --git a/lib/user-interface/react-app/index.html b/lib/user-interface/react-app/index.html index 78f133df7..27471c348 100644 --- a/lib/user-interface/react-app/index.html +++ b/lib/user-interface/react-app/index.html @@ -17,9 +17,6 @@ AWS GenAI Chatbot -
diff --git a/lib/user-interface/react-app/src/common/utils.ts b/lib/user-interface/react-app/src/common/utils.ts index 9ab4e21c9..59b621c3e 100644 --- a/lib/user-interface/react-app/src/common/utils.ts +++ b/lib/user-interface/react-app/src/common/utils.ts @@ -80,6 +80,14 @@ export class Utils { /* eslint-disable @typescript-eslint/no-explicit-any */ static getErrorMessage(error: any) { + if ( + error.errors && + error.errors.length === 1 && + error.errors[0].originalError?.response?.status === 429 + ) { + // Detect WAF throttling error. originalError is an AxiosError object + return "Too many requests. Please try again later."; + } if (error.errors) { return error.errors.map((e: any) => e.message).join(", "); } diff --git a/lib/user-interface/react-app/vite.config.ts b/lib/user-interface/react-app/vite.config.ts index 014c7a924..892b548e9 100644 --- a/lib/user-interface/react-app/vite.config.ts +++ b/lib/user-interface/react-app/vite.config.ts @@ -9,6 +9,8 @@ const isDev = process.env.NODE_ENV === "development"; export default defineConfig({ define: { "process.env": {}, + // Prevents replacing global in the import strings. + global: "global", }, plugins: [ isDev && { diff --git a/tests/__snapshots__/cdk-app.test.ts.snap b/tests/__snapshots__/cdk-app.test.ts.snap index 5cb10fcbc..ba4a1dc45 100644 --- a/tests/__snapshots__/cdk-app.test.ts.snap +++ b/tests/__snapshots__/cdk-app.test.ts.snap @@ -4057,6 +4057,121 @@ schema { }, "Type": "AWS::IAM::Policy", }, + "ChatBotApiWafAppsync9FEB4E22": { + "Properties": { + "DefaultAction": { + "Allow": {}, + }, + "Description": "WAFv2 ACL for APPSync", + "Name": "WafAppsync", + "Rules": [ + { + "Action": { + "Block": {}, + }, + "Name": "LimitRequestsPerIP", + "Priority": 10, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "EvaluationWindowSec": 600, + "Limit": 400, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "LimitRequestsPerIP", + "SampledRequestsEnabled": true, + }, + }, + { + "Action": { + "Block": { + "CustomResponse": { + "ResponseCode": 429, + }, + }, + }, + "Name": "LimitLLMRequestsPerIP", + "Priority": 1, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "EvaluationWindowSec": 600, + "Limit": 100, + "ScopeDownStatement": { + "AndStatement": { + "Statements": [ + { + "ByteMatchStatement": { + "FieldToMatch": { + "UriPath": {}, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": "/graphql", + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "Body": { + "OversizeHandling": "MATCH", + }, + }, + "PositionalConstraint": "CONTAINS", + "SearchString": "mutation SendQuery(", + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + ], + }, + }, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "LimitRequestsPerIP", + "SampledRequestsEnabled": true, + }, + }, + ], + "Scope": "REGIONAL", + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "WafAppsync", + "SampledRequestsEnabled": true, + }, + }, + "Type": "AWS::WAFv2::WebACL", + }, + "ChatBotApiWebACLAssociation24C73AD5": { + "Properties": { + "ResourceArn": { + "Fn::GetAtt": [ + "ChatBotApiChatbotApiBABF9B87", + "Arn", + ], + }, + "WebACLArn": { + "Fn::GetAtt": [ + "ChatBotApiWafAppsync9FEB4E22", + "Arn", + ], + }, + }, + "Type": "AWS::WAFv2::WebACLAssociation", + }, "ChatBotApiapiLoggingRoleD60CA740": { "Metadata": { "cdk_nag": { diff --git a/tests/chatbot-api/__snapshots__/chatbot-api-construct.test.ts.snap b/tests/chatbot-api/__snapshots__/chatbot-api-construct.test.ts.snap index 66e461e64..7bb87d8fd 100644 --- a/tests/chatbot-api/__snapshots__/chatbot-api-construct.test.ts.snap +++ b/tests/chatbot-api/__snapshots__/chatbot-api-construct.test.ts.snap @@ -4119,6 +4119,121 @@ schema { }, "Type": "AWS::IAM::Policy", }, + "ChatBotApiConstructWafAppsyncE6AACFFE": { + "Properties": { + "DefaultAction": { + "Allow": {}, + }, + "Description": "WAFv2 ACL for APPSync", + "Name": "WafAppsync", + "Rules": [ + { + "Action": { + "Block": {}, + }, + "Name": "LimitRequestsPerIP", + "Priority": 10, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "EvaluationWindowSec": 600, + "Limit": 400, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "LimitRequestsPerIP", + "SampledRequestsEnabled": true, + }, + }, + { + "Action": { + "Block": { + "CustomResponse": { + "ResponseCode": 429, + }, + }, + }, + "Name": "LimitLLMRequestsPerIP", + "Priority": 1, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "EvaluationWindowSec": 600, + "Limit": 100, + "ScopeDownStatement": { + "AndStatement": { + "Statements": [ + { + "ByteMatchStatement": { + "FieldToMatch": { + "UriPath": {}, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": "/graphql", + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "Body": { + "OversizeHandling": "MATCH", + }, + }, + "PositionalConstraint": "CONTAINS", + "SearchString": "mutation SendQuery(", + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + ], + }, + }, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "LimitRequestsPerIP", + "SampledRequestsEnabled": true, + }, + }, + ], + "Scope": "REGIONAL", + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "WafAppsync", + "SampledRequestsEnabled": true, + }, + }, + "Type": "AWS::WAFv2::WebACL", + }, + "ChatBotApiConstructWebACLAssociation9B3FB92B": { + "Properties": { + "ResourceArn": { + "Fn::GetAtt": [ + "ChatBotApiConstructChatbotApi21E23C68", + "Arn", + ], + }, + "WebACLArn": { + "Fn::GetAtt": [ + "ChatBotApiConstructWafAppsyncE6AACFFE", + "Arn", + ], + }, + }, + "Type": "AWS::WAFv2::WebACLAssociation", + }, "ChatBotApiConstructapiLoggingRole6BE21CB3": { "Metadata": { "cdk_nag": {