老炮踩坑录 · FIN-F02 · 翻车现场系列
基于「协作金融项目」真实源码,复盘我在 review 里放行的第二处
关键词:明文密码 · root 直连 · Git 历史 · 环境变量 · 最小权限
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:一次 review 的复盘(续)
引子
上一篇复盘操作员模块的默认密码,结尾预告了这篇。有兄弟留言问:配置文件里的密码也算 review 问题?配置又不过编译。
问得好,这篇文章就来回答这个。配置文件在我的 review 里享受过什么待遇,这个待遇的代价是什么,下面按实际文件讲。
交接那天,我把仓库地址发给接手的兄弟。五分钟后他消息过来:本地跑起来了,一行没改。
我回了个"顺利就好"。
复盘写到这儿才想明白,他能一行不改就跑起来,说明数据库密码跟着仓库走完了全程。从建仓那天,到传进下一个接手人的硬盘。全程没有任何人觉得这里有问题,包括我。
现场:一个 datasource 块,三处问题
application.yml 第 8 到 16 行,原文照抄:
spring:
application:
name: xiezuojinrong
datasource:
# MySQL 5,端口 3306;如密码不同请修改 password
url: jdbc:mysql://localhost:3306/xiezuojinrong?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true&connectionCollation=utf8mb4_general_ci
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
三处问题,一个比一个麻烦。
第一处,password: root,明文。真正的重点在它上面那行注释:"如密码不同请修改 password"。这句话说明密码从来没被人设过,它是脚手架模板自带的默认值,团队里没有一个人为它做过决定。review 的时候我看过这行注释,当时想的是"回头有人会改"。回头这个词在工程里的实际含义,多数时候是永远不会。
第二处,username: root。root 是 MySQL 的超级账号,能建库、删库、授权,能读 mysql 库里全部账号的哈希。应用真正需要的,只是当前这个库的增删改查。拿超级账号跑业务,应用的每一行 SQL、每一个可能出现的注入点,碰到的都是整个实例。
第三处,整个项目只有这一份 application.yml。没有 application-dev.yml,没有 application-prod.yml,没有环境分离。url 里写的是 localhost,照这份配置,应用和数据库得部署在同一台机器上。生产部署时有没有人改过 url 和密码、用什么方式改的,我不知道,交接的时候没人说得清。一份连生产长什么样都没体现的配置文件,就这么一直待在仓库里。
顺带一提,url 里的 useSSL=false 和 allowPublicKeyRetrieval=true,是本地开发"能连上就行"的组合。跟着文件进了仓库,也跟着仓库进了每一台克隆它的机器。host 一旦从 localhost 换成远程 IP,账号口令就在网络上明文传输。
这份密码在 Git 里走多远
clone 即交付。 仓库给到谁,密码给到谁。Git 的每次提交都是永久的,密码从建仓第一天起就在历史里,今天把它改掉,git log -p 翻回去照样能看到。真要清理,得动 git filter-repo 或者 BFG 重写历史,所有协作者重新克隆,这笔代价比加一行环境变量高一个量级。
多语句执行开关。 url 里有一个参数:allowMultiQueries=true,允许一次提交跑多条 SQL。MyBatis-Plus 的常规写法用不上它,我猜当初是为了初始化脚本方便开的,为什么开,我没找到任何记录,这本身就是问题的一部分。这个开关平时无事,哪天项目里出现一个拼接 SQL 的点,注入的破坏上限就从单条查询变成任意多条语句。
SQL 全文进 stdout。 mybatis-plus 配置里,log-impl 指的是 StdOutImpl,把每条 SQL 连同实际参数值直接打到标准输出。用户表要是有密码字段,查询参数一起出去。日志文件的访问权限一般比数据库宽,这段输出给排查省下的时间,和它多送出去的数据放一起算,不划算。
我当时为什么放行:配置文件在 review 里的待遇
操作员模块那段 Java 代码,我至少逐行看过。这份 yml,我当时的处理方式是扫了一眼。
review 的时候,注意力全在 Java 文件上。diff 页面里 yml 的改动就几行缩进,没有编译报错,没有 lint 提示,没有测试失败。配置文件享受不到编译器的任何保护,它在 review 里走的是另一条通道:看一眼,没乱码,过。
《10条军规》里军规八讲过:改 yml 要按改代码处理。轮到自己 review 的时候,我还是扫了一眼。
还有一层原因。'内网项目'三个字,当年在组里能挡住所有安全追问。数据库在内网、系统只在内部用、外面进不来,三句话下来,明文密码、root 直连,全都"先这样"。我复盘过的另一个项目,鉴权拦截器整段被注释掉,理由同样是内网系统。
有个细节我没完全确认:这份 yml 是仓库里的最终状态。部署时有没有人用启动参数覆盖过密码、生产库是不是也是 root,我没有证据。这篇按"仓库即事实"来复盘,如果当年部署侧另有一套,严重程度要打折扣。但"仓库里存在明文的超级账号口令"这件事本身,不因为部署侧兜过底就消失。
改法:按成本从低到高,四步
第一步,换账号,不改代码。
给应用建专用账号,只授业务库的增删改查:
CREATE USER 'zjhz_app'@'%' IDENTIFIED BY '换一个像样的口令';
GRANT SELECT, INSERT, UPDATE, DELETE ON zjhz_jsjn.* TO 'zjhz_app'@'%';
FLUSH PRIVILEGES;
应用出了问题能碰到的范围,从整个实例缩小到一张库的读写。这步在数据库上执行完,yml 里改两行就生效。
第二步,密码离开仓库。
yml 里换成占位符:
spring:
datasource:
username: ${DB_USERNAME:zjhz_app}
password: ${DB_PASSWORD}
${DB_PASSWORD} 不带默认值,环境变量没配,应用启动直接失败。这是故意的:少了配置就该起不来,起来之后连着一个错误口令更糟。本地开发用 application-local.yml 配真实值,这个文件进 .gitignore。
第三步,换密码,再清历史,顺序别反。
先在数据库侧把口令换掉,仓库历史里的旧值立刻作废;然后用 git filter-repo 重写历史:
echo 'password: root==>password: REMOVED' > replacements.txt
git filter-repo --replace-text replacements.txt
历史重写完,所有协作者重新克隆。要是嫌重写历史动静太大,只做换密码也行,密码换了,历史里的旧值就是一串废字符。清历史是锦上添花,换密码才是正事。
第四步,顺手两处。
log-impl 从 StdOutImpl 换成 Slf4jImpl,让 SQL 日志走日志框架、受级别管,生产上直接关:
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
allowMultiQueries 没有明确用途就关掉,真有批量脚本要跑,让脚本单独连。
Jasypt 那类加密方案我提一嘴,不推荐上。加密值在仓库里看不见了,可解密密钥总得有人管,管来管去还是同一个问题挪了个位置。环境变量加部署平台的加密变量,是同一件事的不同实现,选顺手的就行。各家平台差异大,这里不展开。
自查清单:五个能直接搜的信号
自查打卡:打开你们项目的 application.yml,看一眼 password 的值。如果它和三年前一模一样,评论区打个 1,我看看有多少仓库三年没换过口令。
老炮点评
上一篇的账还能一人一半:代码是人写的,我是点通过的人。这一处的账没得分,密码连写的人都没有,它是模板自带的,从头到尾没被任何人碰过。审查链上唯一看过它的活人是我,扫了一眼。
配置文件的问题,代价都在编译器管不到的地方兑现。代码写错,编译器当场说话;配置配错,MySQL 说话,说话的地点是生产环境。
那位接手的兄弟现在应该还留着那个仓库。密码在他硬盘里,也在当年每一个克隆过仓库的人硬盘里。仓库能删,硬盘删不掉。
最后问一句:你们项目的数据库口令,现在还写在配置文件里的扣 1,已经进环境变量或者配置中心的扣 2。我看看哪个多。
相关阅读:
- 《默认密码 123456 那行代码,过了我的 review》——同一批 review,第一处
- 《10条军规》军规八:改 yml 和升版本,按改代码处理
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。
下期预告:《密码重置接口的权限校验,校验的是谁的权限》
resetPwd 接口里有一行
if (user != null && ...)。拦截器没生效、user 为 null 的时候,这个条件直接短路,403 不抛,谁的密码都能改。下一篇拆这行条件。