适用场景:为什么先治理联系表单

独立博客上线一段时间后,联系表单、评论入口和订阅入口往往比后台登录更早暴露在噪声流量里。它们看起来只是一个小功能,但会牵出邮件轰炸、重复提交、垃圾链接、日志污染、数据库膨胀和人工处理成本。对 Stepnex 这类 Flask 博客来说,百度网盟审核冲刺期更需要稳定、克制、可解释的站点质量信号:页面能正常提交,异常提交有记录,用户不会因为误操作连续发十次,站长也不会被一批无效线索淹没。

这篇文章不写万能反垃圾方案,而是给一个单站 Flask 项目准备最小可落地的表单防护清单。目标是把联系表单从“能收消息”推进到“能限制、能验证、能追踪、能复盘”。它可以和站内已有的 Flask 博客后台审计日志怎么做 配合:后台审计关注管理员行为,联系表单治理关注公开入口的提交质量,两者都服务于站点长期维护。

技术取舍:先少拦错,再逐步收紧

公开表单的防护不要一开始就堆复杂验证码。验证码能挡一部分自动提交,也可能伤害真实用户,尤其是移动端和跨境访问场景。更稳妥的顺序是:先做服务端字段校验和长度限制,再做 CSRF 或一次性提交令牌,随后加蜜罐字段和频率限制,最后把异常提交写入日志并进入每日复盘。这样每一步都有验证方式,也能在误伤用户时快速回滚。

配置上建议保留四个边界:第一,Nginx 限制请求体大小,避免大包直接打到 Flask;第二,Flask 路由只接受明确字段,不信任前端隐藏字段;第三,数据库只保存必要信息,IP 和 User-Agent 如需保留应设置保留周期;第四,邮件通知和后台列表分离,避免垃圾提交直接变成通知轰炸。涉及隐私和用户数据时,页面上的隐私政策要说明联系表单会收集哪些信息以及用途。

步骤一:把字段和请求体边界写清楚

先给联系表单定义最小字段,不要把“以后可能有用”的字段都放进去。普通博客的联系页可以只保留姓名、邮箱、主题、正文和一个隐藏蜜罐字段。服务端只读取白名单字段,并在进入业务逻辑前做长度、格式和空值检查。

CONTACT_LIMITS = {
    "name": 40,
    "email": 120,
    "subject": 100,
    "message": 2000,
}

def clean_text(value: str, limit: int) -> str:
    value = (value or "").strip()
    if len(value) > limit:
        raise ValueError("field too long")
    return value

Nginx 侧同步加请求体限制,避免异常大包占用应用进程。联系表单通常不需要上传文件,可以把限制设得比全站上传入口更低:

location /contact {
    client_max_body_size 64k;
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

改完后执行命令检查:

sudo nginx -t
sudo systemctl reload nginx
curl -i -X POST https://stepnex.cn/contact -d "name=test&email=test@example.com&subject=hello&message=ok"

验证重点不是只看返回 200,而是确认合法提交进入正常流程,超长正文返回清晰错误,大请求不会变成 500。避坑点是不要只依赖 HTML 的 maxlength,因为任何人都可以绕过前端直接 POST。

步骤二:加入 CSRF 或一次性提交令牌

如果项目已经使用 Flask-WTF,可以按框架方式启用 CSRF;如果是轻量表单,也可以实现一次性提交令牌。核心思路是:服务端生成随机 token 放入 session,同时渲染到表单;提交时必须匹配,成功后立即轮换,失败时记录日志但不要暴露内部原因。

import secrets
from flask import session

def issue_form_token() -> str:
    token = secrets.token_urlsafe(32)
    session["contact_form_token"] = token
    return token

def verify_form_token(token: str) -> bool:
    expected = session.pop("contact_form_token", "")
    return bool(expected and token and secrets.compare_digest(expected, token))

这个配置适合传统服务端渲染页面。如果联系表单来自前后端分离页面,需要确认 Cookie 的 SameSite、HTTPS 和跨域策略,否则真实用户可能拿不到 session。验证方式是打开页面、提交一次、刷新后重复提交旧表单,第二次应被拒绝或要求重新获取页面。复盘时要看日志里是否存在大量 token 缺失提交,如果有,说明入口被扫描或前端集成有问题。

步骤三:用蜜罐字段挡低成本脚本

蜜罐字段不是强安全措施,但很适合小站公开表单。做法是在页面里放一个真实用户看不到、不需要填写的字段,例如 website。正常提交时它应该为空;垃圾脚本如果把所有输入框都填满,就会命中蜜罐。

<div class="visually-hidden" aria-hidden="true">
  <label for="website">Website</label>
  <input id="website" name="website" tabindex="-1" autocomplete="off">
</div>

服务端处理:

honeypot = request.form.get("website", "").strip()
if honeypot:
    current_app.logger.info("contact_honeypot_hit path=%s ip=%s", request.path, request.remote_addr)
    return render_template("contact_thanks.html"), 200

这里故意返回正常感谢页,而不是提示“你是机器人”。这样低成本脚本不容易通过返回内容判断规则。避坑点是 CSS 隐藏方式要避免影响辅助技术用户,不要把必填字段藏起来,也不要用“电话”这类真实用户可能误填的字段名。验证方式是手动构造带 website 的 POST,确认不会创建有效询盘,也不会发送邮件通知。

步骤四:做频率限制和重复提交识别

频率限制要谨慎,尤其是使用 CDN 或反向代理时,request.remote_addr 可能不是用户真实 IP。先确认 Nginx 传递了 X-Forwarded-For,Flask 侧按可信代理配置读取真实地址。小站可以先用内存或数据库做简单窗口限制,例如同一 IP 十分钟最多 3 次、同一邮箱一小时最多 2 次。规模变大后再换 Redis 或专门限流组件。

from datetime import datetime, timedelta

def recent_contact_count(db, ip: str, since_minutes: int = 10) -> int:
    since = datetime.utcnow() - timedelta(minutes=since_minutes)
    return db.session.execute(
        "select count(*) from contact_message where ip_hash=:ip and created_at>=:since",
        {"ip": ip, "since": since},
    ).scalar()

不要长期明文保存完整 IP。更克制的做法是用站点密钥加盐后保存哈希,只用于短期频率判断。重复提交则可以按 email + subject + message 计算哈希,十分钟内相同内容只保留一条,并给用户返回“已收到”。验证方式包括:同一浏览器连续提交 4 次、换邮箱提交、复制同一正文提交、使用 curl 模拟无 Cookie 提交。日志里应该能看见 rate_limitedduplicate_messageaccepted 三类结果。

步骤五:把通知和后台审核分开

联系表单最容易失控的地方是邮件通知。每条提交都发邮件,遇到脚本刷表单时就会把邮箱打爆。建议先写入数据库,再按规则发送通知:合法字段、未命中蜜罐、未触发频率限制、不是重复内容,才进入通知队列。后台列表里保留待处理、已回复、垃圾、忽略四种状态,人工审核后再决定是否回复。

日志字段建议包括提交时间、路径、结果、原因、IP 哈希、User-Agent 摘要、字段长度和消息哈希。不要把完整正文写进应用日志,正文属于用户提交内容,应在数据库里按权限查看。

current_app.logger.info(
    "contact_submit result=%s reason=%s ip_hash=%s subject_len=%s message_len=%s",
    result,
    reason,
    ip_hash,
    len(subject),
    len(message),
)

这样做的好处是后续可以和 Nginx 访问日志对齐。如果某天联系页 POST 激增,可以先看应用日志里的 result 分布,再看 Nginx 的状态码、User-Agent 和来源路径。它不会保证完全挡住垃圾提交,但能让问题有证据可查。

验证清单:上线前至少跑一轮

  • 正常用户从联系页提交一次,后台能看到记录,邮件通知只发送一次。
  • 刷新页面后重复提交旧 token,被拒绝或要求重新打开表单。
  • 蜜罐字段有值时返回感谢页,但不创建有效消息。
  • 超长姓名、邮箱、主题和正文都返回可理解的错误,不出现 500。
  • 同一 IP 连续提交超过阈值时进入限流分支。
  • 同一邮箱和相同正文短时间重复提交时只保留一条。
  • Nginx client_max_body_size 生效,大请求不会压到 Flask。
  • 应用日志能区分 accepted、honeypot、rate_limited、duplicate、invalid_token。

避坑点:不要把防刷做成用户障碍

第一,不要把所有异常都返回 403。真实用户的网络、浏览器插件、回退按钮都可能造成 token 失效,提示语要告诉用户重新打开页面再试。第二,不要把验证码作为唯一手段。验证码服务不可用时,联系入口就会被连带拖垮。第三,不要在日志里保存完整正文、完整邮箱和完整 IP,排查需要的是分类信号,不是无限收集个人数据。第四,不要把频率限制写死到全站所有 POST,否则后台发布、搜索和评论可能被误伤。

还有一个常见误区是只看数据库数量,不看被拦截数量。如果每天都有很多蜜罐命中,但后台看起来很干净,说明防护在工作;如果 accepted 数量突然升高,才需要人工抽样检查。复盘时应同时看通过量、拦截量、误伤反馈和邮件通知量。

复盘清单:每周固定检查

每周抽十分钟看联系表单日志,按四个问题复盘:第一,真实消息有没有被正常处理;第二,垃圾提交主要被哪一层拦住;第三,是否出现大量 token 缺失、蜜罐命中或同 IP 重复;第四,最近是否有用户反馈无法提交。发现问题后只调整一个参数,例如限流窗口、字段长度或通知规则,调整后保留命令输出和日志片段。

如果站点正在审核期,不建议频繁改动联系页文案和入口结构。优先修复真实错误、补齐日志、降低通知噪声和保留人工审核流程。公开表单不需要一次做到完美防护,更实际的目标是让正常用户能顺利联系,让无效提交有成本,让站长能根据日志持续改进。