markdown
# 安全网关拦掉 PUT / DELETE 之后,我在入口文件里做了一次「HTTP 动词还原」
> 环境:CRMEB-BZ v4.4.1(ThinkPHP 6 多应用 + Vue 2 / iView 前后端分离)
> 症状:后台删除轮播图、删除 DIY 页面、修改栏目,全部失败。
## 一、现象:一个删不掉的轮播图
点删除,接口没反应,或者报 404 / 连接被重置。看 Network:
DELETE https://example.com/adminapi/diy/del/12
→ 请求压根没到 PHP,被中间的安全网关掐了
排查这类问题,**第一步永远是绕开所有中间层,直连 PHP 打一次**:
```powershell
# ① 直连后端,验证是不是网关问题
curl.exe -i -X DELETE "https://域名/adminapi/diy/del/12" -H "Authori-zation: Bearer <token>"
# ② 顺便验证 PUT 是不是同样被拦
curl.exe -i -X PUT "https://域名/adminapi/diy/set_status/12" -H "Authori-zation: Bearer <token>"
判读方式很干脆:
| 现象 | 结论 |
|---|---|
| 405 / HTML 错误页 / 连接被重置 | 网关(或 Nginx / WAF / CDN)拦的,代码一行都不用改 |
返回 CRMEB 的 JSON(哪怕是 {"status":400}) |
请求已进框架,是路由或参数问题,与方法无关 |
我这边是第二种情况之外的前一种:安全网关只放行 GET / POST。
二、能放行就放行,别急着写代码
PUT / DELETE 被拦,常见来源:
- Nginx 站点配置里的
limit_except - Apache 的
<LimitExcept GET POST> - IIS:WebDAV 模块劫持(
<remove name="WebDAVModule" />) - 宝塔的「免费防火墙 / WAF」规则
- CDN 的防护策略,默认就砍非 GET/POST
能放开就放开,这是唯一不用重打包前端的解。 我这里是网关策略不可动,才走下面的代码方案。
三、影响面比「删轮播图」大得多
先量化一下,别以为只是一个小功能坏了:
php
// app/adminapi/route/*.php
Route::resource('slide', 'v1.setting.Slide');
// ↑ 一组 resource 自动生成 save / read / update / delete
// 其中 PUT 和 DELETE 各占一条,全站共 24 组
再加上显式写的 Route::delete(...) / Route::put(...),受影响的接口上百个。而且不只影响删除------所有「编辑保存」「状态开关」「排序更新」这类走 PUT 的,一起坏。
前端对应的调用点:
js
// view/admin/src/api/product.js
return request({ url: `product/${id}`, method: 'delete' })
view/admin/src/api/ 下 25+ 个文件、上百处 method: 'put' | 'delete'。逐个改成 method: 'post' 显然不现实。
四、方案:前端降级 + 后端还原,必须成对
这是配对方案,单独改任何一边都是无效的,原因很具体:
- 只改后端 :前端继续发原生
DELETE,请求在网关就被掐了,后端那段还原代码永远不会执行,等于白写。 - 只改前端 :请求变成 POST,网关放行,但 ThinkPHP 拿到的动词是 POST,而路由定义是
Route::delete(...)→ 匹配不上 → 落进后台路由表的 miss,返回一个裸 404(没有 JSON body,前端连错误提示都弹不出来)。
完整链路:
#mermaid-svg-zsz4Jcqh6HtsLdKV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zsz4Jcqh6HtsLdKV .error-icon{fill:#552222;}#mermaid-svg-zsz4Jcqh6HtsLdKV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zsz4Jcqh6HtsLdKV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .marker.cross{stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zsz4Jcqh6HtsLdKV p{margin:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label text{fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label span{color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster-label span p{background-color:transparent;}#mermaid-svg-zsz4Jcqh6HtsLdKV .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV span{fill:#333;color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node rect,#mermaid-svg-zsz4Jcqh6HtsLdKV .node circle,#mermaid-svg-zsz4Jcqh6HtsLdKV .node ellipse,#mermaid-svg-zsz4Jcqh6HtsLdKV .node polygon,#mermaid-svg-zsz4Jcqh6HtsLdKV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .rough-node .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label text,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label{text-anchor:middle;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .rough-node .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label,#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label{text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node.clickable{cursor:pointer;}#mermaid-svg-zsz4Jcqh6HtsLdKV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .arrowheadPath{fill:#333333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster text{fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV .cluster span{color:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zsz4Jcqh6HtsLdKV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zsz4Jcqh6HtsLdKV rect.text{fill:none;stroke-width:0;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape p,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zsz4Jcqh6HtsLdKV .icon-shape .label rect,#mermaid-svg-zsz4Jcqh6HtsLdKV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zsz4Jcqh6HtsLdKV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zsz4Jcqh6HtsLdKV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zsz4Jcqh6HtsLdKV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Vue 组件
request({method:'delete'})
axios 请求拦截器
降级为 POST
网关
POST 放行
public/index.php
还原 REQUEST_METHOD=DELETE
TP6 Request
读到 DELETE
Route::delete 命中
控制器执行
4.1 前端:只改一个文件
先确认哪个封装在生效------CRMEB 后台里有个死文件:
js
// view/admin/src/libs/axios.js
this.baseUrl = 'http://admin.crmeb.net/adminapi' // 硬编码官方演示域名
它没有任何 api/*.js 引用。真正生效的是 view/admin/src/libs/request.js(全部 25+ 个 api 文件都是 import request from '@/libs/request')。
改错文件是这类方案最常见的翻车点:代码写了,重新打包了,功能依旧坏,因为你改的那份根本没被 import。
在 request.js 的请求拦截器里、return config 之前插入:
js
// view/admin/src/libs/request.js
// 安全网关只放行 GET/POST,PUT/DELETE/PATCH 降级为 POST,真实方法用 _method 传给后端还原
const realMethod = (config.method || '').toLowerCase();
if (realMethod === 'put' || realMethod === 'delete' || realMethod === 'patch') {
config.params = Object.assign({}, config.params, { _method: realMethod.toUpperCase() });
config.method = 'post';
}
return config
为什么 _method 放 query 而不是 body? 这是本方案第一个关键点:
当
Content-Type: application/json时,PHP 不会 把请求体解析进$_POST。
PUT 请求往往带 JSON 数据体,_method 塞进 body 里,后端从 $_POST['_method'] 取到的永远是 null。而放 query 对 JSON 体和 multipart/form-data 上传都一致生效。
顺带,Object.assign({}, config.params, ...) 而不是直接 config.params._method = ...,是为了不修改调用方传入的对象------避免模块级的 params 常量被污染。
4.2 后端:入口文件还原动词
放在 public/index.php,必须在 require autoload 和 new App() 之前。这是第二个关键点:
TP6 的
Request是懒实例化 的,在$http->run()时才从$_GET/$_POST/$_SERVER超全局数组取值。
所以在这个位置改超全局数组是有效的------框架后面读到的就是你改写后的值。这等于借用了框架的初始化时序,而不是去 hook 框架内部。
php
<?php
// public/index.php
namespace think;
define('DS', DIRECTORY_SEPARATOR);
// PUT/DELETE 传输层降级还原:安全网关只放行 GET/POST 时,
// 前端把 PUT/DELETE 发成 POST + ?_method=XXX,这里在框架实例化
// Request 之前还原真实动词,使路由定义、鉴权、日志零改动继续工作。
$crmebOverride = strtoupper((string)($_GET['_method'] ?? $_POST['_method'] ?? ''));
if (in_array($crmebOverride, ['PUT', 'PATCH', 'DELETE'], true)) {
$crmebPath = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
// 兼容 /index.php/adminapi/... 形式的伪静态
if (strpos((string)$crmebPath, '/index.php') === 0) {
$crmebPath = substr($crmebPath, 10);
}
if (empty($crmebPath) || $crmebPath === '/' || $crmebPath === '/index.php') {
$crmebPath = (string)($_GET['s'] ?? $_SERVER['PATH_INFO'] ?? '');
}
$crmebApp = strtolower(explode('/', ltrim((string)$crmebPath, '/'))[0]);
if ($crmebApp === 'adminapi' || $crmebApp === 'kefuapi') {
$_SERVER['REQUEST_METHOD'] = $crmebOverride;
error_log('[METHOD_OVR] ' . $crmebOverride . ' path=' . $crmebPath);
}
}
// 无论是否命中作用域都摘掉,防止 _method 污染控制器全量取参
unset($_GET['_method'], $_POST['_method'], $_REQUEST['_method']);
require __DIR__ . '/../vendor/autoload.php';
$http = (new App())->http;
$response = $http->run();
$response->send();
$http->end($response);
四道保险,每一道都对应一个具体的坑:
① 白名单校验
in_array(..., ['PUT','PATCH','DELETE'], true) ------ 严格比较,_method=GET、_method=config 这类乱七八糟的值一律不认。传输层还原只做动词映射,不做参数注入。
② 作用域限定到后台应用
只对 /adminapi、/kefuapi 生效。这条不是洁癖,是必要的 :/api 下有微信支付通知和公众号消息推送回调,它们本身是 POST,一旦哪天有人在回调 URL 上挂了 _method,你会得到「支付通知变成一次 PUT 请求 → 路由匹配不上 → 掉单」这种噩梦级故障。
③ unset 无条件执行
_method 现在是个真实存在的 URL 参数。ThinkPHP 的 param() 是 GET + POST 合并,它会作为普通业务参数进入控制器:
- 用
getMore()/postMore([...])显式列字段的地方(CRMEB 绝大多数)→ 无影响 - 用
$request->post()/param()全量取再入库的地方(系统配置保存、DIY 页面数据、组合数据)→ 可能报fields not exists:[_method],或者写进 JSON 字段变成脏数据
最阴的地方在于:这个问题只在一部分接口上出现 。你测删除功能全绿,过两天改系统配置保存炸了,根本联想不到入口文件。所以 unset 放在 if 外面。
④ 路径取值用 REQUEST_URI 而不是 PATH_INFO
这是我第一版踩的坑,也是本篇最值得记的一条:
php
// 反例
$crmebPath = $_SERVER['PATH_INFO'] ?? (string)($_GET['s'] ?? '');
?? 只在 key 不存在 时才兜底。而 Nginx + PHP-FPM 在没配 fastcgi_split_path_info 的情况下,PATH_INFO 往往是存在但值为空字符串 。空字符串是 isset 的,于是:
$crmebPath = '' → $crmebApp = '' → 不等于 'adminapi' → 还原不执行
失效表现极具迷惑性:前端已经发成 POST,网关也放行了,请求进了框架,但因为动词还是 POST,Route::delete() 匹配不上,落进后台路由表的 miss 返回裸 404 ------ 你百分之百会以为是自己路由写错了,绝不会怀疑到入口文件这一行三元上。
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 才是可靠的,Nginx 重写不会改变 REQUEST_URI,它始终是浏览器发出的原始路径。
``