为什么垃圾信息打败了我有速率限制的联系表单
垃圾信息为什么能穿过设有速率限制的联系表单? 因为速率限制只按地址统计请求,其中没有任何环节去问是谁发出的。10月30日和11月2日,两个漏洞扫描器爬取了我的网站,像任何访客一样加载了带有表单的首页,拿到一个有效的CSRF令牌,然后不断提交表单,直到它们转向别处或被拦截。第一天有101次提交通过,第二天有31次,每一次都是应用返回的200;应用在发送成功时返回200,发送失败时也返回200,所以这两个数字是它们最多可能产生的邮件数量。12 我的应用限速器设定为每个地址每分钟5次提交,在这两天分别拒绝了它们830次和31次提交,却放过了101次和31次发送,比一个在该设定下精确运作的限速器所允许的多出47次和9次。3 边缘速率限制对两个扫描器都从未触发:第一次洪泛时,我在最后一次表单提交之后约12分钟才创建这条规则;第二个扫描器的提交经由两个Cloudflare数据中心到达,而各数据中心的计数器是分开的。这一分流是我对第二次沉默最有力的解释:它与每分钟的总数相符,但总数无法证明它。456 在我的工作室网站上,一个蜜罐字段在8天内拦下了52次提交中的42次,放过了发送方没有填写蜜罐的那10次。78 本站的边缘确实有一条能询问“是否有真人在场”的规则,即针对表单路径的托管质询,但它只对列表中的User-Agent字符串触发,而两个扫描器发送的都是浏览器的字符串;另一条以高威胁评分为条件的质询永远不可能触发,因为Cloudflare现在把这个评分固定为零。于是,没有任何东西对每一次提交提出这个问题。910411 从11月3日上午起,凡是可能进入我收件箱的提交,两个表单都会由Turnstile提出这个问题。12
TL;DR
- 两个扫描器,一个收件箱。 10月30日,一个地址在53个一分钟时段内向我的网站发送了76,429个请求;11月2日,另一个地址在22个时段内发送了10,907个。两者都爬取了整个网站,都在路径和User-Agent请求头中放入SQL注入字符串,也都提交了联系表单:第一天1,065次,第二天500次。131412
- 有效的CSRF令牌只能证明客户端持有一个由我的网站签发的令牌。 两天里的每一次发送都带着这样的令牌,因为我的令牌是无状态的,任何获取页面的客户端都能拿到一个。在两次洪泛中,CSRF检查一共只拒绝了4次提交。1516
- 我自己的限速器有漏洞。 它从Cloudflare KV读取计数器、进行比较,然后再读一次,并在另一个请求中写入计数加一;第二次读取失败时会写入1,KV调用失败时则退回到保存在单个进程内存中的计数。这些路径中的任何一条都可能放过超过5次,而日志无法说明是哪一条:在上限为5的情况下,一分钟内发出了18次发送。171
- 边缘速率限制按地址、按数据中心计数。 11月2日18:56到19:05:00 UTC之间,也就是拦截前后的那几分钟,扫描器的85次表单提交经由阿姆斯特丹和伦敦到达,分别为44次和41次,最繁忙的一分钟在两地之间以18比14分开。这一分流是规则保持沉默最可能的原因,但每分钟的总数无法证明这一点。次日上午我做的一次探测证明了这条规则会触发,而且Cloudflare会记录它。518
- 两次拦截都是地址拦截,而且都来得太晚,没能挡住邮件。 10月30日,我在发现问题3.9分钟后、该地址首次提交表单约50分钟后手动拦截了它。11月2日,我的监控器在扫描开始的第一分钟之后8分钟拦截了它。两天里都没有任何发送出现在拦截所在的那一分钟之后:10月30日最后一次发送比拦截早12分钟以上;11月2日,拦截前的几分钟里有26次,拦截所在的那一分钟里有5次,而按分钟统计的数据无法判断这5次是在拦截那一秒之前还是之后。101912
- 蜜罐能抓住填写所有字段的机器人。 在我的工作室网站上,它对52次提交中的42次回应得仿佛提交成功,却什么也没有存储;按到达的小时计算,所有来自Tor出口节点的提交都在其中。被存储下来的10次,也就是最多10封邮件,来自没有填写蜜罐的发送方。720
- 做了哪些改变。 两个表单现在都带有托管模式的Cloudflare Turnstile小组件,在Cloudflare的siteverify API接受该提交的令牌之前,服务器不存储、不发送任何内容,对缺失、超长或无法验证的令牌一律拒绝。Turnstile在访客的浏览器中运行质询,并处理IP地址、TLS指纹和User-Agent等信号;Cloudflare表示它不会接触表单中填写的内容。它挡不住真人手工输入的垃圾信息,也不能取代仍在约束总量的速率限制。122122
1. 那个问题:“是你吗?”
11月2日,我的收件箱被联系表单消息塞满,我的第一个问题是问那个运行我基础设施的智能体会话:是不是我自己的自动化在发这些邮件。不是。发邮件的是一个扫描器,它通过的是本站的联系表单;我工作室网站941apps.com上的表单也在承受自己那股细水长流的提交,不过那天没有一条进入收件箱。238 我的监控器告警和两个表单这三股邮件流,都从我自己邮件域名下的同一个地址发出,所以在收件箱里看起来一模一样。23
邮件主题露了馅。本站的联系处理程序会把每次提交发给我,主题是“Contact Form:”加上发送方在姓名栏里填写的任何内容,于是扫描器的载荷就成了主题行。2423 工作室网站的表单则先把每条消息存入数据库,再发邮件到同一个收件箱。25
我最先问的问题问错了,而正确的问题花了一整天的查询才得到答案:这些表单上的控制措施中,哪一个能分辨扫描器和真人?每一个又为什么放过了其余的?控制措施共有五类。在本站,是带自定义规则的边缘防火墙、边缘速率限制、CSRF令牌,以及应用内的速率限制器。在工作室网站,是同类的边缘规则、边缘速率限制、一个没有拒绝任何已观测到表单提交的自有应用限速器,以及一个蜜罐字段。9257 本文接下来会逐一讨论两次洪泛和那股细流,再逐一讨论这五层,并给出每一层的规则原文或代码行为。
数字从哪里来
下文的每一个计数都来自Cloudflare对这两个区域的请求分析和防火墙事件(于11月3日针对已经结束的时间窗口查询),加上工作室网站的消息表和我的监控器自己的日志。16 一个脚本把这些保存下来的查询结果整理成一份几百行的台账,每一行都附有产生它的规则;本文的数字只取自这份台账及其附录,不取自别处。我当天的笔记与台账不一致时,以台账为准:10月30日的笔记是在洪泛结束几分钟后写的,当时Cloudflare还在摄入最后几分钟的数据,所以计数偏少。1626 Cloudflare对这些数据集的保留时间是“at least 31 days of data on every plan”(所有套餐至少保留31天的数据),所以我可以事后统计一次洪泛,但不能永远这样做。27
有两类数字需要提醒。防火墙事件是抽样,所以我只用它来判断是哪条规则、在什么时间起作用,数量则用请求数据集。16 另外,本站一次返回200的表单提交,要么是一次发送,要么是处理程序同样返回200的失败(这样的失败路径有两条),并不等于一封送达的邮件,而我也没有查看邮件服务商的日志。因此,本文中的每一个邮件数量都是上限。2428
平常是什么样子
在洪泛之前,这个表单很安静。在查询覆盖的8天里,除了两个扫描器之外,另有三个地址成功提交过表单,每个恰好两次,10月27日、28日和29日各一个地址。29 我不知道那些是不是真人。同一地址的两次提交符合双击,也符合会重试的机器人。这种不确定正是整个问题的缩影:日志和控制措施都无法回答。
2. 第一次洪泛:10月30日,手动拦截
第一次洪泛来自一个网络上的单个地址,地区互联网注册机构将该网络登记在BULLIONET FBW NETWORKS SAS名下,注册地为法国。30 它的第一个请求落在17:59 UTC这一分钟(太平洋夏令时10:59),最后一个落在18:51这一分钟(11:51 PDT):53个一分钟时段,向本站发送了76,429个请求。两次独立的查询,一次按分钟统计该地址,一次扫遍我账户中的所有区域,得出的总数一致,而且该地址在此之前和之后都没有发送任何请求。13
扫描的形态
表单只是其中一小部分。在拦截之前,该地址平均每分钟发送1,389.7个请求,在18:36这一分钟达到3,219个的峰值,并且遍历了整个网站:至少2,881条不同的路径,这是下限,因为路径查询触到了行数上限。1331 整个扫描的结果分布如下:13
| 结果 | 请求数 | 占比 |
|---|---|---|
| 已响应(2xx、3xx) | 49,513 | 64.8% |
| 被应用拒绝(4xx),其中10,019个来自网站的智能体关卡 | 18,990 | 24.8% |
| 源站错误(520) | 2,339 | 3.1% |
| 在边缘被拦截(403) | 5,587 | 7.3% |
边缘的7.3%几乎全部来自那次拦截:5,587次拒绝中有5,234次发生在拦截所在的那一分钟之后。13 在此之前的五十分钟里,只有漏洞利用路径规则在起作用。这条规则从第一分钟起就生效了,第一条防火墙事件出现在17:59:53 UTC,但它匹配的是.php和/wp-这类路径子串,而这次扫描大多请求的是真实存在的页面。109
让它成为扫描而不只是爬取的,是它在请求里放了什么。几乎所有流量,至少69,322个请求,使用的是同一个普通的Windows版Chrome 141 User-Agent。另有至少2,755个请求自称Googlebot,而边缘的已验证机器人规则并没有把它们当作Google。32 至少3,154个请求直接在User-Agent请求头里携带SQL注入载荷,涉及至少396种不同的字符串;至少2,680个请求在路径中携带载荷,涉及至少810条不同的路径。3231 这些载荷属于有文档记载的类型。PG_SLEEP(15)、waitfor delay '0:0:15'和XOR(if(now()=sysdate(),sleep(15),0))都是基于时间的探测:PortSwigger的速查表列出了Oracle、Microsoft SQL Server、PostgreSQL和MySQL的延时语法,sqlmap自己的载荷定义里也有同样的函数。3334 PortSwigger说明了它们为什么有效:“As SQL queries are normally processed synchronously by the application, delaying the execution of a SQL query also delays the HTTP response.”(由于应用程序通常同步处理SQL查询,推迟SQL查询的执行也会推迟HTTP响应。)35 一次缓慢的响应几乎证明不了什么;每当载荷要求时就会出现约15秒的延迟,而在不带载荷的对照请求上不出现,这才是可能存在注入的证据。
本站的绘画应用有一个分享端点,扫描器找到了它。它向分享路径发送了320个POST请求,这是查询覆盖的8天里唯一向那里提交的地址,端点拒绝了全部320个请求,没有存储任何内容。在这些请求前后,它还向内嵌载荷的路径变体提交,例如/paint/share'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||'和/paint/(select 198766*667891 from DUAL),网站对这些返回了404。31 该地址还触及了我账户中的另一个区域,向kiradex.app发送了387个GET请求,全部返回200,除此之外没有触及其他区域。13 我的解读是,它顺着本站指向那个网站的链接爬了过去;我没有核实那些链接。
OWASP的自动化威胁目录给这类活动起了一个名字:OAT-014,Vulnerability Scanning(漏洞扫描),“Crawl and fuzz application to identify weaknesses and possible vulnerabilities.”(爬取应用并进行模糊测试,以识别弱点和潜在漏洞。)36 联系表单只是又一个可供模糊测试的输入。
表单,逐分钟
该地址提交了1,065次联系表单,每一次都到达了应用;边缘一次也没有拦下。1 第一次提交和第一次发送出现在18:00 UTC这一分钟(11:00 PDT)。下表列出了表单提交到达应用的每一分钟,按应用返回的状态分类:1
| UTC | PDT | 已发送(200) | CSRF拒绝(403) | 缺少字段(422) | 限速器拒绝(429) |
|---|---|---|---|---|---|
| 18:00 | 11:00 | 8 | |||
| 18:01 | 11:01 | 12 | 133 | ||
| 18:02 | 11:02 | 9 | 148 | ||
| 18:03 | 11:03 | 4 | 4 | 200 | |
| 18:04 | 11:04 | 9 | 89 | ||
| 18:05 | 11:05 | 16 | 164 | ||
| 18:06 | 11:06 | 18 | 85 | ||
| 18:07 | 11:07 | 7 | 11 | ||
| 18:08 | 11:08 | 2 | |||
| 18:09 | 11:09 | 4 | |||
| 18:10 | 11:10 | 9 | |||
| 18:11 | 11:11 | 1 | |||
| 18:12 | 11:12 | 1 | |||
| 18:35 | 11:35 | 1 | |||
| 18:36 | 11:36 | 1 | |||
| 18:37 | 11:37 | 129 |
合计:发送101次,被应用限速器拒绝830次,因缺少必填字段被拒绝130次,被CSRF检查拒绝4次,这4次全都在18:03这一分钟。1 这些429来自应用。当时边缘的速率限制规则还不存在,该地址没有任何速率限制防火墙事件,而在Cloudflare为这些提交记录了源站状态的地方,429都来自源站。1037 422来自FastAPI的表单校验,它在限速器之前运行,所以从未占用限速器的名额;我用一个十行的FastAPI应用验证了这个顺序,缺少必填字段的提交返回422,包装函数一次都没有被调用。38 18:37那一波129次,看起来是扫描器通过逐个删除字段来对表单做模糊测试。
请把“已发送”一栏和限速器对照着看。限速器允许每个地址在每个自然分钟内提交5次,但其中有八个分钟发出的数量超过5次,18:06那一分钟发出了18次。13 第4节会解释原因。
发现,然后拦截
我在11:46 PDT发现了问题,并记下有什么东西在一遍又一遍地冲击网站的联系表单。26 那时距第一次表单提交已经约46分钟,而表单洪泛本身已在11:37 PDT结束。1 我手动把这个地址加入账户级拦截列表,区域的第一条自定义规则本来就会匹配这个列表;该规则的第一条事件出现在18:49:53 UTC,即11:49:53 PDT。10 拦截发生在我发现问题后3.9分钟、第一次表单提交后约50分钟、扫描开始的第一分钟之后50.9分钟。113 101次发送全都在此之前就已发出;最后一次到达应用的表单提交比拦截早约12分钟。1 在拦截所在的那一分钟之后,该地址又发送了5,234个请求,边缘全部拒绝。13
就在同一秒,18:49:53 UTC,我创建了该区域唯一的一条速率限制规则:针对发往联系路径和绘画分享路径的表单POST,每个地址每10秒3次,超出则拦截10秒。9 18:52:38 UTC,我从自己的机器上测试了它。我那次测试的笔记写道,使用浏览器User-Agent、不带有效CSRF令牌、间隔一秒的六次提交“returned 403 403 403 429 429 429 (the app’s CSRF rejection, then the edge’s rate limit)”(返回了403 403 403 429 429 429(先是应用的CSRF拒绝,然后是边缘的速率限制)),而用普通curl发出的提交根本没有到达这条规则,因为一条已有的自定义规则先给它们返回了托管质询。26 我的笔记说这些提交没有带令牌,但正如我11月3日的探测所显示,缺少该字段的提交会在处理程序运行之前就得到422,所以那些403意味着令牌存在但无效。18 防火墙事件与此一致:来自我测试地址的三条速率限制事件,第一条就在那一秒,以及针对我的curl提交的24次托管质询。39 我的测试流量不计入上文的任何数字。
那天我的监控器没有在运行。我在10月31日重新启用了它,而它的流量检测器是在这次洪泛之后才加上的。19 10月30日的拦截,是一个读收件箱的人做的。
10月30日:来自一个地址的76,429个请求,逐分钟;在我手动拦截该地址之前,边缘几乎一个也没有拒绝。1310
3. 第二次洪泛:11月2日,由我的监控器拦截
第二个地址属于荷兰的托管服务商NovoServe B.V.。40 它在07:49 UTC(11月1日太平洋标准时间23:49)发送了两个GET请求,随后11.1小时没有任何动静。正式的扫描从18:56 UTC这一分钟持续到19:17这一分钟,即10:56到11:17 PST:22个一分钟时段,10,907个请求,加上早先那两个GET是10,909个。四次独立查询得出的总数一致。14
它看起来像是同一个工具。它在9,725个请求中使用了与10月30日那个地址完全相同的Windows版Chrome 141 User-Agent字符串,在942个请求中使用了相同的Googlebot字符串,发送了相同类型的注入,也只触及了同样的两个区域,向kiradex.app发送了368个GET。414214 两个国家、两个网络上的两个地址,证明不了是谁在操作它们,所以我只说到“看起来像”为止。
时间线,两个时区
| UTC | PST | 发生了什么 |
|---|---|---|
| 18:56 | 10:56 | 扫描开始:这一分钟内29个请求。1419 |
| 18:57:00 | 10:57:00 | 我的监控器开始一次运行。它的时间窗口在18:57:22结束,所以只看到约一分钟的扫描,什么也没发现。19 |
| 18:57 | 10:57 | 拦截前最繁忙的一分钟,637个请求。边缘的漏洞利用路径规则在18:57:11开始拦截。144 |
| 18:58 | 10:58 | 最初的三次表单提交。应用三次都发送了。2 |
| 19:00 | 11:00 | 又有四次表单提交,四次都发送了。2 |
| 19:02 | 11:02 | 32次表单提交到达应用:发送9次,被应用限速器拒绝23次。2 |
| 19:02:46 | 11:02:46 | 监控器的下一次运行开始。19 |
| 19:03 | 11:03 | 发送10次,拒绝8次。2 |
| 19:03:53 | 11:03:53 | 我的监控器针对该地址的1,914个请求,给出一条严重级别的请求洪泛检测结果。19 |
| 19:04:01 | 11:04:01 | 我的监控器把该地址加入账户拦截列表。19 |
| 19:04:03 | 11:04:03 | 拦截列表规则的第一条事件。4 |
| 19:04 | 11:04 | 最后五次发送,以及在边缘被拒绝的22次表单提交,全都在拦截所在的这一分钟内;按分钟的时段无法说明孰先孰后。2 |
| 19:07 | 11:07 | 整个扫描中最繁忙的一分钟,1,107个请求,全部被拦截。14 |
| 19:17 | 11:17 | 最后一分钟,300个请求,全部被拦截。14 |
我的监控器的八分钟
我的监控器在扫描开始的第一分钟之后7.9分钟给出第一条检测结果,拦截在其后8.0分钟生效,也就是第一条防火墙事件之后6.8分钟。194 到那时,拦截前的几分钟里已有26次发送发出,最后5次落在拦截所在的那一分钟内,按分钟的数据无法把它们放在拦截那一秒之前或之后;此后的各分钟里没有任何发送。2 八分钟对拦截来说算快,对收件箱来说则太慢。
那次在10:57:00 PST开始的运行,只把扫描纳入视野约一分钟,在它的窗口内,该地址发出了29到666个请求;上限数字统计的是18:57整整一分钟,而窗口只看到了其中的22秒。检测器没有记录任何结果。19 11:02:46 PST的下一次运行发现了洪泛并实施了拦截,而这次运行也说明了第一次为什么可能漏掉。它的窗口在向Cloudflare查询的那一刻就结束了,最新的事件还没有到达:它的日志记录了该地址的1,914个请求,而我在11月3日用完整数据重放同一个五分钟窗口,计数是2,143个;这次重放连同命令和完整输出一起保存了下来,其计数是台账附录中的行。1943 重放还给出了一条实时运行从未给出的表单洪泛检测结果,来自39次提交,其中23次被拒绝,严重级别为高,它本会成为每日摘要中的一行,而不是一封邮件或一次拦截。43 我重放的是第二次运行的窗口,而不是第一次的,所以对10:57 PST那次运行而言,同样的延迟只是可能的原因,并未经过测量。当天,我修改了监控器的窗口,让它在每次运行前90秒结束,并从上一个窗口结束的地方开始。43 针对该地址一共触发了六条检测结果,我的监控器把它们全部并入每日摘要,而没有发邮件给我,因为该地址已经在它的拦截台账上了。19 我的监控器应该如何告警,以及从它的角度看这个扫描器,是告警疲劳:我的安全监控器一天发来82封邮件这篇文章的主题;对本文来说,重要的是拦截是一种反应,而这次反应来得比31次发送中的至少26次都晚。
表单,按状态
该地址提交了500次表单。2
| UTC | PST | 已发送(200) | 限速器拒绝(429) | 边缘拦截(403) |
|---|---|---|---|---|
| 18:58 | 10:58 | 3 | ||
| 19:00 | 11:00 | 4 | ||
| 19:02 | 11:02 | 9 | 23 | |
| 19:03 | 11:03 | 10 | 8 | |
| 19:04 | 11:04 | 5 | 22 | |
| 19:05 | 11:05 | 39 | ||
| 19:06 | 11:06 | 19 | ||
| 19:07 | 11:07 | 10 | ||
| 19:08 | 11:08 | 13 | ||
| 19:09 | 11:09 | 31 | ||
| 19:10 | 11:10 | 41 | ||
| 19:11 | 11:11 | 47 | ||
| 19:12 | 11:12 | 97 | ||
| 19:13 | 11:13 | 29 | ||
| 19:14 | 11:14 | 23 | ||
| 19:15 | 11:15 | 21 | ||
| 19:16 | 11:16 | 28 | ||
| 19:17 | 11:17 | 18 |
合计:发送31次,被应用限速器拒绝31次,在边缘被拒绝438次,后者全部发生在拦截所在的那一分钟或之后。所以到达应用的提交是62次。2 每一次发送都证明扫描器持有有效的CSRF令牌,因为处理程序在发送之前会检查令牌。429则什么也证明不了,因为限速器在处理程序读取令牌之前就拒绝了提交。2416 在Cloudflare为这些429记录了源站状态的地方,它们都来自源站,即应用限速器,而该地址没有任何速率限制防火墙事件。374
扫描的其余部分
按结果划分,11月2日的扫描分布如下:14
| 结果 | 请求数 | 占比 |
|---|---|---|
| 已响应(2xx、3xx) | 1,388 | 12.7% |
| 被应用拒绝(4xx),其中420个来自智能体关卡 | 1,473 | 13.5% |
| 源站错误(520) | 116 | 1.1% |
| 在边缘被拦截(403) | 7,930 | 72.7% |
边缘的72.7%大部分来自那次拦截:拦截所在那一分钟之前有2,986个请求,之后有7,445个,而网站对后者一个也没有响应。14 这一天的路径查询是完整的:2,173条不同的路径,其中542条路径上的778个请求携带SQL注入载荷。44 User-Agent查询也是完整的,共73种不同的字符串。除了那个普通的Chrome字符串和自称Googlebot的字符串,扫描器还在149个请求中发送了54种不同的SQL载荷,在78个请求中发送了3种破坏引号的字符串,并发送了12个标记令牌,每个一次。41
其中一些字符串值得一看,因为它们显示了扫描器在问什么。(select 198766*667891 from DUAL)是一个无表select中的算术表达式;正如PortSwigger的速查表所示,FROM dual是Oracle为此提供的语法。33 if(now()=sysdate(),sleep(15),0)与同一速查表上的条件时间延迟具有相同的IF(condition, SLEEP(n), x)形式。33 @@qkLpP是一个@@前缀(速查表在Microsoft和MySQL的SELECT @@version中使用的那个)后面跟着五个字符,扫描器发送了十几个类似的字符串。3341 OWASP的测试指南点出了这些字符串所服务的两种技术:基于错误的注入,即“forces the database to generate an error”(迫使数据库产生错误),以及时间延迟,即“use database commands (e.g. sleep) to delay answers in conditional queries.”(使用数据库命令(例如sleep)在条件查询中延迟响应。)45 我获取的页面中没有一个记载了这些确切的字符串,所以我只描述它们,不把它们归于某个特定工具。
11月2日:来自一个地址的10,907个请求,逐分钟,标出了我的监控器的两次运行,以及它在11:04:01 PST实施的拦截。1419
4. 五个层次,以及每一层能分辨什么
在这些机器人和我的收件箱之间,有五类控制措施。对每一类,我都问同一个问题:它能分辨机器人和真人吗?边缘规则中有一条托管质询可以向访客提出这个问题,但只针对User-Agent字符串在其列表中的请求,而扫描器的字符串不在列表上;另一条以威胁评分为条件的质询永远不可能触发,所以没有任何东西对每一次提交提出这个问题。这些控制措施中,两个负责计数,一个匹配名称和列表,一个证明客户端持有网站签发的令牌,还有一个只抓得住会填写隐藏字段的机器人。下面逐一说明每一层检查什么、拦下了什么,以及它放过其余部分的确切原因。
边缘自定义规则
检查什么。 在本站,第一条自定义规则拦截账户级列表中的任何地址,外加按ASN指定的七个托管网络。另一条拦截.php、/wp-和/.env这类漏洞利用路径子串。还有两条对联系表单和管理路径返回托管质询:一条针对空的User-Agent,或包含curl、wget、python-requests、httpx、scrapy、HeadlessChrome、PhantomJS或Selenium的User-Agent;另一条针对Cloudflare的高威胁评分。工作室网站有同类规则,两条质询都作用于全站,另有一条针对API路径的拦截规则,但豁免了联系表单。9 两个网站各有一条威胁评分质询,都是死规则:Cloudflare的字段参考文档说,这个评分曾经在0到100之间,而“Now, the threat score is always 0 (zero).”(现在,威胁评分始终为0(零)。)要求高评分的规则什么也匹配不到;我在读到这一点的11月3日当天停用了这两条规则,User-Agent质询则保留。1146 Cloudflare的Free套餐允许五条自定义规则,它们“work as customized web application firewall (WAF) rules that you can use to perform actions like Block or Managed Challenge on incoming requests.”(作为定制的Web应用防火墙(WAF)规则运作,可用于对传入请求执行Block或Managed Challenge等操作。)47
拦下了什么。 两天里,漏洞利用路径从第一分钟起就被拦下。104 10月30日,没有拦下任何一次表单提交。110 11月2日,拦下了我的监控器实施拦截之后的438次表单提交。2 在工作室网站上,没有拦下任何一次表单提交:8天里,防火墙没有对表单的POST记录任何事件。7
其余的为什么通过了。 没有一条规则去看请求本身是什么。列表规则只有在某样东西把一个地址放进列表之后才会匹配它,10月30日是一个人放的,11月2日是我的监控器放的。91019 User-Agent质询匹配的是字符串,而两个扫描器发送的都是浏览器字符串,第一天至少69,322次,第二天9,725次。3241 User-Agent质询对自报身份的工具确实有效:我10月30日用curl做的测试招来了24次托管质询。39 威胁评分质询对谁都不可能触发,而防火墙也没有为这两个地址记录任何针对表单的质询。11104 Tor出口节点以及七个指定ASN之外的托管网络,不在任何列表上。
边缘规则可以把已知地址、已知网络或诚实的工具与其余流量区分开,而它的质询只会向其表达式选中的请求询问是否有真人在场;它无法把扫描器发送的浏览器字符串与真正的浏览器区分开。
边缘速率限制
检查什么。 规则原文很短:9
(http.request.method eq "POST" and http.request.uri.path in {"/api/contact" "/paint/share"})
它按Cloudflare数据中心和客户端地址统计匹配的请求,每10秒允许3次,超出则拦截10秒。平均摊开的话,这个上限名义上相当于每个地址、每个数据中心每分钟18次;规则本身按10秒周期计数。916 Free套餐固定了周期、超时和计数键,并且每个区域只给一条这样的规则:10秒的计数周期、10秒的缓解超时、按IP计数。48 数据中心也不是可选项。Cloudflare的文档写道:“Every rate limiting rule includes the Cloudflare data center ID (cf.colo.id) as a mandatory characteristic”(每条速率限制规则都把Cloudflare数据中心ID(cf.colo.id)作为必需特征),以及“Counters are not shared across data centers, with the exception of data centers associated with the same geographical location.”(除了与同一地理位置关联的数据中心之外,计数器不会在数据中心之间共享。)6
拦下了什么。 两天里,扫描器的请求一个也没有拦下。在事件时段内,它唯一的事件就是我自己测试产生的那三条。10439 工作室网站有一条自己的全站速率限制规则,以同样的方式统计除Cloudflare列为已验证机器人之外的所有客户端请求,它没有拒绝这股细流(下文称“细流”)中的任何一次表单提交:8天里,它的防火墙没有对表单提交记录任何事件。那条规则统计的是一个地址向该网站发送的所有此类请求,而我的数据只统计表单提交,所以无法衡量计数器还剩多少余量。97
其余的为什么通过了。 10月30日,这条规则直到18:49:53 UTC才存在,与手动拦截的第一条事件是同一秒,距最后一次表单提交到达应用约12分钟。91 那个扫描器的1,065次提交全都经由同一个数据中心,巴黎,所以如果规则当时已经存在,它们会共用一个计数器。5 11月2日规则已经存在,而扫描器的表单提交在18:56到19:05:00 UTC之间经由两个数据中心到达:阿姆斯特丹44次,伦敦41次。在最繁忙的19:02那一分钟,伦敦承载了18次,阿姆斯特丹14次。5 一分钟18次名义上相当于规则的上限,但我的数据是每分钟的总数,而不是到达时间,所以无法说明某个数据中心的任何一个10秒周期内是否超过3次。Cloudflare对精度说得很直白:“Rate limiting rules are not designed to allow a precise number of requests to reach your origin server. There may be a delay of up to a few seconds between detecting a request and updating rate counters.”(速率限制规则并非为了让精确数量的请求到达您的源服务器而设计。从检测到请求到更新速率计数器之间,可能存在最多几秒的延迟。)48 从第一次提交到达应用到最后一次,在这七分钟的跨度里,扫描器平均每分钟8.86次。2 我当天的笔记称之为“about nine a minute”(大约每分钟九次),然后就停在了那里;跨数据中心的分流,正是笔记漏掉的部分。23
我想证明这条规则至少是能工作的,所以在11月3日上午从自己的机器上对它做了探测。06:36:49 PST,我以0.4秒的间隔发送了十个不带CSRF令牌的POST:前四个返回422,因为缺少必填字段而被FastAPI拒绝,随后六个返回429。06:38:03 PST,我发送了八个字段齐全的请求:前三个返回403,即源站的CSRF拒绝,随后五个返回429,响应体是Cloudflare的“error code: 1015”。在那几分钟里,防火墙对表单记录了11条事件,全部是速率限制规则的拦截。18 这条规则会触发,Cloudflare也会记录它。我对11月2日的解读是一个假设:由于被分到两个计数器上,且在每个计数器上都接近名义上限运行,扫描器一个也没有触发。这是与每分钟总数相符的最有力解释,而不是证明;总数无法显示每一分钟内的提交间隔,而且Cloudflare也承认,在计数器更新期间,超额请求可能会通过。548
速率限制可以分辨快和慢,但仅限于一个数据中心上的一个地址;它分辨不出是谁在发送。
CSRF令牌
检查什么。 本站的表单带有一个渲染在页面中的隐藏令牌,由三部分组成:时间戳、随机值,以及对前两者的HMAC签名。签名匹配且时间戳不超过一小时,服务器就接受它。15 工作室网站的表单没有CSRF令牌。25
拦下了什么。 10月30日拦下4次提交,全都在18:03这一分钟;11月2日一次也没有。12
其余的为什么通过了。 这个令牌是无状态的。它不与会话、Cookie或地址绑定,所以任何获取页面的客户端都能拿到一个有效令牌,任何从别的客户端那里拿到令牌的客户端,也可以在其有效期内出示它。两个扫描器确实获取了网站的页面,请求日志显示了这一点。这项检查所能说明的更窄:两天里的每一次发送都带着一个由我的网站签发、有效且未过期的令牌,因为处理程序在发送之前会检查令牌。1513142416 CSRF令牌的存在,是为了阻止别的网站让访客的浏览器提交我的表单。它从来就不是为了阻止一个想自己提交表单的客户端,而它也确实没有阻止。
同一项检查还有第二个缺陷,而它落在了人身上,而不是机器人身上。令牌校验失败时,处理程序会抛出一个JSON 403,而HTMX表单从不显示它,所以一个令牌过期的人点击发送后,什么也看不到。12 我修复了这个问题,不过我的第一次修复同样没有生效;第6节会说明原委。
有效的CSRF令牌证明客户端持有一个由我的网站签发且未过期的令牌;它无法证明是谁填写了表单,甚至无法证明这个客户端加载过页面。
应用速率限制器
检查什么。 本站的处理程序位于一个装饰器之后,该装饰器在Cloudflare KV中按客户端地址、按自然分钟统计表单提交(另有内存中的后备方案),超过五次就返回429拒绝。17 工作室网站为匿名客户端准备了一个按进程运行的内存限速器,按地址和客户端分类计数(分类是限速器根据User-Agent和地址分配的等级),覆盖除健康检查和静态文件之外的所有路径。25
拦下了什么。 10月30日拦下830次提交,11月2日拦下31次。在工作室网站上一次也没有。127
其余的为什么通过了。 按设计,它对任何人每分钟都放行五次,而且由于窗口是自然分钟,跨越分钟边界的突发流量会得到两份额度。17 这一点我是知道的。在把计数和代码对照着读之前我不知道的是,它会漏,而且代码中至少有三条会漏的路径。限速器通过HTTP从KV读取计数器,与上限比较,然后在另一个请求中写入计数加一,所以两个都在对方写入之前完成读取的提交,会双双通过。递增操作在写入前会再读一次计数器,如果第二次读取失败,写入就会把计数重置为一,这一分钟就从头开始。而每当KV读取没有返回任何东西或写入失败时,判断就会退回到保存在单个进程内存中的列表,其他进程并不共享这个列表。17
计数暴露了这个漏洞。10月30日,有八个分钟的发送超过五次,18:06那一分钟达到18次。1 一个每分钟五次的精确限速器,在扫描器提交的那些分钟里最多只会允许54次发送,而实际发出了101次,超出上限47次。11月2日,19:02和19:03两分钟分别放过了9次和10次;一个精确的限速器在它看到的62次提交中最多只会允许22次,而实际发出了31次,超出上限9次。3 计数并不能说明是哪条路径漏了。我的解读是:要么多个提交在任何一个写入之前读到了同一个计数,要么KV调用在负载下失败,导致计数被重置或判断被交给各进程的列表,或者是这些情况的某种组合;我手上的日志只记录了应用返回的状态,没有记录是哪条路径作出的判断,所以无法区分这些路径。173 无论哪条路径,补救办法都一样,而且不是改一行代码就能解决:需要一个支持原子递增的计数存储,而先读KV再单独写入的做法提供不了这一点。这个漏洞是我的bug。它也让本文的论点比我刻意构造的还要尖锐:一个号称五次却放过十八次的控制措施,从来就不可能是决定谁可以给我写信的那个东西。
在工作室网站上,应用限速器没有拒绝任何一次已观测到的表单提交,最活跃的地址一小时内发送了13次。它的计数器会把同一客户端分类下该地址发往其他所有路径的请求也算进去,而我的数据只涵盖表单提交,所以这些计数无法衡量那些计数器还有多少余量。725
速率限制器在请求轮流到来时能分辨五次和六次;在任何数量下,它都分辨不出真人和脚本。
蜜罐
检查什么。 工作室网站的表单有一个名为website、真人永远看不到的文本输入框:它带有tabindex="-1"和aria-hidden,所以键盘和屏幕阅读器都不会停在它上面。如果它回传时带有任何内容,处理程序就返回真人会看到的同一条成功消息,但不存储也不发送任何东西。25 自从我6月5日搭建这个表单以来,这个分支就一直在处理程序里。25 本站的表单没有蜜罐。24
Ned Batchelder在2007年描述过这种技术:“These fields are called honeypots and are validated when the form data is posted. If they contain any text, then the submitter must be a bot”(这些字段称为蜜罐,在表单数据提交时进行校验。如果其中有任何文本,那么提交者一定是机器人)。49 他给出的有效原因是,填表机器人“cannot distinguish invisible fields from visible ones.”(无法区分不可见字段和可见字段。)49
拦下了什么。 8天里52次提交中的42次,即80.8%,按到达的小时计算,包括每一次来自Tor出口节点的提交;按小时和消息模式计算,还包括一个自称SQL爬虫的来源在一次突发中发出的全部13次提交。720
其余的为什么通过了。 只填写可见字段的机器人从不碰website,而那些提交被存储、因而可能进入我收件箱的发送方,都把它留空了。825 说他们是有意只填可见字段,这是我的解读;处理程序只记录了隐藏字段回传时为空。Batchelder早就料到了这一点:“In theory, it is possible for a spambot to defeat any of these measures. But in practice, bots are very stupid”(理论上,垃圾信息机器人可以攻破这些措施中的任何一种。但实际上,机器人非常愚蠢)。49 有些机器人没那么愚蠢,而兜售潜在客户开发服务的那些,是其中最不愚蠢的。
蜜罐只能证明客户端填写了一个真人看不到的字段;一个空的蜜罐什么也证明不了。
每股流量走了多远:两个扫描器在本站的提交,以及工作室网站上8天的细流。1278
这五层的共同点
把这五句结论放在一起读,缺口就一目了然。速率限制约束每个地址的数量。CSRF令牌证明客户端持有网站签发的令牌。蜜罐证明客户端填写了隐藏字段。边缘规则认得名称和列表,而其中那条能询问另一端是否有真人的规则,即User-Agent质询,只询问User-Agent字符串在其列表中的请求;旁边那条威胁评分质询则永远不可能触发。两个扫描器都没有匹配上,所以没有任何东西询问每一次提交,而扫描器做的事情,没有一件是浏览器做不到的:它们获取页面,保存其中的令牌,并以限制措施会放过一部分的速度提交。OWASP的撞库防护指南针对另一种表单提出了同样的观点,建议采用“not rely on a single predictable volume limit”(不依赖单一可预测数量限制)的防御。50
5. 工作室网站上的细流
工作室网站的表单从未遭遇洪泛。它遇到的是细流,一种零星不断的提交,而细流是完全不同的东西。这里的时间窗口,从8天查询中第一个有表单提交的小时,即10月26日17:00 UTC,到我保存数据的那一刻,即11月3日13:59:50 UTC;第一天和最后一天只覆盖了一部分。7
提交与已存储的行
在这个窗口内,表单收到52个POST,全部以200回应。同一窗口内,消息表新增了10行。由于处理程序中唯一返回200却不存储任何内容的路径就是蜜罐分支,蜜罐拦下了其余42次,即80.8%。7825 按UTC日期:78
| 日期(UTC) | 提交(200) | 已存储的行 | 被蜜罐拦下 |
|---|---|---|---|
| 10月26日(部分) | 4 | 2 | 2 |
| 10月27日 | 5 | 0 | 5 |
| 10月28日 | 3 | 2 | 1 |
| 10月29日 | 5 | 1 | 4 |
| 10月30日 | 16 | 1 | 15 |
| 10月31日 | 8 | 2 | 6 |
| 11月1日 | 4 | 0 | 4 |
| 11月2日 | 5 | 0 | 5 |
| 11月3日(部分) | 2 | 2 | 0 |
10月30日的高峰来自英国的一个地址,它在一小时内用User-Agent Mozilla/5.0 (compatible; SQLSpider/1.5)发送了13次提交。按小时和消息模式判断,消息表一次也没有存储它们:那个小时里存储的那一行是一条潜在客户开发推销,属于同一小时内也在提交的那个美国地址的模式。7820
谁在发送
Cloudflare给来自Tor网络的请求打上一个特殊的国家代码T1。它的请求头参考文档写道“T1 - Used for clients using the Tor network”(T1:用于使用Tor网络的客户端),它的IP Access规则页面写道“T1: Tor exit nodes (country name: Tor)”(T1:Tor出口节点(国家名称:Tor))。5152 按这个代码,52次提交中有23次来自Tor,占44.2%,35个不同地址中有23个是Tor出口节点,占65.7%。7 作为交叉核对,我把每一个提交过表单的地址与Tor Project的批量出口列表进行了比对,该项目称这份列表为“a nearly real time authoritative source for Tor exits that allow contacting your server on port 80”(一个近乎实时的权威来源,列出允许通过80端口连接您服务器的Tor出口)。Cloudflare标记为T1的23个地址全部出现在我11月3日获取的列表上,其余12个地址没有一个在上面。5354 两个来源是一致的。不过那份列表反映的是11月3日的出口节点,所以它只能印证当天的代码,并不能证明在每次提交到达的那一天,哪些地址是出口节点。该项目的CollecTor档案保存了过去的出口列表,可以确定事件当天的归属;我没有查询它。55
这个窗口内的大多数地址只提交了一次:35个中有32个,占91.4%,所有Tor出口节点都在其中。7 提交超过一次的三个地址是:发送13次的SQL爬虫、一个在一小时内发送两次的波兰地址,以及一个发送五次的美国地址。最后这个地址的五次提交,恰好落在消息表在该窗口内存储的五条潜在客户开发推销所在的五个小时里,而且它每次都更换User-Agent:先是Linux上的Chrome 135,然后是Firefox 128、137、138,再回到137。720
User-Agent也沿着同一条线分开。在Tor提交中,18次声称是Android设备,5次声称是桌面版Chrome,23次中有21次声称是Chrome 81或更早的版本。非Tor提交声称的则是新得多的桌面浏览器:10次是125到135版的桌面Chrome,6次是128到138版的Firefox,另有那个爬虫以自己名义发出的13次。7
谁被存储了
两拨发送方在蜜罐处分道。窗口内被存储的行,没有一行落在只有Tor提交的小时里,全部10行都落在有非Tor提交的小时里。23次Tor提交中有22次落在完全没有存储行的小时里,第23次与那个美国潜在客户开发地址同处一个小时。20 我之所以按小时匹配,是因为请求日志和消息表没有共同的请求ID,所以这种匹配是一种推断:按到达的小时计算,窗口内每一次来自Tor的提交都触发了蜜罐。20 被存储下来、因而可能进入收件箱的,来自Tor以外的地址,它们发送较新的桌面浏览器字符串,并把蜜罐留空。
这些数字符合表单垃圾信息的老套路。Batchelder在2007年的分类把垃圾信息制造者分为“Playback spambots, form-filling spambots, and humans.”(回放型垃圾信息机器人、填表型垃圾信息机器人,以及人类。)49 Tor细流的表现像是填写所有字段、掉进蜜罐的填表型。成功通过的发送方,表现得更像坐在键盘前的人,或者一个为了看起来像人而打造的脚本。OWASP把这类活动归入OAT-017,Spamming(垃圾信息):“Malicious or questionable information addition to public or private content, or messages.”(在公开或私有内容或消息中添加恶意或可疑信息。)56 OWASP还明确排除了一种情况:“The mass abuse of broken form-to-email and form-to-SMS functions, to send messages to unintended recipients, is not included in this threat event”(大规模滥用有缺陷的表单转邮件和表单转短信功能、向非预期收件人发送消息,不包括在此威胁事件中)。56 我的表单只给我自己发邮件,所以它们属于被包括的情况,即发给网站所有者的垃圾信息,而不是被排除的那种。
细流在设计上也能击败速率限制。OWASP的撞库防护指南点出了把请求分散到大量地址上的效果:“This may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low”(这可能同时击败IP拦截列表和速率限制,因为每个IP的请求量可能保持在相对较低的水平)。50 每个地址一次提交,已经是数量所能达到的最低水平。
存储的消息说了什么
消息表的记录最早可追溯到6月6日,我读取时共有108行;最新的一条于11月3日07:24 UTC到达。8 我用固定规则按模式对它们分类,以第一个匹配的规则为准:57
| 模式 | 全表 | 10月 |
|---|---|---|
| “Hi, I wanted to know your price”(你好,我想了解你们的价格),每行一种语言,同一个发送者名称 | 49 | 10 |
| 供应商推销:网站线索、Instagram增长、讲解视频、SEO、B2B数据、目录收录、设计 | 22 | 4 |
| 奖品诱饵:头奖、促销码、PlayStation、兰博基尼、劳斯莱斯链接,同一个发送者名称 | 16 | 3 |
| telegra.ph上的“$50,000,000 per month”(每月5,000万美元)链接 | 9 | 9 |
| 潜在客户开发推销:“If I were a real customer, how soon would I get a call back?”(如果我是真正的客户,多久能接到回电?) | 6 | 5 |
| 其他:两串乱码、一个订阅邮件列表的请求、一条让我去别处写信的留言 | 4 | 1 |
| 9月21日我对邮件通知功能的测试 | 1 | 0 |
| 按我的解读,一次真正的咨询 | 1 | 0 |
其中有几类值得细看。那九行$50,000,000,用的是同一个发送者名称和九个不同的发件地址,每一条都是同一个标题的小小变体。8 价格问题在全表中以25种语言出现:南非荷兰语、亚美尼亚语、巴斯克语、白俄罗斯语、孟加拉语、保加利亚语、加泰罗尼亚语、克罗地亚语、丹麦语、英语、格鲁吉亚语、希腊语、匈牙利语、冰岛语、印度尼西亚语、爱尔兰语、意大利语、拉丁语、拉脱维亚语、立陶宛语、卢森堡语、西班牙语、越南语、威尔士语和祖鲁语。仅在10月,它就以九种语言出现:亚美尼亚语、巴斯克语、丹麦语、印度尼西亚语、拉丁语、拉脱维亚语、立陶宛语、西班牙语和越南语。57 其中有两种语言的判断出自我手,而且很难说准:我判断为克罗地亚语的那句话可能是波斯尼亚语,我判断为印度尼西亚语的那句可能是马来语。57 有人正拿着一本常用语手册,一种语言接一种语言地往联系表单里灌。
频率是细流的另一个特征。10月共存储32行,平均每天1.03行,分布在31天中的21天里,任何一天都不超过三行。8 每天一次,任何按地址计数的速率限制都不会被触发。
哪些进入了收件箱
这个表单直到9月21日17:46:47 UTC才开始给工作室收件箱发邮件。从那以后,它存储并因此发送了43行,这是一个上限,因为通知调用是尽力而为的,而我也没有核实部署时间;在那之前存储的65行从未发送过邮件。258 证据窗口内的每一行都是垃圾信息。57
除了我自己的测试,全表只有一行不是垃圾信息。9月14日,工作室某款产品的一位客户写信询问一个具体的错误,语气是一个真实的人,而这条消息就这样躺在表里,无人阅读。57 一周后我把表单接到收件箱时,提交信息用朴素的话写明了原因:“a paying customer’s message sat unread in D1 for a week”(一位付费客户的消息在D1里躺了一周无人阅读)。25 这条消息就是通知功能存在的理由,也是我不能靠关掉通知来解决垃圾信息的理由。在这里我不透露这位发送者的身份;重点在于,一个真实的人和那本常用语手册同在一张表里。
6. 什么是机器人检查,以及上线了什么
用Cloudflare自己的话说Turnstile
Cloudflare把Turnstile称为它的“smart CAPTCHA alternative”(智能CAPTCHA替代方案),并说它“can be embedded into any website without sending traffic through Cloudflare and works without showing visitors a CAPTCHA.”(无需让流量经过Cloudflare即可嵌入任何网站,并且无需向访客展示CAPTCHA即可工作。)21 回答我的问题的,是它的机制部分。概述页面写道:“First, we run a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment,”(首先,我们运行一系列小型、无需交互的JavaScript质询,以收集有关访客或浏览器环境的信号),以及“These challenges include proof-of-work (computational puzzles), proof-of-space, probing for web APIs, and various other challenges for detecting browser-quirks and human behavior.”(这些质询包括工作量证明(计算谜题)、空间证明、Web API探测,以及其他各种用于检测浏览器特性和人类行为的质询。)21 速率限制问的是有多少。CSRF令牌问的是客户端是否持有网站签发的令牌。Turnstile问的是另一端是什么样的客户端。我的User-Agent质询也能问这个问题,但只针对User-Agent字符串在其列表中的请求,而我的威胁评分质询永远不可能触发;这里真正要紧的区别,是选择性触发的质询与对每一次提交都要求的验证之间的区别。
这个小组件有三种模式。我用的是托管模式,Cloudflare的描述是“Automatically chooses between non-interactive or checkbox challenge based on visitor risk level. No images or text to decipher.”(根据访客风险等级,自动在无交互质询和复选框质询之间选择。无需辨认图片或文字。)58 通俗地说,访客只会看到一个简短的检查,除非信号需要更多,否则别无其他;需要时,会出现一个可勾选的方框。隐形模式连这个也隐藏起来,而Cloudflare为此设了条件:启用它的网站必须在自己的隐私政策中提及Cloudflare的Turnstile Privacy Addendum。58 Turnstile有自己的免费套餐,最多20个小组件,质询次数不限,Cloudflare说它“can be used independently without requiring other Cloudflare services.”(可以独立使用,无需其他Cloudflare服务。)59
服务器那一半,真正起作用的那一半
单靠小组件,对表单毫无作用,Cloudflare在其服务端指南的开头就这样说:“You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms.”(您必须调用Siteverify API才能完成Turnstile的实现。仅靠客户端小组件无法保护您的表单。)接着说得更直白:“Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge.”(令牌可以伪造。攻击者无需完成质询,就能向您的表单端点提交任意字符串。)60 服务器把令牌连同自己的密钥POST到https://challenges.cloudflare.com/turnstile/v0/siteverify,还可以把访客的IP地址作为remoteip一并提交。60
令牌的两个属性决定了围绕它的代码的形态。“Each token is valid for 300 seconds (5 minutes) after generation.”(每个令牌在生成后300秒(5分钟)内有效。)以及“Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the timeout-or-duplicate error code.”(令牌是一次性的。每个令牌只能验证一次。重放的令牌会以timeout-or-duplicate错误代码被拒绝。)60 一个扫描器即便搞到一个令牌,也无法用它提交1,065次表单。Cloudflare还把令牌长度上限定为2,048个字符,并要求服务器“Validate the action and hostname when specified.”(在指定时验证action和hostname。)60 同一页面上有一处需要注意:它在一处把过期令牌对应到timeout-or-duplicate,而在错误表中又把invalid-input-response描述为“Token is invalid, malformed, or expired”(令牌无效、格式错误或已过期),所以服务器应当把任何失败都视为拒绝,而不是把逻辑绑定在某一个代码上。60
它处理什么,用Cloudflare自己的话说
Cloudflare并没有声称Turnstile不使用任何个人数据,我也不会这样声称。它的Turnstile隐私附录点明了这些信号:“Cloudflare Turnstile processes a variety of client-side signals (“Signals”) such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin.”(Cloudflare Turnstile会处理各种客户端信号(“Signals”),例如客户端IP地址、TLS指纹、User-Agent请求头,以及Sitekey和相关来源。)它说明了目的:“The purpose of collecting these Signals is not to identify, profile or target any individuals but solely to detect and block bots.”(收集这些Signals的目的不是识别、画像或针对任何个人,而仅仅是检测和拦截机器人。)它还说Cloudflare也用这些信号改进产品:“Cloudflare is a data controller of Signals that we process to improve Turnstile’s bot detection capabilities.”(对于我们为改进Turnstile机器人检测能力而处理的Signals,Cloudflare是数据控制者。)22 Cloudflare明确表示不碰的,是内容:“Turnstile does not access, store, or transmit user communications, form entries, or other page inputs.”(Turnstile不会访问、存储或传输用户通信、表单填写内容或其他页面输入。)21 产品页面补充道:“Turnstile never harvests data for ad retargeting.”(Turnstile绝不会为广告再营销收集数据。)61 我的解读是:Turnstile看到的,是我的边缘本来就为每个请求记录的同类连接数据,而人在我的表单里输入的内容,它从来看不到。
它需要的CSP
Turnstile从Cloudflare的质询来源加载,所以内容安全策略必须允许它。Cloudflare列出了两条指令:“script-src: https://challenges.cloudflare.com”和“frame-src: https://challenges.cloudflare.com”。62 我的两个区域都通过Cloudflare的响应头规则设置策略。在本站,frame列表本来就存在,于是加上了质询来源。工作室网站则根本没有frame-src,所以它的default-src 'self'本会拦截小组件的iframe;它加上了frame-src 'self'和质询来源,代码中应用自己的策略也加上了同样的内容。12
为什么不直接对表单自身的POST使用托管质询
现成的捷径本来就在我的区域里:一条对表单POST返回托管质询的自定义规则。我现有的规则就是这样对待curl及其同类工具的,而且有效,因为没有人会用curl给我发消息。939 把它扩展到所有POST,就会让表单对所有人失效。Cloudflare直接记录了这一限制:“Challenge Pages interrupt the request flow by returning a full HTML page for the user’s browser to render and solve. This mechanism fails when the browser expects a non-HTML response, such as an AJAX or XHR (fetch) request.”(质询页面通过返回一个完整的HTML页面,供用户浏览器渲染和解答,从而中断请求流程。当浏览器期望的是非HTML响应时,例如AJAX或XHR(fetch)请求,这一机制就会失败。)63 本站的联系表单是一个HTMX表单,用hx-post提交并把响应换入页面,所以它的POST正是这类请求;文档没有点名HTMX,这一关联是我的解读。24 一个被质询的POST会以HTML质询页面的形式返回,Cloudflare会给它加上一个“set to challenge”(设置为challenge)的cf-mitigated响应头,并且“Regardless of the requested resource-type, the content-type of a challenge will be text/html.”(无论请求的资源类型是什么,质询的content-type都将是text/html。)64 表单要么把一个质询页面换进它的响应框,要么什么也不显示。
对于这种情况,Cloudflare自己的答案是另一项功能:“To ensure your API calls are protected without breaking single-page applications (SPAs) or API integrations, Cloudflare recommends using Turnstile Pre-clearance.”(为确保在不破坏单页应用(SPA)或API集成的前提下保护您的API调用,Cloudflare建议使用Turnstile Pre-clearance。)63 使用预先放行(pre-clearance)时,一个被解答的小组件还会签发一个cf_clearance Cookie,它“enables visitors to bypass WAF Challenges on all subsequent requests on the zone, including API requests, based on the security clearance level set by the customer.”(让访客能够根据客户设置的安全放行级别,在该区域的所有后续请求中绕过WAF质询,包括API请求。)这个Cookie并不取代令牌:Cloudflare说它“is issued to the visitor in addition to the default Turnstile token”(是在默认的Turnstile令牌之外额外签发给访客的),而且同一页面仍然要求服务器端检查:“You must call Siteverify to complete your Turnstile configuration.”(您必须调用Siteverify才能完成Turnstile的配置。)65 预先放行所增加的,是一个XHR提交能够挺过去的边缘质询,前提是访客的Cookie持有不低于该质询级别的有效放行。我采用的是默认方案:表单中的小组件,加上由我自己的服务器检查的令牌。
11月3日上线了什么
在两个网站上,我都通过Cloudflare的API创建了一个托管模式的小组件,范围限定在该网站自己的域名。每个小组件的密钥在同一个进程中从API响应直接写入我Mac的钥匙串和托管服务的变量,从未出现在命令行上,也从未写入文件。12
本站。 一个新的验证器会把令牌连同访客的地址POST到siteverify。缺失的令牌、超过2,048个字符的令牌、被拒绝的令牌,或传输失败,全都返回false,因此只要设置了密钥,表单就会失败即关闭(fail closed);在未设置密钥的情况下,例如本地开发和测试套件中,表单照旧工作。12 Cloudflare接受的令牌,还必须是在本站自己的某个主机名上提供的;当Cloudflare返回小组件的action时,它也必须是这个表单自己的,正如服务端指南所建议的那样;没有主机名的应答一律拒绝。因此,在别的网站或别的小组件上签发的有效令牌不会通过。这项检查是同一天上午的第二处改动,从07:20 PST开始构建;07:25 PST清除缓存之后,一个带有取自线上页面的有效CSRF令牌、但没有Turnstile令牌的提交,再次返回了403。这证明部署之后表单仍然失败即关闭;一个不带令牌的提交无法检验主机名检查本身,这项检查依赖的是代码和测试。6660 在第三处改动之前,两个小组件和两个验证器都还接受localhost,而由于sitekey是公开的,任何人在自己的localhost上用我的sitekey签发的令牌,都能同样通过siteverify和主机名检查。Cloudflare的测试页面不建议这样做:“Cloudflare recommends that sitekeys used in production do not allow local domains”(Cloudflare建议生产环境中使用的sitekey不允许本地域名),并把localhost列在其中。08:12 PST,我把localhost从两个小组件中移除,两个验证器现在默认只接受网站本身及其www主机,因此检查只认本站自己的主机名,别的一概不认;本地开发在设置中指定自己的主机名。这次改动之后,两个网站在太平洋时间08:15和08:18再次通过了同样的线上检查:页面上的小组件,以及一个不带令牌、被403拒绝的提交。没有任何线上检查能检验主机名规则本身,因为缺失或无效的令牌在读取主机名之前就已失败;这条规则依赖的是小组件设置、代码和测试。4667 处理程序的检查顺序现在是CSRF,然后是Turnstile,然后才是发送,并且仍然位于与之前相同的按地址限速器之后。两种拒绝都会以403返回页面自己的错误片段,而HTMX默认不会把4xx响应换入页面,所以表单需要一个让403通过的处理器。这个处理器的第一个版本没有生效。我把它挂在了表单上,但HTMX是在接收响应的元素上触发该事件的,而在这个页面上,那个元素是表单的兄弟元素,所以事件从未到达处理器,被拒绝的人仍然什么也看不到。对本文草稿的第二次审阅,通过对照打包的HTMX和模板发现了这个问题。修复是当天上午的第四处改动,把监听器放在页面body上,并将范围限定在响应框,另有一个测试断言它会被渲染。在那次部署之后的08:21 PST,我在Playwright的Chromium中运行表单,把小组件的令牌替换成一个无效字符串:响应框显示“error: Verification failed. Reload the page and try again.”(错误:验证失败。请刷新页面后重试。),并且没有发送任何内容,因为在处理程序走到发送之前,siteverify就拒绝了这个无效令牌。68 旧的CSRF拒绝,也就是HTMX从不显示的那个JSON 403,已经不复存在。表单在每次HTMX请求之后都会重置自己的小组件,并按照Cloudflare重置调用所接受的方式指明小组件的容器,这样重试时就会带上一个新令牌,因为每个令牌都是一次性的,寿命只有300秒。126069 Cloudflare的客户端指南给出了原因和调用方式:“To reset the widget if the given widget timed out or expired, you can use the function:”(如果某个小组件超时或过期,要重置它,可以使用以下函数:)turnstile.reset(widgetId);。69 我的第一个版本在调用重置时没有传参数,而指南并没有记载这种用法;第四轮审阅发现了这一点,现在两个网站上的调用都指明了容器。11月3日的一次浏览器检查在每个表单上提交了一个无效令牌,看到了拒绝信息,随后在隐藏输入框里发现了一个新令牌:工作室网站在八秒以内,本站在二十五秒以内,本站的小组件先重新运行了一次质询。12 小组件的div和脚本只在设置了站点密钥时才会渲染。十一个测试全部通过:没有密钥时照旧发送;有密钥时,处理程序在任何发送之前拒绝缺失的令牌,拒绝被拒的令牌,接受的令牌则发送,并且CSRF仍然先运行;验证器接受本站的主机名,拒绝其他主机名、默认情况下的localhost、其他action,或没有主机名的情况。126646
工作室网站。 蜜罐仍然放在最前面。隐藏字段被填写时,依旧返回成功消息并且不存储任何内容,所以最廉价的机器人永远不会消耗一次对Cloudflare的调用。接下来是Turnstile,缺失、被拒或无法验证的令牌会得到一个带错误片段的403。然后是校验、写入消息表和通知。12 它的验证器以同样的方式检查主机名(现在只限网站自己的两个主机名)和action;这项改动在07:19 PST部署,07:22 PST时线上页面已带有小组件和站点密钥,不带令牌的提交和带无效令牌的提交都返回403,蜜罐提交仍然返回200。66 部署之前,我用Cloudflare文档中的测试密钥在本地运行了处理程序。使用总是通过的密钥时,不带令牌的提交返回403,而虚拟令牌通过了Turnstile并进入校验;使用总是失败的密钥时,两者都返回403;蜜罐返回200。1267 Cloudflare的测试页面解释了为什么虚拟令牌在生产环境中无法通过:“Production secret keys: Only accept real tokens, reject dummy tokens.”(生产环境密钥:只接受真实令牌,拒绝虚拟令牌。)67
线上检查。 那天上午更早的时候,在每个网站各自的部署成功、并清除缓存之后,06:35 PST时,每个首页都带有一个小组件div、一个Turnstile脚本标签和站点密钥,每个区域的线上策略都在frame-src中列出了质询来源。向本站表单提交一个带无效CSRF令牌的请求返回了403,但那是CSRF检查拒绝的,对Turnstile什么也证明不了。Turnstile检查本身,我在06:43 PST做了测试:一个带有取自线上页面的有效CSRF令牌、但没有Turnstile令牌的提交,返回403并附带消息“error: Verification failed. Reload the page and try again.”,同样的提交带上一个无效的Turnstile令牌,也返回403。所以密钥已经在服务上配置好,而且检查在CSRF之后运行。在工作室网站上,不带令牌的提交和带无效令牌的提交都返回403,蜜罐提交返回200,没有存储任何内容,也没有发送任何邮件。70
浏览器检查。 在由Playwright驱动的真实Chromium中,两个页面上的小组件都渲染了出来,Turnstile脚本已加载,隐藏的cf-turnstile-response输入框在六秒内在两个页面上都填入了一个730个字符的令牌。70 这能证明的东西比看上去要少。Cloudflare的测试页面警告说,“Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile”(自动化测试套件(如Selenium、Cypress或Playwright)会被Turnstile识别为机器人),而令牌能否通过,只由siteverify决定。67 这项检查证明的是,小组件在两套策略下都能运行,并能填好自己的字段。我还没有做的,是对本文读者最重要的那一项测试:一个真人填写表单并收到回复。那项测试会发出邮件,所以应该由我亲自来做,而我还没有做。
7. 速率限制还用来做什么
Turnstile取代了作为大门的速率限制,却没有取代作为围栏的速率限制。机器人检查回答的是“谁”;“多少”仍然需要有东西来回答,因为现在每一次带着非空、长度合规的令牌到达检查环节的提交,都要花费一次Cloudflare siteverify API调用,而每一次通过的提交仍然要花费一次发送。12 Cloudflare自己的服务端指南,在针对siteverify调用本身的建议中就列出了速率限制:“Rate limit to protect against validation flooding.”(实施速率限制,以防验证洪泛。)60 我的两道限制都保留:每个地址、每个数据中心每10秒3次提交的边缘规则,以及每个地址每分钟5次提交的应用限速器。917
既然知道了边缘规则是怎么计数的,它就值得一个诚实的描述。Cloudflare坦率地说明了设计:“Cloudflare tracks request rates by maintaining separate counters for each unique combination of values in a rule’s characteristics,”(Cloudflare通过为规则特征值的每种唯一组合维护单独的计数器来跟踪请求速率),以及“Cloudflare does not support global rate limiting counters across the entire network.”(Cloudflare不支持覆盖整个网络的全局速率限制计数器。)6 一个经由两个数据中心到达我区域的地址,会得到两份额度,而11月2日扫描器的提交确实是经由两个数据中心到达的;这是我对规则为何保持沉默最有力的解释,与每分钟的总数相符,但并未被它们证明。5 再加上Cloudflare关于精度的说明,即这些规则“are not designed to allow a precise number of requests to reach your origin server”(并非为了让精确数量的请求到达您的源服务器而设计),这条规则最恰当的描述是一个名义预算,而不是一个经过证明的上限:每个地址、每个数据中心每10秒3次表单提交,名义上相当于每分钟18次,执行起来只是近似的。489 在Free套餐上,这也是我能拥有的唯一一条速率限制规则,周期固定为10秒,只按IP计数。48
如果您也像我一样,曾想把速率限制本身做成质询,那么Free套餐上的一个细节就很重要。Cloudflare的参数页面说,在Free、Pro和Business套餐上,带质询动作的速率限制规则总是以节流方式运作,而不会持续一段时长,并且“When visitors pass a challenge, their corresponding request counter is set to zero.”(当访客通过质询时,其对应的请求计数器会被置零。)71 对表单的XHR POST进行质询,也会因为第6节所述的原因而失败。63 所以边缘规则仍然是拦截,质询则放在小组件里。
应用限速器需要它的漏洞所要求的修复:一个支持原子递增的计数存储,使任何两个提交都不会读到同一个计数,以及一条不会把计数拆散到各个进程的失败路径。当前的代码,即先读KV再单独写入、并带有按进程的后备方案,两者都提供不了,所以这需要更换存储,而不是打一个一行的补丁。173 既然Turnstile已经作为一个附加条件,作用于每一次可能进入收件箱的提交,那么漏洞放过的提交也必须携带一个Cloudflare接受的令牌,于是这个漏洞的代价变成了数量和金钱,而谁可以提交的决定,不再取决于计数。限速器是一种预算。一个在一次洪泛中就差了47次的预算是糟糕的预算,但它从来就不是守门人。
OWASP的认证指南用自己的话表达了同样的分层思路。它把CAPTCHA视为“as a defense-in-depth control to make brute-force attacks more time-consuming and expensive, rather than as a preventative.”(一种纵深防御控制,用来让暴力破解攻击更耗时、更昂贵,而不是一种预防手段。)72 它的撞库防护指南还指出:“Most tools used for these types of attacks will make direct POST requests to the server and read the responses, but will not download or execute JavaScript”(用于这类攻击的大多数工具会直接向服务器发送POST请求并读取响应,但不会下载或执行JavaScript)。50 我的两个扫描器确实获取了网站的页面,这是它们持有有效CSRF令牌最可能的途径。一个不运行小组件就提交表单的客户端,没有Turnstile令牌可发,而两个表单现在都会拒绝不带令牌的提交:在本站,一个带有效CSRF令牌但没有Turnstile令牌的提交返回了403,工作室网站上不带令牌的提交也一样。70
检查清单
对于一个位于Cloudflare之后的联系表单,按我现在会采取的顺序:
- 在表单上加机器人检查,并在服务器上验证每一个令牌。 用Cloudflare自己的话说:“The client-side widget alone does not protect your forms.”(仅靠客户端小组件无法保护您的表单。)60 按同一页面的建议,也要检查应答中的主机名和action,让在别处签发的令牌无法通过;并且不要让生产环境的sitekey包含本地域名,因为“Cloudflare recommends that sitekeys used in production do not allow local domains”(Cloudflare建议生产环境中使用的sitekey不允许本地域名)。606667
- 失败即关闭。 缺失、超长、被拒或无法验证的令牌一律拒绝,因为“Tokens can be forged.”(令牌可以伪造。)6012
- 每次提交后重置小组件。 令牌是一次性的,有效期300秒,所以不重置就重试,就是拿一个用过的令牌重试。6069
- 在两条CSP指令中都允许质询来源。 Cloudflare要求对
https://challenges.cloudflare.com设置script-src和frame-src,而只有default-src 'self'的策略会拦截iframe。6212 - 不要对通过XHR或fetch提交的表单使用托管质询。 Cloudflare说质询页面“fails when the browser expects a non-HTML response”(在浏览器期望非HTML响应时会失败),而HTMX的提交正是这类请求。63
- 返回一个人能看见的错误。 我的CSRF拒绝是一个HTMX表单从不显示的JSON 403,所以一个令牌过期的人点击发送后什么也看不到;而我的第一次修复是在表单上监听,HTMX却是在响应的目标,也就是表单的兄弟元素上触发换入事件。所以,请在浏览器里测试拒绝的情况。1268
- 把蜜罐放在机器人检查之前。 在我的工作室网站上,它只花了一个隐藏输入框和一个
if的代价,就拦下了52次提交中的42次。725 - 保留速率限制,并把它定位为成本上限,而不是大门。 Cloudflare按地址、按数据中心计数,而11月2日的扫描器经由两个数据中心到达,这是规则从未触发最可能的原因,尽管每分钟的总数无法证明这一点。65
- 用支持原子递增的存储来支撑应用限速器。 我的限速器读取计数后在另一个请求中写回,第二次读取失败时把计数重置为一,KV失败时退回到单个进程的内存,结果比它自己的上限多放过了47次和9次发送;日志无法说明是哪条路径漏的。173
- 不要指望User-Agent规则能挡住扫描器。 两个扫描器发送的都是普通的Chrome字符串,10月30日至少69,322次,11月2日9,725次。3241
- 在邮件服务商那里统计邮件,而不是在表单那里。 我的处理程序在失败路径上也返回200,而Resend的
email.sent事件只意味着“the API request was successful”(API请求成功);之后发生了什么,要看检索到的邮件的last_event字段。242873 - 在一个月内查询分析数据。 Cloudflare的自适应数据集保留“at least 31 days of data on every plan”(所有套餐至少31天的数据)。27
- 在窗口结束后重新统计。 我在10月30日洪泛结束几分钟后写的笔记少算了表单提交,因为Cloudflare还没有摄入完最后几分钟的数据。16
- 把地址拦截当作善后。 我的拦截在10月30日发生在第一次表单提交约50分钟后,在11月2日发生在扫描开始8分钟后,两天都没有任何发送出现在拦截所在的那一分钟之后:10月30日拦截前101次,11月2日拦截前26次、拦截那一分钟内5次。1192
可能发生的变化
这些是条件,不是预测。每一条都说明需要发生什么,以及我会怎么应对。
如果扫描器开始能通过托管质询。 Turnstile的检查在浏览器中运行,寻找自动化的痕迹;一个能大规模运行完整浏览器、且足以通过这些检查的工具,会让小组件对它而言成为又一项成本,而不是一堵墙。征兆会是:带有siteverify接受的令牌的表单发送,来自同时在User-Agent请求头中发送注入字符串的地址。Batchelder在2007年就蜜罐写下了这件事的坦率版本:“If and when they do, we can switch back to CAPTCHAs.”(如果它们真做到了,我们可以换回CAPTCHA。)49 我的退路在精神上相同,就是更强的质询;在此期间,蜜罐和速率限制依然在岗。
如果我加上带预先放行的边缘检查。 对于无法渲染质询页面的请求,Cloudflare推荐Turnstile预先放行,cf_clearance Cookie能让访客“bypass WAF Challenges on all subsequent requests on the zone, including API requests”(在该区域的所有后续请求中绕过WAF质询,包括API请求)。6563 如果我采用它,边缘就可以直接对表单的POST进行质询,而不会对Cookie持有不低于该质询级别有效放行的访客破坏HTMX;Cloudflare的CSP页面为这种模式增加了一项要求:“your connect-src directive must include ‘self’.”(您的connect-src指令必须包含’self’。)62 这个Cookie是“in addition to the default Turnstile token”(在默认Turnstile令牌之外)签发的,siteverify仍然必不可少,所以边缘质询会是我服务器检查之前的第二道检查,而不是它的替代品。65 我还没有这样做,本文描述的是目前在线的小组件加siteverify的设计。
如果我导出这两个窗口的Resend日志。 本文中的每一个邮件数量都是上限,因为表单的200要么是一次发送,要么是我的处理程序同样返回200的失败。24 Resend的检索端点会为每封邮件返回一个last_event,例如delivered,它的事件列表区分了“sent - The email was sent successfully.”(sent:邮件已成功发送。)和“delivered - Resend successfully delivered the email to the recipient’s mail server.”(delivered:Resend已成功将邮件投递到收件人的邮件服务器。)7374 有了这份导出,101和31就会变成精确的计数,而且只可能变小。
如果有人手工发送垃圾信息。 Turnstile问的是是否有浏览器在场,有时也问是否有人在场。它不问为什么。一个受雇往联系表单里粘贴推销文字的人可以通过它,我工作室网站上那些潜在客户开发的发送方,如果背后是真人,也可以通过。对于这种情况,防线还是老的那几道:用于控制数量的速率限制,以及我自己在收件箱里的判断。
如果Tor细流需要逐日的答案。 我核对的出口列表是11月3日获取的那一份,而Tor Project的CollecTor服务保存着过去的列表。5355 如果我查询这8天的数据,Tor的归属就会建立在提交到达当天的列表之上,而不是Cloudflare的代码加上11月3日的列表。Cloudflare还在所有套餐上提供Onion Routing,它通过Tor网络直接为Tor用户提供服务;Cloudflare说启用后,“Human Tor users and bots can be distinguished by our Onion services, such that interactive challenges are only served to malicious bot traffic.”(我们的Onion服务可以区分真人Tor用户和机器人,因此交互式质询只会提供给恶意机器人流量。)75 我没有开启它,在开启之前,我想先测量一下Turnstile上线后工作室网站的Tor流量。
要点总结
如果您运营一个联系表单:
- CSRF令牌加速率限制,仍然挡不住扫描器给您写信。我的两个扫描器都持有有效令牌,而我的限速器放过了101次和31次发送。12
- 在服务器上验证机器人检查的令牌,失败即关闭,并在每次请求后重置小组件;单靠小组件“does not protect your forms.”(无法保护您的表单。)60
- 在负载和故障条件下测试您的限速器,而不是一次只发一个请求。一个先读后写、带按进程后备方案的计数器,在设定为允许五次的一分钟里放过了18次发送。117
- 保留蜜罐。它是整个技术栈中最廉价的过滤器,在我的工作室网站上,8天里它在处理程序检查地址、存储一行或发送一封邮件之前,就拦下了这些提交中的80.8%。725
如果您负责网站的边缘:
- 弄清楚您的速率限制统计的是什么。在Cloudflare上,每条规则都按数据中心计数,一个经由两个数据中心到达的扫描器,会得到两个计数器。65
- 探测您自己的规则,之后再读防火墙事件。我的探测产生的十一条防火墙事件,比我花一整天推理它为何保持沉默,告诉我的东西更多。18
- 不要用质询页面去质询XHR或fetch的POST;Cloudflare记录了这样做会失败,并推荐在这种情况下使用预先放行。63
- 要预料到User-Agent规则只能抓住诚实的工具。这里的扫描器发送的是浏览器字符串;我的curl测试则招来了质询。3239
如果读收件箱的人是您:
- 收件箱被塞满时,先看发件人,再看主题。我的三股邮件流共用一个发件地址,这就是我第一次猜错的原因。23
- 在邮件服务商的日志给出不同结论之前,把表单的200当作邮件数量的上限,而不是计数。2428
- 在关掉通知之前,先读一读存储的消息。曾经有一位真实客户的消息一周无人阅读,而正是那条消息,才让工作室的表单开始给我发邮件。25
常见问题
为什么用了Cloudflare,我的联系表单还会收到垃圾信息?
最可能的原因是,表单前面没有任何东西会就每一次提交询问是不是真人发送的。Cloudflare的自定义规则匹配的是地址、网络、路径和User-Agent字符串,它的速率限制按地址、按数据中心统计请求;一个发送浏览器User-Agent、先加载页面、以适中速度提交的扫描器,可以穿过所有这些。在我的网站上,两个扫描器正是这样做的,分别让101次和31次提交通过;而在我测量的8天里,我工作室网站上的垃圾信息大多是每个地址一次提交,任何按地址的计数都抓不住。96127
速率限制能挡住机器人吗?
速率限制约束的是一个地址能有多快;它分辨不出机器人和真人。Cloudflare自己的文档说,它的规则“are not designed to allow a precise number of requests to reach your origin server”(并非为了让精确数量的请求到达您的源服务器而设计),并且计数器不在数据中心之间共享。我的边缘规则对两个扫描器都从未触发,而我设定为每分钟五次的应用限速器,在一分钟内放过了18次:它的计数器是先读后单独写入,第二次读取失败会把它重置为一,存储失败时会由按进程的后备方案接管;任何一条路径都可能放过额外的提交。48641173
蜜罐字段够用吗?
对会填写隐藏字段的机器人来说够用,而在我的工作室网站上,这类机器人占这些提交的80.8%:它在8天里拦下了52次提交中的42次,按到达的小时计算,其中包括每一次来自Tor出口节点的提交。对于把隐藏字段留空的发送方,它无能为力,而它放过的那10次(被存储,因此最多10封邮件)正是这样做的。720 Ned Batchelder在2007年描述这种技术时也说过:“In theory, it is possible for a spambot to defeat any of these measures.”(理论上,垃圾信息机器人可以攻破这些措施中的任何一种。)49
Turnstile和CAPTCHA有什么区别?
传统的CAPTCHA要求访客解一道谜题。Turnstile运行的是“a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment”(一系列小型、无需交互的JavaScript质询,以收集有关访客或浏览器环境的信号),在托管模式下,只有当访客的风险需要时才显示一个复选框,并且“No images or text to decipher.”(无需辨认图片或文字。)2158 Cloudflare说它的质询不使用“CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.”(CAPTCHA谜题,或选择物体、输入扭曲字符之类的视觉测试。)76 无论哪一种,都仍然需要服务器验证结果;对Turnstile来说,就是siteverify调用。60
为什么CSRF防护挡不住垃圾信息?
正确实现的CSRF防御,是为了阻止别的网站让访客的浏览器提交您的表单。我的令牌比这还弱:它是无状态的、带签名的,有效期一小时,不与会话、Cookie或地址绑定,所以它只能证明客户端持有一个由我的网站在这一小时内签发的令牌,而不能证明请求来自哪里。无论哪一种,都挡不住一个加载您的页面、然后自己提交表单的客户端,因为加载页面正是客户端获得有效令牌的方式,而两个扫描器都收集到了有效令牌;在两次洪泛中,这项检查只拒绝了它们的4次提交。1512
如何在日志中分辨扫描器和真人?
看这个地址做过的所有事情,而不只是表单。我的两个扫描器爬取了成千上万条路径,在路径和User-Agent请求头中放入SQL注入字符串,大多数请求发送Chrome的User-Agent,一部分请求自称Googlebot而Cloudflare的已验证机器人规则并不认可,并且以每分钟几十到几百次的突发方式提交表单。31324441 像sleep(15)和waitfor delay '0:0:15'这样基于时间的载荷,属于有文档记载的探测类型。3377 真人只会从一个自己真正读过的页面发送一条消息,也不会要求您的服务器睡上一觉。
Turnstile会收集个人数据吗?
Cloudflare并没有说它什么都不收集。它的Turnstile隐私附录列出了它处理的信号,“such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin”(例如客户端IP地址、TLS指纹、User-Agent请求头,以及Sitekey和相关来源),说明其目的是“solely to detect and block bots”(仅仅是检测和拦截机器人),并说明Cloudflare为改进其机器人检测而作为这些信号的数据控制者。它的概述页面说,Turnstile“does not access, store, or transmit user communications, form entries, or other page inputs.”(不会访问、存储或传输用户通信、表单填写内容或其他页面输入。)2221 所以它看到的是连接数据,而不是人输入的内容。
本站相关文章。 Claude Code 的 auto 模式不是安全边界针对另一种控制措施提出了同样的论点:一个逐项判断动作的分类器并不是那堵墙。Agents.txt不是访问控制机制在向机器人声明一项政策与强制执行它之间划出了界线。告警疲劳:我的安全监控器一天发来82封邮件从我的监控器那一侧讲述了11月2日的扫描器:在第一条检测结果后8秒、扫描开始后8分钟实施的拦截,以及关于它的零封邮件。每晚入睡前我都会运行的检查是另一种运维例程,每晚检查15,000个页面的状态、响应时间和缓存;无人监督运行AI智能体时,究竟什么会出问题则梳理了无人监督的智能体会话的各种失败模式,也就是运行我基础设施的那类自动化。
参考来源
作者数据说明:所有来自我自己系统的计数,都取自Cloudflare针对这两个区域的GraphQL Analytics API(请求数据集和防火墙事件数据集)、工作室网站的消息表、我的监控器日志,以及两个表单的代码,全部于2026年11月3日保存,并由脚本整理成台账。除标注PDT或PST者外,时间均为UTC。这些证据中的地址、规则或列表标识符、密钥和文件系统路径,均未出现在本文中。
-
作者数据。Cloudflare对本站区域的请求分析,10月30日扫描器向联系表单发送的POST,按分钟和边缘状态统计,于2026年11月3日针对已结束的窗口查询:1,065次提交,其中101次返回200,830次返回429,130次返回422,4次返回403,全部到达应用;第一次提交和第一个200在18:00这一分钟,峰值为18:03这一分钟的208次提交,单分钟最多的200为18次(18:06),有八个分钟的200超过五次,最后一个200在18:36,最后一次到达应用的提交在18:37。台账中的推导:拦截前发送101次,第一次提交比我报告洪泛早约46分钟、比拦截早49.88分钟,最后一次到达应用的提交比拦截早11.88分钟。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者数据。Cloudflare对本站区域的请求分析,11月2日扫描器向联系表单发送的POST,按分钟和边缘状态统计,于2026年11月3日查询:500次提交,其中31次返回200,31次返回429,438次在拦截所在的那一分钟或之后返回403;62次到达应用;第一次提交在18:58这一分钟,到达应用的提交峰值为19:02这一分钟的32次,单分钟最多的200为10次(19:03),最后一个200在19:04这一分钟;从第一次到达应用的提交(18:58这一分钟)到最后一次(19:04这一分钟)的七分钟跨度内,平均每分钟8.86次;31次发送中,26次在拦截所在那一分钟之前的几分钟里,5次在拦截所在的那一分钟本身,即19:04,该地址于19:04:01被加入列表的那一分钟,按分钟的时段无法与那一秒排出先后;之后为零。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者在台账中的分析:对每一分钟,取5与应用限速器看到的提交数(200、429以及应用的CSRF 403;不含422,因为FastAPI在限速器运行之前就拒绝了它们)中较小者,再对各分钟求和。10月30日:在限速器看到的935次提交中,精确限速器最多允许54次发送,实测101次,超出47次。11月2日:62次中最多22次,实测31次,超出9次。这是一个反事实的上限,不是测量值;测得的发送数是上文的表单计数。 ↩↩↩↩↩↩↩
-
作者数据。Cloudflare针对11月2日地址的防火墙事件,于2026年11月3日查询(抽样数据集,只用于判断哪条规则在何时起作用,从不用于数量):第一条事件在18:57:11,来自漏洞利用路径规则;拦截列表规则的第一条事件在19:04:03;每一行都是拦截,没有托管质询;联系表单上有310行,全部是拦截,第一行在19:04:05;该地址没有来自速率限制规则的事件。 ↩↩↩↩↩↩↩↩↩↩
-
作者数据,台账附录。Cloudflare对两个扫描器表单POST的请求分析,按Cloudflare数据中心、分钟和状态分组,于2026年11月3日查询:11月2日从18:56到19:05:00(查询的终点,因此19:05这一分钟只包含第一次提交),经阿姆斯特丹44次,经伦敦41次;19:02这一分钟,经伦敦18次(发送4次,被应用拒绝14次),经阿姆斯特丹14次(发送5次,拒绝9次);单个数据中心单分钟的最大计数为19,是阿姆斯特丹在19:04这一分钟的数字,其中15次是拦截之后在边缘被拒绝的。10月30日,全部1,065次提交都经由巴黎。 ↩↩↩↩↩↩↩↩
-
Cloudflare,“How Cloudflare determines the request rate”,Cloudflare WAF文档,2026年11月3日14:08:57 UTC获取。每种特征组合使用单独的计数器;除同一地理位置的数据中心外,计数器不在数据中心之间共享;
cf.colo.id是必需特征;没有全局计数器。 ↩↩↩↩↩↩↩ -
作者数据。Cloudflare对工作室网站区域的请求分析,向联系表单发送的POST,按小时、地址、国家和User-Agent统计,时间从2026年10月26日17:00 UTC到11月3日13:59:50 UTC:52次提交,全部返回200,来自35个地址;带有Cloudflare的Tor国家代码的提交23次、地址23个(44.2%和65.7%);恰好提交一次的地址32个(91.4%),所有Tor地址都在其中;三个重复提交的地址分别为13次(英国,一小时内,SQLSpider User-Agent)、2次(波兰,一小时内)和5次(美国,每次换一个新的User-Agent);Tor的User-Agent为18个Android和5个桌面Chrome,其中21个声称是Chrome 81或更早版本;非Tor的User-Agent为10个桌面Chrome 125到135、6个Firefox 128到138,以及13个其他。该窗口内,区域防火墙没有对表单的POST记录任何事件。蜜罐触发数为提交数减去已存储的行数:42,即80.8%。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者数据。工作室网站的联系消息表,于2026年11月3日14:04 UTC读取:从6月6日到11月3日07:24 UTC共108行,其中10行在查询后的窗口内;每日数据见第5节的表格;10月有32行,分布在31天中的21天,平均每天1.03行,任何一天都不超过三行;九行$50,000,000使用同一个发送者名称和九个发件地址;通知功能提交之后43行,之前65行。 ↩↩↩↩↩↩↩↩↩↩↩
-
作者数据。两个网站的区域配置,于2026年11月3日通过Cloudflare的API导出:本站的速率限制规则(版本1,最后更新于2026-10-30 18:49:53 UTC;表达式如文中引用;特征为数据中心和客户端地址;每10秒周期3个请求;10秒缓解超时,名义上相当于每个地址、每个数据中心每分钟18次)及其五条自定义规则(一条账户列表加七个ASN的拦截、一条漏洞利用路径拦截、一条已验证机器人跳过,以及两条分别按User-Agent字符串和按威胁评分、针对联系表单和管理路径的托管质询,导出时两者都已启用,威胁评分那条在当天稍后被停用);工作室网站的同类规则,质询作用于全站,另有一条豁免表单的API路径拦截,以及一条全站速率限制规则,针对非已验证机器人的客户端请求,按数据中心和地址计数。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者数据。Cloudflare针对10月30日地址的防火墙事件,于2026年11月3日查询(3,932行抽样,低于查询上限,只用于归因和时间):第一条事件在17:59:53,来自漏洞利用路径规则,该规则从扫描的第一分钟起就在起作用;18:49:53之前的111条抽样事件全部来自该规则;拦截列表规则的第一条事件在18:49:53;该地址没有来自速率限制规则的事件,也没有联系表单上的事件。 ↩↩↩↩↩↩↩↩↩↩↩
-
Cloudflare,“cf.threat_score”,Ruleset Engine字段参考,2026年11月3日16:12:44 UTC获取。该字段的描述:一个曾在0到100之间的评分,以及“Now, the threat score is always 0 (zero).”(现在,威胁评分始终为0(零)。) ↩↩↩
-
作者对2026年11月3日上线内容的记录,根据会话的工具输出整理:两个小组件(托管模式,约14:13 UTC通过Cloudflare的API创建)、两个区域的CSP改动、本站的验证器和处理程序顺序(失败即关闭,先CSRF再Turnstile再发送,403错误片段,每次HTMX请求后重置小组件)、十个新测试(10个通过;第十一个随主机名检查一同加入)、工作室网站的处理程序顺序(蜜罐,然后Turnstile,然后校验、存储和通知),以及使用Cloudflare测试密钥的本地检查;在两个网站上,验证器对缺失或超过2,048个字符的令牌都不调用siteverify而直接拒绝(读自代码)。两个网站均在14:32到14:36 UTC之间部署。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者数据。Cloudflare针对10月30日地址的请求分析,按分钟统计,并扫遍我账户中的所有区域,于2026年11月3日查询:从17:59这一分钟到18:51这一分钟(53个时段),向本站发送76,429个请求,两次查询总数相同;拦截所在那一分钟之前69,484个,平均每分钟1,389.7个,峰值为18:36的3,219个;拦截所在那一分钟之后5,234个,全部未被响应;按结果,已响应49,513个(64.8%),被应用拒绝18,990个(24.8%,其中包括网站智能体关卡的10,019个GET 418),源站520错误2,339个(3.1%),在边缘被拦截5,587个(7.3%);从扫描的第一分钟到拦截为50.88分钟;向kiradex.app发送387个GET,全部返回200,未触及其他区域。 ↩↩↩↩↩↩↩↩↩↩
-
作者数据。Cloudflare针对11月2日地址的请求分析,按分钟、按路径、按User-Agent统计,并扫遍所有区域,于2026年11月3日查询:扫描前11.1小时,即07:49 UTC,有两个GET;从18:56这一分钟到19:17这一分钟(22个时段)共10,907个请求,加上早先的GET为10,909个,四次查询总数相同;第一分钟29个请求,拦截前最繁忙的一分钟(18:57)637个,最繁忙的一分钟(19:07)1,107个,最后一分钟300个;拦截所在那一分钟之前2,986个(平均每分钟373.2个),之后7,445个,全部未被响应;按结果,已响应1,388个(12.7%),被应用拒绝1,473个(13.5%,其中包括420个GET 418),源站520错误116个(1.1%),在边缘被拦截7,930个(72.7%);从扫描的第一分钟到拦截为8.02分钟;向kiradex.app发送368个GET,未触及其他区域。 ↩↩↩↩↩↩↩↩↩↩↩
-
作者的代码阅读。本站在10月30日和11月2日部署的CSRF模块:令牌由时间戳、随机值以及对两者的HMAC-SHA256签名组成;签名匹配且时间戳不超过3,600秒即有效;不与会话、Cookie或地址绑定。于2026年11月3日从源代码中读取。 ↩↩↩↩
-
作者数据。由脚本根据保存的查询结果构建的台账(484行,每行都注明来源和方法)及其编码手册,2026年11月3日,其中包括:每种状态由哪一层作出响应的规则;防火墙事件提供归因和时间戳、请求数据集提供计数的规则;只有200才能证明令牌是有效CSRF令牌的规则;以及我当天笔记与11月3日查询结果不一致之处的表格(10月30日的笔记写于11:58 PDT,当时Cloudflare尚未摄入最后几分钟的数据,因此少算了表单提交、422、绘画分享提交以及那一小时的请求数)。 ↩↩↩↩↩↩↩↩
-
作者的代码阅读。本站在10月30日和11月2日部署的表单速率限制器:默认每个客户端地址每个窗口5次表单提交,以自然分钟为键的60秒窗口,从KV读取计数器,在递增操作内部进行第二次读取并把失败的读取计为零,再单独向KV写入该计数加一,读到的计数达到上限时返回429,KV调用失败时退回到按进程的内存列表。于2026年11月3日从源代码中读取。 ↩↩↩↩↩↩↩↩↩↩
-
作者数据,台账附录。2026年11月3日我从自己的机器上对本站速率限制规则所做的探测:14:36:49 UTC,十个不带令牌的POST,间隔0.4秒(四个422,然后六个429);14:38:03 UTC,八个字段齐全的请求(三个来自源站的403,然后五个响应体为“error code: 1015”的429);Cloudflare在14:35到14:40 UTC之间针对表单的防火墙事件,于14:40:36 UTC获取:11条事件,全部是速率限制规则的拦截。 ↩↩↩↩
-
作者数据。我的监控器在2026年11月2日10:40到11:32 PST之间的日志,及其检测结果和拦截记录,于11月3日保存:一次在10:57:00 PST开始的运行,其检测窗口在18:57:22 UTC结束,没有检测结果,按每分钟数据,该地址在该窗口内的请求数在29到666之间;下一次运行在11:02:46 PST;第一条检测结果在11:03:53 PST(严重级别的请求洪泛,为该地址统计了1,914个请求),距扫描的第一分钟7.88分钟;该地址于11:04:01 PST被加入账户拦截列表;共六条检测结果,全部并入每日摘要,没有一条以邮件发送。10月30日监控器没有运行;我在10月31日重新启用了它,流量检测器是之后才加上的。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
作者的分析。2026年11月3日,按小时时段把工作室网站已存储的行与每小时的POST数据进行匹配:没有已存储的行落在只有Tor提交的小时里;全部10行都落在有非Tor提交的小时里;23次Tor提交中有22次落在没有已存储行的小时里,第23次落在与美国潜在客户开发地址共享的小时里;窗口内的五行潜在客户开发记录,落在该地址提交的五个小时里。这是按小时的推断,不是基于请求ID的连接。 ↩↩↩↩↩↩↩
-
Cloudflare,“Overview”,Turnstile文档概述,2026年11月3日14:08:57 UTC获取。“Cloudflare’s smart CAPTCHA alternative”(Cloudflare的智能CAPTCHA替代方案);无需经过Cloudflare流量即可嵌入;无交互JavaScript质询以及工作量证明和空间证明的描述;Turnstile不访问、不存储、不传输表单填写内容的声明。 ↩↩↩↩↩↩
-
Cloudflare,“Turnstile Privacy Addendum”,最后更新于2025年6月18日,2026年11月3日14:08:57 UTC获取。Turnstile处理的信号、其声明的目的,以及Cloudflare为改进机器人检测而作为数据控制者的角色,均为引用。 ↩↩↩
-
作者的工作日志(Captain’s Log),2026年11月2日:我向运行我基础设施的智能体会话提出的问题,即它是否在发送这些邮件;共用一个发件地址的三股邮件流;带有扫描器载荷的邮件“Contact Form:”主题;以及我记下的扫描器表单提交“ran about nine a minute”(大约每分钟九次)。 ↩↩↩↩↩
-
作者的代码阅读。本站在10月30日和11月2日部署的联系处理程序和表单模板:一个HTMX表单(
hx-post指向表单路径),其中渲染了CSRF令牌;处理程序之前的限速器装饰器;发送之前的CSRF检查,失败时抛出403;通过Resend发送,主题为“Contact Form:”加上发送者姓名;发送被接受时返回成功片段;以及两条失败路径,即缺少邮件配置,以及发送失败或没有返回id,它们以FastAPI的默认状态200返回错误片段。没有蜜罐字段。于2026年11月3日从源代码中读取。 ↩↩↩↩↩↩↩↩↩ -
作者对工作室网站在2026年11月3日之前所部署版本的代码阅读和仓库历史:联系处理程序的蜜罐分支(隐藏的
website字段非空时返回成功片段,不存储也不发送任何内容;它是处理程序的第一项检查,位于地址检查、写入和通知之前),自2026年6月5日搭建表单的那次提交起就存在;隐藏输入框的tabindex="-1"和aria-hidden;没有CSRF令牌;尽力而为的通知功能,于2026年9月21日17:46:47 UTC在一次提交中加入,其提交信息写着“a paying customer’s message sat unread in D1 for a week”(一位付费客户的消息在D1里躺了一周无人阅读);以及一个面向匿名客户端的按进程内存速率限制器,以客户端地址和客户端分类(限速器根据User-Agent和地址分配的机器人等级)为键,覆盖除健康检查和静态文件之外的所有路径。于2026年11月3日从仓库中读取。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
作者的工作日志(Captain’s Log),2026年10月30日,写于11:58 PDT:我在11:46 PDT对洪泛的报告,11:48到11:49 PDT之间的手动拦截和速率限制规则,以及我在11:52 PDT的测试,均如文中引用。其中的计数是在Cloudflare仍在摄入洪泛最后几分钟数据时得出的,本文改用11月3日的查询结果。 ↩↩↩
-
Cloudflare,“GraphQL API - Limits”,GraphQL Analytics API文档,2026年11月3日14:08:57 UTC获取。自适应数据集“retain at least 31 days of data on every plan”(在所有套餐上至少保留31天的数据)。 ↩↩
-
Resend,“Event Types”,Resend webhook文档,2026年11月3日14:14:47 UTC获取。
email.sent为“Occurs whenever the API request was successful. Resend will attempt to deliver the message to the recipient’s mail server.”(每当API请求成功时发生。Resend将尝试把消息投递到收件人的邮件服务器。);成功投递对应email.delivered。 ↩↩↩ -
作者数据。Cloudflare对8天查询中所有其他向本站联系表单提交过的地址的请求分析,不含两个扫描器和我的测试地址,于2026年11月3日查询:三个地址(韩国、日本、印度),各自在10月27日、28日、29日中的某一天恰好有两个200。 ↩
-
RIPE NCC,RIPEstat的network-info、whois和as-overview,针对10月30日的地址及其AS,于2026年11月3日14:10:34和14:11:34 UTC获取:AS34534,持有者“BULLIONET FBW NETWORKS SAS”,一个以国家FR登记的分配。 ↩
-
作者数据。Cloudflare针对10月30日地址的请求分析,按路径、方法和状态分组,于2026年11月3日查询;查询触到了3,000行的上限,覆盖了76,429个请求中的74,125个,因此路径计数为下限:至少2,881条不同的路径,至少2,680个在路径中携带SQL注入载荷的请求,涉及至少810条不同的路径;向绘画分享路径发送的320个POST全部被拒绝(422或400),8天内没有其他地址向该路径提交;第2节引用的分享路径载荷变体返回404。 ↩↩↩↩
-
作者数据。Cloudflare针对10月30日地址的请求分析,按User-Agent分组,于2026年11月3日查询;查询触到了500行的上限,覆盖了76,429个请求中的76,095个,因此这里的每一个计数都是下限:至少69,322个请求使用同一个普通的Windows版Chrome 141字符串,至少2,755个自称Googlebot,至少3,154个在至少396种不同字符串中携带SQL注入载荷。 ↩↩↩↩↩↩
-
PortSwigger Web Security Academy,“SQL injection cheat sheet”,2026年11月3日14:10:34 UTC获取。Oracle(
dbms_pipe.receive_message)、Microsoft(WAITFOR DELAY)、PostgreSQL(pg_sleep)和MySQL(SLEEP)的时间延迟;条件形式IF(condition, SLEEP(10), 'a');Microsoft和MySQL的SELECT @@version;Oracle的FROM dual。 ↩↩↩↩↩ -
sqlmap项目,“time_blind.xml”,该工具基于时间的盲注载荷定义,2026年11月3日14:12:27 UTC获取。包含
SLEEP、PG_SLEEP、WAITFOR DELAY和DBMS_PIPE.RECEIVE_MESSAGE载荷。引用它是为了说明载荷类型,而不是说它就是任一扫描器所用的工具。 ↩ -
PortSwigger Web Security Academy,“Blind SQL injection”,2026年11月3日14:10:34 UTC获取。为什么查询中的时间延迟会推迟HTTP响应,如文中引用。 ↩
-
OWASP Automated Threats to Web Applications,“OAT-014 Vulnerability Scanning”,2026年11月3日14:13:41 UTC获取。摘要,如文中引用。 ↩
-
作者数据,台账附录。Cloudflare对两个扫描器表单POST的请求分析,按边缘状态和源站状态分组,于2026年11月3日查询:10月30日,830个429中有395个记录了源站状态429,其余435个没有记录;11月2日,31个429中有16个记录了源站429,15个没有记录。在记录了源站状态的地方,没有一个429带有仅属于边缘的状态。 ↩↩
-
作者的测试,2026年11月3日:一个十行的FastAPI应用,在网站自身的环境(FastAPI 0.128.0)中运行,端点上带有一个限速器式的装饰器。缺少必填字段的POST返回422,包装函数从未被调用;完整的POST到达了包装函数。 ↩
-
作者数据。Cloudflare针对我自己的测试地址在2026年10月30日的防火墙事件和请求分析,于11月3日查询:三条来自速率限制规则的事件,第一条在18:52:38 UTC,使用浏览器User-Agent;以及User-Agent质询规则针对我的curl提交发出的24次托管质询。不计入任何事件计数。 ↩↩↩↩↩
-
RIPE NCC,RIPEstat的network-info、whois和as-overview,针对11月2日的地址及其AS,于2026年11月3日14:10:34和14:11:34 UTC获取:AS24875,持有者“NOVOSERVE-AS NovoServe B.V.”,一条登记在NovoServe B.V.名下的路由,国家NL。下级分配的描述中有一位私人的姓名,此处不予转载。 ↩
-
作者数据。Cloudflare针对11月2日地址的请求分析,按User-Agent分组,于2026年11月3日查询,数据完整(73种字符串覆盖全部10,909个请求):9,725个请求使用同一个普通的Windows版Chrome 141字符串,942个自称Googlebot,149个请求中有54种不同的SQL注入载荷,78个请求中有3种破坏引号的字符串,12个请求中有12个
@@标记令牌,另有2个普通的其他字符串。 ↩↩↩↩↩↩ -
作者数据。台账脚本于2026年11月3日生成的两天User-Agent表:那个普通的Windows版Chrome 141字符串和Googlebot字符串,在10月30日和11月2日逐字节相同。 ↩
-
作者于2026年11月3日所做的重放:在11月2日18:58:23到19:03:23 UTC的完整Cloudflare数据上运行我的监控器自己的检测器,这正是11:02:46 PST开始的那次运行的窗口;于15:55:04 UTC运行,并作为重放记录保存,附有确切的命令和检测器的完整输出;台账附录中的五行由脚本从该记录推导而来:请求洪泛检测结果中该地址的2,143个请求,扫描器检测结果中的1,055个被拒绝请求,以及一条来自39次提交、其中23次被拒绝的表单洪泛检测结果,严重级别为高,这一级别会进入每日摘要,既不发邮件也不拦截。实时运行的1,914个,来自一个在运行发出查询的那一刻就结束的窗口,是我的监控器自己的日志所记录的数字,而实时运行没有给出表单洪泛检测结果。当天所做的窗口改动:每个实时窗口现在在运行前90秒结束,并从上一个窗口结束的地方开始;当上一条记录缺失或过旧时,退回为简单的五分钟回溯。 ↩↩↩
-
作者数据。Cloudflare针对11月2日地址的请求分析,按路径分组,于2026年11月3日查询,数据完整(2,318行覆盖全部10,909个请求):2,173条不同的路径,以及542条带有SQL注入载荷的不同路径上的778个请求。 ↩↩
-
OWASP Web Security Testing Guide v4.2,“Testing for SQL Injection”,2026年11月3日14:12:12 UTC获取。基于错误的技术和时间延迟技术,如文中引用。 ↩
-
作者对2026年11月3日第三处改动的记录,该改动是在对本文草稿的一次审阅之后做出的:16:12 UTC,两个小组件的允许域名被设为网站本身及其www主机,移除了localhost;两个验证器的默认主机名列表也设为这两个主机名,本地开发则自行设置列表;本站的测试断言默认情况下会拒绝localhost(11个通过);工作室网站重新部署,并在本地用Cloudflare的总是通过的测试密钥运行,虚拟令牌在列出该密钥的占位主机名时仍会进入校验,未列出时则被拒绝。16:13 UTC,以威胁评分为条件的两条托管质询规则(每个区域一条)被停用,并在其描述中写明了原因;User-Agent质询保留。此次改动后的线上检查:工作室网站在16:15 UTC(小组件存在,不带令牌和带无效令牌的提交返回403,蜜罐返回200),本站在16:18 UTC(小组件存在,不带令牌的提交返回403);没有一项检验了主机名规则。 ↩↩↩
-
Cloudflare,“Custom rules”,Cloudflare WAF文档,2026年11月3日14:10:34 UTC获取。自定义规则的作用,如文中引用;以及Free套餐的五条规则,可使用除Log之外的所有动作。 ↩
-
Cloudflare,“Rate limiting rules”,Cloudflare WAF文档,2026年11月3日14:08:57 UTC获取。规则参数;精度说明和计数器更新延迟,如文中引用;可用性表格,其中Free套餐为10秒计数周期、10秒缓解超时、按IP计数以及一条规则。 ↩↩↩↩↩↩
-
Ned Batchelder,“Stopping spambots with hashes and honeypots”,创建于2007年1月21日,2026年11月3日14:10:34 UTC获取。蜜罐的定义、机器人掉进不可见字段的原因、回放型与填表型的分类,以及他关于机器人攻破这些措施和换回CAPTCHA的两句话,均为引用。该页面把这种技术描述为他保护自己网站的方法,并未声称是他发明的。 ↩↩↩↩↩↩
-
OWASP Cheat Sheet Series,“Credential Stuffing Prevention Cheat Sheet”,2026年11月3日14:10:34 UTC获取。不依赖单一可预测的数量限制;每个地址的低请求量可击败拦截列表和速率限制;直接提交而不执行JavaScript的工具,均为引用。 ↩↩↩
-
Cloudflare,“Cloudflare HTTP headers”,Cloudflare Fundamentals文档,2026年11月3日14:14:20 UTC获取。CF-IPCountry请求头的特殊代码,包括“T1 - Used for clients using the Tor network.”(T1:用于使用Tor网络的客户端。) ↩
-
Cloudflare,“IP Access rules parameters”,Cloudflare WAF文档,2026年11月3日14:14:20 UTC获取。“T1: Tor exit nodes (country name: Tor)”(T1:Tor出口节点(国家名称:Tor))。 ↩
-
The Tor Project,“Bulk Tor Exit Exporter”,check.torproject.org,2026年11月3日14:10:34 UTC获取。列表的说明(如文中引用),以及关于历史数据请参阅CollecTor的指引;列表本身于同一时间从https://check.torproject.org/torbulkexitlist 获取,包含1,239个地址。 ↩↩
-
作者数据,台账附录。8天查询中每一个向工作室网站表单提交过的不同地址,按Cloudflare的国家代码划分,由脚本与2026年11月3日14:10:34 UTC获取的Tor Project批量出口列表(1,239个地址)进行比对:标记为T1的23个地址中有23个在列表上,其他12个中有0个。仅为11月3日的列表;事件当天未与档案核对。 ↩
-
The Tor Project,“Sources”,Tor Metrics,2026年11月3日14:10:34 UTC获取。TorDNSEL出口列表,“containing the IP addresses of relays that it found when exiting through them”(包含它在经由这些中继出口时发现的中继IP地址),及其档案。 ↩↩
-
OWASP Automated Threats to Web Applications,“OAT-017 Spamming”,2026年11月3日14:10:34 UTC获取。摘要、包括表单垃圾信息在内的其他名称,以及对针对非预期收件人的表单转邮件滥用的排除,如文中引用。 ↩↩
-
作者的分析。2026年11月3日,按固定规则(以第一个匹配为准)对工作室网站存储的108条消息进行分类(价格问题和奖品诱饵按发送者名称匹配,其余按消息文本,有一行作为真实咨询手动归类):第5节表格中的计数;价格问题在全表中涉及25种语言,10月涉及9种,依据我对每条固定句子的判读,其中克罗地亚语(或波斯尼亚语)和印度尼西亚语(或马来语)属于难以判定的情况;查询后窗口内的每一行都被归类为垃圾信息。 ↩↩↩↩↩
-
Cloudflare,“Turnstile widgets”,Turnstile文档,2026年11月3日14:08:57 UTC获取。托管、非交互和隐形三种模式,以及访客在每种模式下看到的内容,其中托管模式为引用;隐形模式的隐私政策条件;challenges.cloudflare.com来源。 ↩↩↩
-
Cloudflare,“Cloudflare Turnstile plans”,Turnstile文档,2026年11月3日14:08:57 UTC获取。Free和Enterprise;最多20个小组件;质询次数不限;无需其他Cloudflare服务即可使用,如文中引用。 ↩
-
Cloudflare,“Validate the token”,Turnstile服务端验证文档,2026年11月3日14:08:57 UTC获取。调用siteverify的要求;伪造的令牌;端点及其
secret、response和remoteip字段;300秒有效期和一次性使用;最长2,048个字符;action和hostname检查;错误代码表及其关于过期的两种描述;针对验证洪泛的速率限制。所有引用段落均出自此页面。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Cloudflare,“Cloudflare Turnstile - Easy CAPTCHA Alternative”,产品页面,2026年11月3日14:08:57 UTC获取。“Turnstile never harvests data for ad retargeting.”(Turnstile绝不会为广告再营销收集数据。) ↩
-
Cloudflare,“Content Security Policy”,Turnstile文档,2026年11月3日14:08:57 UTC获取。针对https://challenges.cloudflare.com 的
script-src和frame-src;基于nonce的做法;预先放行所需的connect-src 'self',如文中引用。 ↩↩↩ -
Cloudflare,“Interstitial Challenge Pages”,Cloudflare Challenges文档,2026年11月3日14:08:57 UTC获取。质询页面如何挂起请求;建议大多数WAF规则使用托管质询;AJAX和XHR(fetch)的限制以及对Turnstile预先放行的推荐,两者均为引用。 ↩↩↩↩↩↩
-
Cloudflare,“Detect a Challenge Page response”,Cloudflare Challenges文档,2026年11月3日14:10:34 UTC获取。设置为challenge的
cf-mitigated响应头,以及每个质询的text/html内容类型,如文中引用。 ↩ -
Cloudflare,“Clearance”,Cloudflare Challenges文档,2026年11月3日14:10:34 UTC获取。
cf_clearanceCookie和预先放行,如文中引用:该Cookie是在默认的一次性令牌之外额外签发的,Siteverify仍然必不可少,Cookie可绕过不高于小组件所设放行级别的质询。 ↩↩↩ -
作者对2026年11月3日第二处改动的记录,于同一天上午完成:工作室网站在15:19 UTC部署,15:22 UTC线上检查(小组件、脚本、站点密钥,以及线上
frame-src中的质询来源;不带令牌和带无效令牌的提交被403拒绝;蜜罐提交返回200);本站从15:20 UTC开始构建,清除缓存,15:25 UTC线上检查(同样的页面检查,不带令牌的提交被403拒绝,随后一个带有取自线上页面的有效CSRF令牌、但没有Turnstile令牌的提交再次被403拒绝,这表明部署后表单仍然失败即关闭,但无法检验主机名检查);两个验证器现在都要求Cloudflare应答中的主机名属于网站自己的主机名之一(没有主机名的应答被拒绝),并且当Cloudflare返回action时,要求它是该小组件自己的;本站的十一个测试通过,新增的测试接受网站的主机名,拒绝其他主机名、其他action以及缺失的主机名;在工作室网站上,用Cloudflare的总是通过的测试密钥在本地运行(该密钥返回一个占位主机名且不返回action),虚拟令牌在列出该主机名时进入了校验,未列出时被拒绝。 ↩↩↩↩ -
Cloudflare,“Test your Turnstile implementation”,Turnstile文档,2026年11月3日14:08:57 UTC获取。总是通过和总是失败的测试密钥,以及虚拟令牌;生产环境密钥拒绝虚拟令牌;自动化测试套件会被识别为机器人,如文中引用。 ↩↩↩↩↩
-
作者对2026年11月3日第四处改动的记录,该改动是在对本文草稿的第二次审阅之后做出的:第一个版本的HTMX before-swap事件处理器(用于在HTMX默认不换入4xx响应的情况下让403片段通过)挂在了联系表单上,而HTMX是在换入目标,也就是表单的兄弟元素上触发这个事件的,所以403片段从未显示;审阅通过阅读打包的HTMX和模板发现了这一点。修复是在页面body上放置一个监听器,并将范围限定在响应目标的id上,首页测试断言它会被渲染(11个通过)。在16:18 UTC确认部署之后,16:21 UTC在Playwright Chromium中进行了一次浏览器检查:把小组件730个字符的令牌替换为一个无效字符串,用占位值填写必填字段,并通过HTMX提交表单;响应目标显示错误片段“error: Verification failed. Reload the page and try again.”,没有成功片段,也没有发送任何内容。未完成:真人的端到端提交。 ↩↩
-
Cloudflare,“Embed the widget”,Turnstile文档,2026年11月3日14:08:57 UTC获取。api.js脚本、小组件容器、在表单内创建的隐藏
cf-turnstile-response输入框,以及用于超时或过期小组件的turnstile.reset(widgetId),如文中引用。 ↩↩↩ -
作者对2026年11月3日线上检查的记录,每项检查都在其所在网站自己的部署成功之后、并在缓存清除之后进行:06:35:24和06:35:54 PST,每个首页上都有一个小组件div、一个脚本标签和站点密钥,每个线上
frame-src中都有质询来源;在本站,一个字段齐全、带无效CSRF令牌的提交被CSRF检查以403拒绝;14:43 UTC,在本站,一个带有取自线上页面的108个字符有效CSRF令牌、但没有Turnstile令牌的提交,以403被拒绝,并附带“Verification failed. Reload the page and try again.”,同样的提交带上无效的Turnstile令牌也以403被拒绝;在工作室网站上,不带令牌和带无效令牌的提交以403被拒绝,蜜罐提交返回200,没有存储或发送任何内容;14:39:50和14:40:04 UTC在Playwright Chromium中,小组件已渲染(602×73像素和557×71像素),Turnstile API对象已加载,两个页面上的隐藏响应输入框都在六秒内填入了一个730个字符的令牌。未完成:真人的端到端提交。 ↩↩↩ -
Cloudflare,“Rate limiting parameters”,Cloudflare WAF文档,2026年11月3日14:08:57 UTC获取。参数名称;在Free、Pro和Business套餐上,质询动作总是以节流方式运作,为转述;通过质询会重置计数器,为引用。 ↩
-
OWASP Cheat Sheet Series,“Authentication Cheat Sheet”,2026年11月3日14:10:34 UTC获取。CAPTCHA作为纵深防御控制,如文中引用。 ↩
-
Resend,“Retrieve Sent Email”,Resend API参考,2026年11月3日14:14:47 UTC获取。检索到的邮件的
last_event字段,例如“delivered”。 ↩↩ -
Resend,“View and manage emails”,Resend文档,2026年11月3日14:15:12 UTC获取。邮件事件列表,包括“sent”和“delivered”,为引用;以及仅用于Broadcasts和Batches的“queued”。 ↩
-
Cloudflare,“Onion Routing and Tor support”,Cloudflare Network文档,2026年11月3日14:08:57 UTC获取。所有套餐上的Onion Routing,以及真人Tor用户与机器人的区分,如文中引用。 ↩
-
Cloudflare,“Challenges”,Cloudflare Challenges文档,2026年11月3日14:08:57 UTC获取。“Cloudflare does not use CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.”(Cloudflare不使用CAPTCHA谜题,也不使用选择物体或输入扭曲字符之类的视觉测试。) ↩
-
OWASP Community,“Blind SQL Injection”,2026年11月3日14:10:34 UTC获取。依赖数据库暂停的基于时间的盲注,以
WAIT FOR DELAY和pg_sleep()为例。 ↩