这是我维护一批老客户企业站时反复用到的一套实战方案。织梦DedeCMS至今还在大量企业官网跑着,自定义表单是询盘、报名、留言这类核心组件,但出厂的/plus/diy.php没有任何防重复提交逻辑——用户多点几次、网络抖一下、误回退页面再 F5,后台就会出现一连串完全相同的脏数据。这篇文章把基于session+IP的5层加固方案整理成可落地代码,每一层都标注修改位置、踩坑点、性能影响、与运营动作的协同,让你直接抄回去用,并附上对集群部署、Cloudflare CDN、织梦升级覆盖等真实场景的处理方法。所有代码片段中的尖括号都用HTML实体替代以避免被strip_tags吃掉。
问题根因:织梦默认表单的安全空白
打开/plus/diy.php这个处理自定义表单提交的核心脚本,会发现它的逻辑非常简陋——拿到POST的字段,按模型配置写库,跳转成功页。整个过程没有任何"重复请求"判断:没有CSRF token、没有IP限速、没有提交频率控制、没有nonce机制。这种"信任前端"的设计带来 4 类典型故障:
第一类,弱网下用户连点。中国大陆移动网络在地铁、电梯、隧道等场景常见 3-5 秒延迟,用户点了提交后界面没反应,下意识又点一次,浏览器把同一份表单数据再发一次,后台就会写入两条记录。这种"无感的重复提交"是真实用户最常见的造脏数据来源。
第二类,浏览器回退键 F5。提交成功跳转到 thank-you 页面后,用户回退到原表单页再按 F5,浏览器会弹"是否重新提交表单"对话框,用户随意点确定就会再次提交。如果 thank-you 页面没禁用缓存,回退甚至不会触发对话框,直接重新 POST。
第三类,CDN 缓存与回源不一致。某些客户的网管在配置宝塔、阿里云 CDN 时不小心把表单页路径也加了缓存,用户回退页面命中缓存版本,再次提交也会出现完全相同的订单。
第四类,低级爬虫脚本。看到 /plus/diy.php 这个公开 POST 入口的爬虫极多——一晚上几百条垃圾数据是常态,包括各种英文乱码、广告链接、SQL 注入测试、XSS 探针。
方案 1:session+IP 基础拦截(核心方案)
修改位置
打开 /plus/diy.php,定位到大约第 58 行附近的字段处理段(不同织梦版本可能在第 50-70 行之间,搜$dede_fields定位)。
核心代码
if(!empty($dede_fields))
{
session_start();
$cur_ip = GetIP();
if ($cur_ip == $_SESSION['form_ip']) {
showmsg('您已经提交过了,无需重复提交!', '/', 0, 10000);
exit;
} else {
$_SESSION['form_ip'] = $cur_ip;
}
$fieldarr = explode(';', $dede_fields);
// 后续保持原样
}GetIP()是织梦自带的工具函数,封装在include/common.func.php里,会按顺序检查HTTP_X_FORWARDED_FOR、HTTP_CLIENT_IP、REMOTE_ADDR三个 header。showmsg是织梦自带的提示函数,第三个参数 0 表示不自动跳转,第四个参数 10000 表示提示展示 10 秒。
为什么用 session+IP 而不是单 IP
这是新人最常问的问题。直接拿 IP 做对比也能拦重复,但缺点明显:IP 是公网共享资源——一个写字楼几百号人共用一个 NAT 出口 IP 是常态、移动 4G/5G 用户在同一基站下共用一批出口 IP、企业园区往往用统一公网。如果纯 IP 拦截,第一个提交后所有同 IP 的真实用户都会被错杀。
session 的作用是绑定到具体浏览器会话(依赖 Cookie 里的 PHPSESSID)。同一用户、同一浏览器才能命中前一次写入的$_SESSION['form_ip']。换浏览器、换设备、清 Cookie,session 重新开始,不会被错杀。
IP 在这里的作用是"二级保险"——如果用户开了 N 个标签页同时提交(共享 session),IP 比对能确认是同一物理位置发出的。
方案 2:时间窗口冷却(基础方案的进阶)
方案 1 的"同 session 永久不能再提交"过于激进——用户可能 2 小时后想重新询盘新内容。给 session 加时间戳,控制冷却窗口能解决这个问题。
session_start();
$cur_ip = GetIP();
$now = time();
$last = isset($_SESSION['form_last_time']) ? (int)$_SESSION['form_last_time'] : 0;
if ($cur_ip == $_SESSION['form_ip'] && ($now - $last) < 1800) {
showmsg('您刚刚提交过,请稍后再试。', '/', 0, 10000);
exit;
}
$_SESSION['form_ip'] = $cur_ip;
$_SESSION['form_last_time'] = $now;这里 1800 是 30 分钟(秒数)。冷却窗口的选择按业务调整:纯展示官网用 1800(30 分钟);询盘价值高的 B2B 站点用 7200(2 小时);招商加盟站点甚至可以用 86400(24 小时);活动报名页用 60-120(1-2 分钟,仅挡连点不挡正常重提)。
方案 3:CSRF token 防止脚本伪造(与 1 叠加)
方案 1 与 2 都假设请求来自浏览器(有 session),如果攻击者直接用 curl 或 Python 脚本 POST 到/plus/diy.php,没有 session、永远不会被拦。CSRF token 是防御这一类攻击的有效手段。
表单页面输出 token
<?php
session_start();
$token = bin2hex(random_bytes(16));
$_SESSION['form_token'] = $token;
?>
<form method="post" action="/plus/diy.php">
<input type="hidden" name="_token" value="<?php echo $token; ?>">
<!-- 其他字段 -->
</form>diy.php 入口校验
session_start();
if (empty($_POST['_token']) || $_POST['_token'] !== $_SESSION['form_token']) {
showmsg('表单已过期,请刷新页面后重新提交。', '/', 0, 10000);
exit;
}
unset($_SESSION['form_token']);token 用一次就销毁。远程脚本拿不到 token 就 POST 不进来。random_bytes(16)生成 16 字节高熵随机数,配合bin2hex转十六进制后是 32 位字符串,碰撞概率忽略不计(2 的 128 次方分之一)。
注意:织梦旧版 PHP 不支持 random_bytes
random_bytes是 PHP 7+ 的函数。如果你的服务器还在 PHP 5.6(很多老织梦站点),用openssl_random_pseudo_bytes替代:$token = bin2hex(openssl_random_pseudo_bytes(16));。再老的环境(不支持 openssl)退回到md5(mt_rand() . microtime() . session_id()),安全强度略低但够防 CSRF。
方案 4:前端按钮置灰+防双击(用户体验层)
服务端的 3 层防御之外,前端做一道用户体验层防护——提交按钮点击后立即置灰+提示"正在提交",避免用户连点。这一步对真实用户场景下的"无感重复"效果最直接。
<form id="diy-form" method="post" action="/plus/diy.php">
<!-- 字段省略 -->
<button type="submit" id="btn-submit">提交</button>
</form>
<script>
document.getElementById('diy-form').addEventListener('submit', function () {
var btn = document.getElementById('btn-submit');
btn.disabled = true;
btn.innerText = '正在提交...';
setTimeout(function () {
btn.disabled = false;
btn.innerText = '提交';
}, 5000);
});
</script>5 秒超时是必要的——如果服务端响应失败(500 错误、连接断开),按钮永远卡在"正在提交"会让用户无路可走。5 秒后自动恢复,给用户重试机会,同时大多数正常请求 3-4 秒能完成,5 秒已经覆盖。
方案 5:thank-you 页面禁用缓存
提交成功后跳转到独立的 thank-you 页面,避免用户在原页面按 F5 触发再次 POST。在 thank-you 页面 HTML 头部加:
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">同时在服务端响应头加:
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');双重禁用缓存确保用户回退时浏览器不会从本地缓存恢复 POST 后的结果。某些激进的浏览器(移动端 UC、QQ 浏览器)默认会缓存所有 GET 页面,没有禁用缓存的 thank-you 页面会被错误恢复。
反向代理与 CDN 场景的真实 IP 处理
这一块是改完 diy.php 之后最容易踩的坑。我见过最严重的案例是一个加盟商站点改完防重复提交后,所有用户从第二天起完全无法提交——后台日志显示$cur_ip永远是127.0.0.1,因为 Nginx 没有透传真实 IP。
Nginx 反向代理配置
在 location 块里加:
location ~ \.php$ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}这样织梦GetIP()能正确从HTTP_X_FORWARDED_FOR读到真实 IP。
Cloudflare CDN 场景
Cloudflare 把客户端真实 IP 放在CF-Connecting-IP这个 header 里,织梦原生GetIP不识别。需要自己包一层:
function GetRealIp() {
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return $_SERVER['HTTP_CF_CONNECTING_IP'];
}
if (!empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
return $_SERVER['HTTP_TRUE_CLIENT_IP'];
}
return GetIP();
}然后把方案代码里的GetIP()全部替换为GetRealIp()。HTTP_TRUE_CLIENT_IP是 Cloudflare Enterprise 计划的标准 header,普通付费版仍然用CF-Connecting-IP。
阿里云、腾讯云 CDN
阿里云 CDN 用X-Forwarded-For透传,符合 RFC 标准;腾讯云 CDN 同样用X-Forwarded-For。两者都不需要额外处理,但要注意 X-Forwarded-For 是逗号分隔的列表(多次代理会追加),织梦GetIP()已经处理了这种情况。
性能影响与压测数据
很多人担心加 session 会拖慢响应。我在一个真实生产环境(4 核 8G 阿里云 ECS、PHP 7.4、Nginx 1.20、MySQL 5.7)做过压测:
未改造前:/plus/diy.php平均响应 85ms,QPS 约 240。
改造后(方案 1+2+3+5 全启用):平均响应 92ms,QPS 约 220。
性能损失约 8%-9%,主要来自 session 文件读写。如果使用 Redis 替代文件 session(推荐生产环境),性能损失降到 3% 以内。
对绝大多数企业站(QPS < 50),这种性能损失完全感知不到。访问量大的场景(QPS > 200)建议直接上 Redis session。
常见报错与排查清单
错误 1:headers already sent
session_start()必须在任何输出之前调用。如果文件顶部 BOM 没去干净(用 UTF-8 with BOM 保存的 PHP 文件会输出 3 字节 BOM),或前面有echo、空行混入,session 启动失败。织梦的入口文件common.inc.php已经做了ob_start(),多数情况下不会触发,但如果你在自定义模板里手动 echo 过东西要警惕。修复方法:用 Notepad++ 把所有相关 PHP 文件转成"UTF-8 无 BOM"格式重新保存。
错误 2:session 文件目录不可写
生产服务器上 PHP 的session.save_path默认指向/tmp或/var/lib/php/sessions。如果运维清理脚本太狠把目录权限改掉(chmod 700但 web 用户不在 owner),session 写不进去就会失效。$_SESSION['form_ip']永远拿不到上次的值,重复提交又能进来。排查方法:phpinfo() 看session.save_path,确认目录存在且 web 用户(通常是 www、nginx、apache)可写。
错误 3:负载均衡场景 session 不一致
多台 web 机器+负载均衡时,文件 session 默认存在各机器本地,用户被分到 A 机器存 session、再次访问被分到 B 机器读不到 session,第二次提交不被拦下。修复方法两种:第一种把session.save_handler切到 Redis(推荐);第二种用 sticky session(Nginx 的 ip_hash 模块或 lvs 的会话保持),保证同一用户始终落到同一台机器。
错误 4:CSRF token 跨页面共享失败
如果用户同时打开多个表单页(多个 token 写到同一 session),后写的 token 覆盖前面的,先打开的页面提交时 token 不匹配。修复方法:每个表单生成独立 token 存到 session 数组,$_SESSION['form_tokens'][$page_id] = $token,校验时查对应页面 ID 的 token。
织梦升级覆盖的应对
织梦官方升级包通常会替换/plus目录下的核心脚本,你的修改会被覆盖。建议两种应对:
第一种是把改动整理成 patch 文件备份。每次改完用diff -u 原 diy.php 改后 diy.php > diy_patch.txt生成补丁,升级后用patch /plus/diy.php diy_patch.txt恢复。简单但需要每次升级都手动操作。
第二种是用auto_prepend_file在 PHP 启动前自动加载防重复逻辑。在php.ini或.htaccess里:php_value auto_prepend_file "/path/to/prevent_duplicate.php"。然后在prevent_duplicate.php里检测当前请求的脚本名是不是diy.php等敏感入口,是的话执行拦截逻辑。这种方式不动织梦源码,升级后自动生效。但织梦的钩子机制相对原始,多数项目还是直接改+备份。
运营层面的去重补救
技术修改之外,运营也要建立去重机制。我会在每个客户后台加一段 SQL 视图:
SELECT
DATE(addtime) AS day,
email,
phone,
COUNT(*) AS submits
FROM dede_diyform_xxx
WHERE addtime > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY))
GROUP BY day, email, phone
HAVING submits > 1;每天凌晨跑一次,把 24 小时内同邮箱或同手机号出现 2 次以上的记录标黄,运营人员人工复核。配合上述服务端拦截,基本上能让脏数据降到忽略不计。
从 DedeCMS 视角看为什么不直接换 CMS
有些读者会问"织梦都被官方放弃多年了,为什么不直接迁移到 WordPress 或 Typecho?"。现实里很多老客户企业站存在以下迁移阻力:
第一,存量内容多。一个跑了 10 年的企业站可能有 8000+ 篇产品页、5000+ 张图片、2000+ 个 SEO 收录链接。迁移要保 URL 兼容、保 SEO 权重,工作量比"重做一个新站"还大。
第二,定制功能深。10 年里客户改过的模板、加过的功能、改过的字段都跑在织梦的逻辑里,迁到 WordPress 需要重新开发。
第三,运维已习惯。客户的内容编辑团队已经熟悉织梦后台 5-10 年,重新培训的隐性成本高。
所以"在老织梦上做安全加固"在很多场景下是比"迁移到新 CMS"更现实的选择。这套防重复提交方案是这种"现实选择"的一部分——不是技术理想最优解,但能让一个 2010 年代的 CMS 在 2026 年继续安全运行。
常见问题解答
改完diy.php之后织梦升级会不会被覆盖?
会。织梦官方升级包通常会替换 /plus 目录下的核心脚本。升级前一定要把改动备份成 patch,升级后再回贴。或者用 auto_prepend_file 把拦截逻辑放在外部文件里自动加载,不动织梦源码。但织梦的钩子机制相对原始,多数项目还是直接改加备份。建议每次升级前用 git 或者简单的备份脚本把所有自定义修改的文件打包,避免漏掉某些文件的改动。
用IP拦截会不会误伤公司内网用户?
会有一定概率。对内网集中提交场景比较多的客户,建议把单纯 IP 拦截改成 IP 加 UserAgent 加表单 hash 三段式比对,提升 session 区分度。或者干脆把冷却窗口从 30 分钟缩短到 60 秒,只挡连点。如果客户的内网用户经常需要批量提交(如展会现场多人填表),建议在前端加一个"我不是同一个人"的复选框,勾选后绕过 IP 限制——把判断权交给用户而不是机器。
session在多台服务器集群下能用吗?
默认 session 存在文件里,多机房会失效。集群方案要么把 session.save_handler 切到 Redis(推荐),要么用 sticky session 保证同一用户始终落到同一台机器。织梦本身没有集群部署的官方支持,多机方案需要自己评估。Redis session 配置:php.ini 里 session.save_handler = redis、session.save_path = "tcp://your-redis-host:6379",重启 PHP-FPM 即可。Redis 同时被多台 web 机器读写不会有数据一致性问题,是织梦集群的标准方案。
除了改diy.php,还有哪些文件需要类似处理?
织梦还有 /plus/feedback.php(评论提交)、/plus/guestbook.php(留言板)、/plus/carbuyaction.php(购物提交)等几个公开提交入口,逻辑大同小异。可以把上面的 IP 加 session 拦截抽成一个公共函数 prevent_duplicate_submit() 放到 include/extend.func.php,在每个入口顶部调用一次,统一维护。如果你站点开了搜索 search.php,搜索操作通常不需要防重复(搜索本身是幂等的),但要防爬虫高频抓取,可加频率限制(同 IP 5 秒内最多 10 次)。
这套方案能不能完全防住爬虫脚本?
不能完全防住但能挡住 90% 的低级脚本。CSRF token 能挡住所有不解析 HTML 直接 POST 的脚本(占爬虫的大头);但高级爬虫会先 GET 表单页解析 token 再 POST,这种情况要叠加验证码(Google reCAPTCHA、阿里云验证码、腾讯云验证码任选)才能挡住。完全防住所有爬虫不现实,只要防御成本超过攻击者收益,对方就会去找下家。验证码的代价是降低真实用户的转化率(5%-15% 用户因看到验证码而放弃提交),是否启用要看业务对脏数据的容忍度与对转化率的敏感度。
提交后跳转thank-you页面能不能加用户提交的具体内容回显?
可以,但要小心 XSS。用户提交的内容里可能含 script 标签、img onerror 等攻击代码。如果直接拼接到 thank-you 页面 HTML 里渲染会触发 XSS。处理方法是用 PHP 的 htmlspecialchars 函数转义所有用户输入再输出:echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');。织梦自带的 dede_htmlspecialchars 函数也可以。这是 OWASP Top 10 里 A03 注入类攻击的常见入口,写表单功能时务必养成转义习惯。
有没有现成的织梦防重复提交插件?
织梦插件市场(dedecms.com 的扩展模块)有几个号称防重复提交的插件,但绝大多数已经多年没维护,部分插件还包含后门或恶意代码(2019-2021 年出现过多起)。建议不要装第三方插件,直接按本文方案修改源码更安全可控。如果团队没有 PHP 修改能力,可以付费请专业开发者做一次性修改,预算 200-500 元能搞定,比装来源不明的插件安全。