返回博客

PHP catch 块把数据库报错直传前端——一个藏了很久的信息泄漏坑

做后台管理系统的 API 审计时,发现前端 toast 里偶尔弹出这样的文字:

SQLSTATE[42S02]: Base table or view not found: 1146 Table 'db_name.some_table' doesn't exist

用户看懵了,安全扫描也亮红灯。顺着报错往上查,根子就在控制器里一行再普通不过的代码。

现象

  • 前端 toast 或接口响应体里出现 SQLSTATE[...]Unknown columnDuplicate entry 等 MySQL/PDO 原始错误字符串。
  • 表名、列名、甚至部分 SQL 片段被完整返回给客户端——等于把数据库结构送给了任何会打开 DevTools 的人。
  • 本地开发时从未触发(因为本地库结构与生产对齐),部署到生产环境一旦遇到 schema 差异就暴露。

根因

PHP 的异常链包含大量服务端敏感上下文:

  • PDOException::getMessage() 直接返回驱动层的原始错误,含表名、列名、SQL 执行片段。
  • \Throwable::getMessage() 在解析类错误时还可能带出文件路径。
  • ThinkPHP 的 Db::connect()->name() 操作抛出的 think\db\exception\PDOException 同样如此。

问题在于,最常见的写法把「记录日志」和「返回响应」混成了一步:

// 典型反模式
try {
    $list = Db::name('order_records')->select()->toArray();
    $this->success('ok', $list);
} catch (\Throwable $e) {
    $this->error($e->getMessage());  // 把 PDO 原始报错直接传给前端
}

$e->getMessage() 是给开发者看的调试信息,不是给用户看的提示文案——两者职责不同,却被放进了同一个出口。

坑:异常 getMessage() 不是用户友好消息

PDOException::getMessage() 返回的是驱动层原始字符串,包含表名、列名、SQL。只要有人能调出接口响应,就能读到这些信息做数据库指纹识别。即使是内网管理端,这也是信息泄漏——一旦 XSS 或权限绕过,攻击面立刻放大。

正解

两件事拆开做:服务端记完整日志,客户端给通用提示。ThinkPHP 还有一个额外陷阱:$this->success() / $this->error() 本身会抛 HttpResponseException,必须先 rethrow,否则成功响应也会被吞掉。

use think\exception\HttpResponseException;
use think\facade\Log;
 
try {
    $list = Db::name('order_records')->select()->toArray();
    $this->success('ok', $list);
} catch (HttpResponseException $e) {
    throw $e;                                         // success/error 的内部机制,必须放行
} catch (\Throwable $e) {
    Log::error('获取订单列表失败: ' . $e->getMessage());  // 完整信息留在服务端
    $this->error('获取数据失败,请稍后重试');              // 通用提示给前端
}

如果 ValidationException 或业务级异常(「余额不足」「优惠券已过期」)的 message 本来就是面向用户的,则可以针对性地单独 catch 并透传,其余全部兜底。

批量审计存量代码可以用这条命令快速定位还在泄漏的 catch 块:

grep -rn "error.*\$e->getMessage" app/api/controller/ \
  | grep -v "Log::"

凡是命中的行,都是「把异常直接送前端」的潜在泄漏点。

正解:日志留后端,提示给前端,HttpResponseException 先放行

三步口诀:① catch (HttpResponseException $e) { throw $e; } 放在最前;② Log::error(...) 记完整 message;③ $this->error('通用提示') 给客户端。用 grep 一键扫存量代码。

一句话外卖

$e->getMessage() 是给开发者的调试信息,不是给用户的提示文案——永远别让它穿越服务器边界。