使用网守解决方案限制对 Web 应用程序的请求

许多类型的组织每天都会遭到 DDoS 攻击。DDoS(分布式拒绝服务)是一种攻击模式,它会生成虚假流量,使组织的财产(网站、API、应用程序等)不堪重负,使合法用户无法使用。这些攻击大多涉及大量虚假流量,耗尽应用程序的资源(内存和CPU)。尽管如此,仍有一些例子 DDoS 攻击的发生速度异常低。在这种情况下,攻击者会将目标对准专为提供低流量而构建的服务。攻击者将流量分配到较小的请求块中,但将其传播给更多的虚假请求者。请求速率足够小,足以规避防火墙实施的限制机制,但足以使应用程序不可用。为了应对这些低速攻击,我们构建了一个实用程序,可以充当应用程序的看门人。

为什么我们需要速率限制?

本节重点介绍实施标准 Web 应用程序防火墙 (WAF) 无法满足的自定义速率限制系统的必要性。我们来自教育科技或教育技术平台等领域的客户在其页面上会收到可预测的请求量。虽然每个 API 的请求速率各不相同,但仍然相对较低。攻击者通常以这些 API 为目标来破坏服务。尽管请求率很低,但攻击表现出异常行为,因此很容易被发现。

这些攻击的问题是:

  • 它们持续的时间很短。
  • 攻击模式经常变化。

可用于防止这些攻击的选项有:

  1. 在总流量的入口处部署 WAF:
    1. 如果所有请求的总和突破 WAF 最低限制, 这将起作用。
    2. 它将确保后端受到保护,免受攻击。
    3. 但这可能会阻止真正的用户在攻击时访问该网站。
  2. 对访问日志运行异常检测算法,并将检测到的 IP 添加到黑名单中。
    1. 这将是最好的选择,因为我们可以查明攻击者。
    2. 但是由于攻击持续时间很短,当我们检测到异常时,攻击已经结束或改变了模式。
  3. 添加 亚马逊云科技 WAF 验证码: 验证码
    1. 有助于区分人类和攻击者并将其阻止。
    2. 但是有些客户不喜欢这如何为最终用户增加一步。

所有这些加在一起就是我们构建本文其余部分中描述的 Gatekeeper 解决方案的原因。

会发生什么?

本指南可帮助您了解我们是如何建立门卫机制的,以及如何将其部署到 Amazon CloudFront 上。它限制了对您的 Web 应用程序或 API 的请求,并防止了前面提到的攻击。

这将帮助你:

  1. 使用超低速率限制阻止攻击。这种保护要么适用于所有请求,要么仅适用于一组匹配路径模式的请求(例如仅保护特定 API)。
  2. 在一段时间内只允许某些请求,并对其他请求显示静态内容/响应,因此它不会向应用程序服务器发送大量请求。在洪水情况下,我们会提供来自 亚马逊 Simple Storage Service(Amazon S3)的静态版本的网页。

诀窍是什么?

我们在 CloudFront 上以 Lambda @Edge (L @E) 函数的形式部署了两个无服务器函数。它是滑动窗和毛毯油门的组合。

第一个 L @E 函数可使用在应用程序级别估算的速率来帮助您保护端点。这是通过单个油门值完成的。例如,EdTechs 中的登录 API 会向平台验证用户身份。我们为每台用户设备调用一次。因此,可以在应用程序级别轻松估计 RPS 或每秒请求数。

其次,L @E 可以帮助你为每个 IP 设置一个滑动窗口。我们将其用于 API,它们需要的请求很少,但可能因用户而异。例如,EdTech 中的授权 API 用于检查用户是否可以观看教程。每个视频内容仅调用一次,并且请求数量保持在相当低的水平。添加滑动窗口可以防止这些攻击。

现在,一旦我们检测到攻击,我们将返回有效的虚拟响应,而不是返回 429 或 4XX。这是为了确保攻击者不知道他们的请求受到了限制。即使攻击仍在继续,我们也会为他们提供来自 CloudFront 的静态页面。

架构

这篇博客文章中提出的解决方案使用在亚马逊 CloudFront 上 按原始请求 部署的 L @E,并根据存储在 Amazon DynamoDB 中的会话详细信息来操作请求 URL,如图 1 所示。

Figure 1: Reference architecture for the Gatekeeper solution

图 1:网守解决方案的参考架构

先决条件

要部署此 Gatekeeper 应用程序,您需要:

  1. 已安装 亚马逊云科技 命令行接口 (亚马逊云科技 CLI )。
  2. 有权创建 亚马逊 Dynamo DB 、亚马逊云科技 身份和访问管理 (IAM ) Access Management 角色 亚马逊云科技 Lambda 等资源的 亚马逊云科技 证书。
  3. 亚马逊云科技 管理控制台 访问权限。
  4. 虚拟响应(网页/文件),在发生攻击时必须出现。
Note:

如果您没有安装 亚马逊云科技 CLI,则此 入门 页面包含下载和配置 亚马逊云科技 CLI 时必须知道的信息。如果你使用的是 Linux 或 OS X,则必须安装 Python(从 2.6.x 到 3.3.x 的任何版本)。你可以使用 easy_install、 pip 或 Windows MSI 安装 CLI。

您可以使用环境变量或使用配置文件为 CLI 设置 亚马逊云科技 证书。如果您在亚马逊弹性计算云 (Amazon EC2) 实例上运行 CLI,那么您也可以使用 IAM 角色

部署应用程序的步骤

创建 DynamoDB 表

使用以下 亚马逊云科技 CLI 命令在我们的账户中创建 DynamoDB 数据库:

$ aws dynamodb create-table \
    --table-name cf-request-counter-table\
    --attribute-definitions \
        AttributeName=PId,AttributeType=S \
        AttributeName=RequestTimeStamp,AttributeType=N \
    --key-schema \
        AttributeName=PId,KeyType=HASH \
        AttributeName=RequestTimeStamp,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=5,WriteCapacityUnits=5 \
    --table-class STANDARD

创建 IAM 角色

你必须创建一个名为 Edge-Role-Trust-policy.json 的文件,其中包含以下内容:

{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Principal": {
"Service": [
              "lambda.amazonaws.com",
       "edgelambda.amazonaws.com"
       ]},
    "Action": "sts:AssumeRole"
  }
}

然后,使用以下 亚马逊云科技 CLI 命令在我们的账户中创建 IAM 角色:

$ aws iam create-role \
    --role-name EdgeFunctionRole \
    --assume-role-policy-document file://Edge-Role-Trust-Policy.json 
 Note: Copy and save the role ARN to be consumed while creating Lambdas.

接下来,使用以下 亚马逊云科技 CLI 命令附加角色策略:

$ aws iam attach-role-policy \
    --role-name EdgeFunctionRole \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole    
$ aws iam attach-role-policy \
    --role-name EdgeFunctionRole \
    --policy-arn arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess
$ aws iam attach-role-policy \
    --role-name EdgeFunctionRole \
    --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess

创建 Lambda 函数以降低油门速率

使用以下代码创建 项目目录 并创建名为 index.js 的文件:

'use strict';
const DURATION_FOR_THROTTLE = 30, //Update your value in seconds
REQUEST_COUNT_FOR_THROTTLE = 5,   //Update your value
CUSTOM_REDIRECT_URL = "YOUR_DUMMY_RESPONSE_HTML_URL", //Update your value
DYNAMO_DB_TABLE_NAME = "cf-request-counter-ddbtable"; //Update your value

var AWS = require("aws-sdk");
AWS.config.update({ region: "REGION_OF_DYNAMODB_TABLE" });
var docClient = new AWS.DynamoDB.DocumentClient();

const redirectResponse = {
 status: '302',
 statusDescription: 'Moved Permanently',
 headers: {
  'location': [{
   key: 'Location',
   value: CUSTOM_REDIRECT_URL,
  }],
  'cache-control': [{
   key: 'Cache-Control',
   value: "max-age=0"
  }],
 },
};
var getRequests = async(clientip, duration) => {
 const myPromise = new Promise((resolve, reject) => {
  var params = {
   TableName: DYNAMO_DB_TABLE_NAME,
   KeyConditionExpression: "#pk = :clientip and RequestTimeStamp > :timestamp",
   ExpressionAttributeNames: {
    "#pk": "PId"
   },
   ExpressionAttributeValues: {
    ":clientip": clientip,
    ":timestamp": duration
   }
  };
  docClient.query(params, function(err, data) {
   if (err) {
    console.error("Unable to read item. Error JSON:", JSON.stringify(err, null, 2));
    reject(err);
   }
   else {
    console.log("GetItem succeeded:", JSON.stringify(data, null, 2));
    resolve(data.Count);
   }
  });
 });
 return myPromise;
}
var addnewRequest = async(clientip, current_time) => {
 const myPromise = new Promise((resolve, reject) => {
  var params = {
   TableName: DYNAMO_DB_TABLE_NAME,
   Item: {
    "PId": clientip,
    "RequestTimeStamp": current_time
   }
  };
  console.log("Adding a new item...");
  console.log(params);
  docClient.put(params, function(err, data) {
   if (err) {
    console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2));
    reject(err);
   }
   else {
    console.log("Added item:", JSON.stringify(data, null, 2));
    resolve(data);
   }
  });
 });
 return myPromise;
}
exports.lambdaHandler = async(event, context) => {
 console.time("functionstart");
 const request = event.Records[0].cf.request;
 const ip = request.clientIp,
  current_time = new Date().getTime(),
  duration = current_time - (DURATION_FOR_THROTTLE * 1000);
 console.time("PutDDBfunction");
 var new_request = await addnewRequest(ip, current_time);
 console.log(new_request);
 console.timeEnd("PutDDBfunction");
 console.time("getDDBfunction");
 var old_requests_count = await getRequests(ip, duration);
 if (old_requests_count > REQUEST_COUNT_FOR_THROTTLE) {
  return redirectResponse;
 }
 console.timeEnd("getDDBfunction");
 console.timeEnd("functionstart")
 return request;
};
Note: Make sure that you’re replacing the lines of the code which are commented "Update your value" with your parameters.

现在,在同一个目录中,使用以下命令将此文件压缩到一个包中:

$ zip lambdaFunc.zip index.js
Note: Alternatively, you can use any zipping software to create zip.

使用以下 亚马逊云科技 CLI 命令在我们的账户中创建 Lambda 函数:

$ aws lambda create-function \
    --function-name LowThrottleRateFunction \
    --zip-file fileb://lambdaFunc.zip --handler index.handler \ 
    --role "<YOUR_ROLE_ARN>" \
    --runtime nodejs12.x \
    --timeout 3 \
    --memory-size 128 \
    --region us-east-1
  Note: Make sure that you are creating L@E function in us-east-1.

为请求限制创建 Lambda 函数

使用以下代码创建新的 项目目录 并创建名为 index.js 的文件:

'use strict';
const DURATION_FOR_THROTTLE = 30,   //Update your value in seconds
    REQUEST_COUNT_FOR_THROTTLE = 5, //Update your value
    CUSTOM_REDIRECT_URL = "YOUR_DUMMY_RESPONSE_HTML_URL", //Update your value
    DYNAMO_DB_TABLE_NAME = "cf-request-counter-ddbtable", //Update your value
    APPLICATION_NAME = "mywebapplication",
    APPLICATION_ID = 100;
var AWS = require("aws-sdk");
AWS.config.update({ region: "REGION_OF_DYNAMODB_TABLE" }); 
var docClient = new AWS.DynamoDB.DocumentClient();
var getRequests = async(applicationname, applicationId) => {
    const myPromise = new Promise((resolve, reject) => {
        var params = {
            TableName: DYNAMO_DB_TABLE_NAME,
            KeyConditionExpression: "#pk = :PID and RequestTimeStamp = :timestamp",
            ExpressionAttributeNames: {
                "#pk": "PId"
            },
            ExpressionAttributeValues: {
                ":PID": applicationname,
                ":timestamp": applicationId
            }
        };
        docClient.query(params, function(err, data) {
            if (err) {
                console.error("Unable to read item. Error JSON:", JSON.stringify(err, null, 2));
                reject(err);
            }
            else {
                console.log("GetItem succeeded:", JSON.stringify(data, null, 2));
                resolve(data.Items[0]);
            }
        });
    });
    return myPromise;
}
var addnewRequest = async(applicationname, applicationId, count, timestamp) => {
    const myPromise = new Promise((resolve, reject) => {
        var params = {
            TableName: DYNAMO_DB_TABLE_NAME,
            Item: {
                "PId": applicationname,
                "RequestTimeStamp": applicationId,
                "RequestCount": count,
                "LastTimeStamp": timestamp
            }
        };
        console.log("Adding a new item...");
        console.log(params);
        docClient.put(params, function(err, data) {
            if (err) {
                console.error("Unable to add item. Error JSON:", JSON.stringify(err, null, 2));
                reject(err);
            }
            else {
                console.log("Added item:", JSON.stringify(data, null, 2));
                resolve(data);
            }
        });
    });
    return myPromise;
}
exports.lambdaHandler = async(event, context) => {
    console.time("functionstart");
    const request = event.Records[0].cf.request;
    const ip = request.clientIp,
        current_time = new Date().getTime(),
        duration = current_time - (DURATION_FOR_THROTTLE * 1000);
    var applicationname = APPLICATION_NAME,
        applicationId = APPLICATION_ID,
        count = 0,
        timestamp = 0;
    console.time("getDDBfunction");
    var old_requests_obj = await getRequests(APPLICATION_NAME, APPLICATION_ID);
    if (old_requests_obj) {
        var old_requests_timestamp = old_requests_obj.LastTimeStamp,
            old_requests_count = old_requests_obj.RequestCount;
        if (duration <= old_requests_timestamp) {
            if (++old_requests_count > REQUEST_COUNT_FOR_THROTTLE) {
                request.uri = CUSTOM_REDIRECT_URL;
                return request;
            }
            count = old_requests_count;
        }
        else {
            count = 1;
        }
    }
    else {
        count = 1;
    }
    timestamp = current_time;
    console.timeEnd("getDDBfunction");
    console.time("PutDDBfunction");
    var new_request = await addnewRequest(applicationname, applicationId, count, timestamp);
    console.log(new_request);
    console.timeEnd("PutDDBfunction");
    console.timeEnd("functionstart")
    return request;
};
Note: Make sure that you’re replacing the lines of the code which are commented "Update your value" with your parameters.

现在,在同一个目录中,使用以下命令将此文件压缩到一个包中:

$ zip lambdaFunc.zip index.js 
Note: Alternatively, you can use any zipping software to create zip.

使用以下 亚马逊云科技 CLI 命令在您的账户中创建 Lambda 函数:

$ aws lambda create-function \
    --function-name RequestThrottleFunction \
    --zip-file fileb://lambdaFunc.zip --handler index.handler \ 
    --role "<YOUR_ROLE_ARN>" \
    --runtime nodejs12.x \
    --timeout 3 \
    --memory-size 128 \
    --region us-east-1
Note: Make sure that you are creating L@E function in us-east-1.

更新 Lambda 函数以降低油门速率

现在我们已经创建了 Lambda 函数 “lowthrottleRateFunction”,让我们配置 CloudFront 来运行我们的函数并监控 CloudFront 收到的任何请求。

让我们为你的函数配置 CloudFront 事件:

  • 登录控制台并打开 Lambda 控制台。
  • 在页面顶部的 亚马逊云科技 区域列表中,选择美国东部(弗吉尼亚北部)。
  • 在函数列表中,选择您的函数:LowThrottleRateFunction。
  • 打开您的函数。
  • 选择 “ 限定词” ,选择 “ 版本” 选项卡,然后选择要添加触发器的编号版本。
  • 复制显示在页面顶部的 ARN,例如:arn: aws: lambda: us-east-1:123456789012: function: lowThrottleRateFunction: 3(最后的数字(在本示例中为 3)是该函数的版本号。)
Note: You can add event only to a numbered version, not to $LATEST.
  • 打开 CloudFront 控制台
  • 在发行版列表中,选择要向其中添加 L @E 函数的发行版的 ID。
  • 选择 “行为” 选项卡。
  • 选中要向其中添加 L @E 函数的缓存行为的复选框,然后选择 “编辑”
  • Lambda 函数关联的 “事件类型” 列表中 ,选择 origin-request
  • 粘贴您之前复制的 Lambda 函数的 ARN。
  • 选择 “是,编辑”。
Note: The function processes requests only after the CloudFront distribution is deployed.

更新 Lambda 函数以限制请求

现在我们已经创建了 Lambda 函数 “requestThrottleFunction”,让我们配置 CloudFront 事件来运行我们的函数来监控 CloudFront 收到的任何请求。

让我们为你的函数配置 CloudFront 事件:

  • 登录控制台并打开 Lambda 控制台。
  • 在页面顶部的 亚马逊云科技 区域列表中,选择美国东部(弗吉尼亚北部)。
  • 在函数列表中,选择您的函数:requestThrottleFunction。
  • 打开您的函数。
  • 选择 “ 限定词” ,选择 “ 版本” 选项卡,然后选择要添加事件的编号版本。
  • 复制显示在页面顶部的 ARN,例如:arn: aws: lambda: us-east-1:123456789012:函数:requestThrottleFunction: 3(最后的数字(在本示例中为 3)是该函数的版本号。)
Note: You can add events only to a numbered version, not to $LATEST.
  • 打开 CloudFront 控制台。
  • 在发行版列表中,选择要向其中添加这些 L @E 函数的发行版的 ID。
  • 选择 “行为” 选项卡。
  • 选中要向其中添加 L @E 函数的缓存行为的复选框,然后选择 “编辑”
  • Lambda 函数关联的 “事件类型” 列表中 ,选择 origin-request
  • 粘贴您之前复制的 Lambda 函数的 ARN。
  • 选择 “是,编辑”。
Note: The function processes requests only after CloudFront distribution is deployed.

测试看门人

要验证 Gatekeeper 的功能,你可以调用配置 Gatekeeper L @E 函数的 URL。确保正确的事件调用函数并且响应对您的应用程序有效,这一点很重要。应重复测试,直到达到 L @E 函数中设置的最低请求速率。这篇博客文章将最低请求速率设置为 30 秒内五次请求。因此,第六个请求将导致显示配置的虚拟页面。看门人使用 L @E 函数中提到的 “ DURATION_FOR_THROTTLE ” 和 “ REQUEST_ COUNT_FOR_THROTTLE” 参数来做出速率限制决策 。因此,我们成功测试并学习了Gatekeeper如何保护您的端点免受低速攻击。

清理

使用以下 亚马逊云科技 CLI 命令从您的账户中删除资源:

$ aws lambda delete-function \
    --function-name LowThrottleRateFunction \
    --region us-east-1
$ aws lambda delete-function \
    --function-name RequestThrottleFunction \
    --region us-east-1
$ aws iam delete-role \
    --role-name EdgeFunctionRole
$ aws dynamodb delete-table \
    --table-name cf-request-counter-table  Note: This step assumes that the names of the resources created are the same as those mentioned in the post. Use the same names provided while creating the resources.

结论

这篇博客文章提供了使用 Lambda @Edge 和 DynamoDB 实现自定义速率限制解决方案 GateKeeper 的全面指南。我们讨论了速率限制的重要性以及在 L @E 函数上配置速率限制所涉及的步骤。通过实施 Gatekeeper,您可以保护您的 Web 应用程序或 API 免受低速率攻击,确保对资源的可靠访问,并降低停机或性能问题的风险。借助 亚马逊云科技 Lambda @Edge 和 DynamoDB,您可以轻松实施和管理快速、可扩展且经济实惠的速率限制解决方案,以满足您的组织需求。

Girish G Nair

Girish G Nair

Girish Nair 是亚马逊网络服务的南非、媒体和娱乐高级媒体专家。Girish 帮助媒体客户采用和优化云解决方案。他的专业知识使组织能够充分利用最新的云技术,例如机器学习和人工智能,来增强内容的制作、分发和查看

Vivek Anurag

Vivek Sinha Anurag

Vivek Anurag 是亚马逊网络服务的高级边缘专家 SA、Edge 和 Elemental。Vivek 天生就是问题解决者,总是渴望代表客户进行创新。他帮助 亚马逊云科技 客户采用 亚马逊云科技 提供的边缘和元素服务