2026 年 8 月 10 日,v1.0.0 发布,功能列表一只手数得过来。 2026 年 10 月 15 日,v3.1.3 发布,360 项测试全绿。 中间只隔了 66 天。
这篇文章不讲成功学,讲讲这 66 天里我们是怎么一路踩坑、填坑、再挖坑给自己跳的。 关键位置都贴了当时真正写进仓库的代码------故事可以讲得活泼,代码不能编。
〇、先看一张"体检表"
| 版本 | 日期 | 主题(一句话) |
|---|---|---|
| 1.0.0 | 08-10 | 出生证:栏目、文章、CKEditor、主题 |
| 1.1.0 | 08-17 | 加了个 Word 导入,喘口气 |
| 2.0.0 | 08-29 | 成人礼:RBAC/审计/工作流等八大模块 |
| 2.1.x | 08-29~30 | 补丁日:N+1、伪静态分页、环境变量改名 |
| 2.2.0 | 08-31 | 插件优先架构:什么都能做成插件 |
| 2.3.0 | 09-02 | i18n 1.0:界面中英双语,表单也插件化 |
| 2.4.0 | 09-04 | 四大件:Alembic + 全文搜索 + Docker + OSS |
| 2.4.1 | 09-05 | 安全审计:默认密钥能伪造管理员会话 |
| 2.4.2 | 09-06 | CSP 太严把自己的 CDN 拦了 + Docker 连环坑 |
| 2.5.0 | 09-07 | 内容级多语言 + Redis |
| 2.5.1/2 | 09-08/09 | 多语言搜索 + 一键翻译插件 |
| 2.6.0~4 | 09-10~12 | 工程化补课:CI、360 测试的雏形、JWT、会员 |
| 3.0.0 | 09-15 | 可视化拖拽页面构建器 |
| 3.1.0 | 10-10 | 数据库一键迁移插件 |
| 3.1.1 | 10-11 | 高危!API 默认免鉴权 + 全站补 CSRF Token |
| 3.1.2 | 10-13 | 中危五连:输入校验与上传安全 |
| 3.1.3 | 10-15 | 性能与并发:设置缓存、浏览量写放大、N+1 |
66 天里,代码行数翻了几倍,测试从 0 到 360,发版从手抖靠运气变成有 Checklist、有 CI、有门禁。下面按时间线开唠。

一、v1.0:先跑起来,比什么都重要
v1.0.0 的需求朴素得令人感动:栏目分列表页和单页两种类型,文章用 CKEditor 全功能汉化版,单页支持自定义字段直接录,再加上碎片管理、友情链接、自定义表单、多主题模板------齐活。一个典型的"给企业官网用"的内容管理系统,不需要微服务,不需要消息队列,需要的是客户拿到手能填内容、前台能正常显示。
但 v1.0 的 changelog 里藏着两个很有意思的 fix,基本预示了后面两个月的主旋律。
第一个是中文文件名上传被误判为类型不允许 。Flask 推荐的 secure_filename() 出于安全考虑会把非 ASCII 字符洗干净,"产品手册.docx" 洗完可能只剩个 ".docx"------文件名没了,后缀判断直接失灵,上传被拒。后来这条演变成一条铁律:文件类型判断必须从原始文件名提取后缀,不能信清洗后的名字。
第二个是带路径参数的路由分页链接 BuildError 。栏目页路由带 slug 参数,分页 url_for 漏传参数就抛异常,前台直接 500。路由和 url_for 的爱恨情仇,这才刚刚开始------后面伪静态那章还会重逢。
v1.1 很小,只加了 Word(.docx)导入正文。这个版本存在的最大意义可能是:让我们习惯了"小步快跑、语义化版本"的节奏。真正的风暴在两周后。
二、v2.0:19 天后的成人礼
8 月 29 日,距离 v1.0 才 19 天,v2.0.0 带着八大模块砸了下来:RBAC 权限体系(角色/权限点/多角色、栏目级授权、4 个预设角色 + 17 个权限点)、审计日志(全后台操作留痕)、内容工作流(草稿/待审核/已驳回/已发布/已归档状态机 + 版本快照 + 定时发布)、备份与恢复(mysqldump / JSON / PGDump 三种姿势)、登录安全(防暴破锁定、验证码、异地提醒、后台地址前缀可自定义)、上传安全(MIME + 后缀双校验、SHA-256 去重、Pillow 压缩缩略图)、表单消息通知(邮件 / 企业微信)、SEO 与性能(伪静态、sitemap、robots 可视化、页面缓存)。
为什么跳这么大?因为 v1.x 的用户模型是"所有登录用户都是超管"------做内部玩具可以,卖给企业是要出事的。v2.0 本质上是把"玩具"改造成"产品"的一次性总清算。
第一个迁移大坑:数据不是说迁就能迁
v1.x 没有 Alembic(那是 v2.4 才有的奢侈品),v2.0 的表结构变化只能写裸迁移脚本 scripts/upgrade_v2.py。这脚本在紧接着的 v2.1 被连修了两个回填缺陷,每个都是血泪。
坑一:SQL 优先级缺括号,隐藏文章可能被"平反"成已发布。
is_enabled 布尔字段要升级成工作流状态,回填逻辑本意是"启用且满足某条件 → 已发布,否则 → 草稿"。WHERE 子句里 AND/OR 混用又忘了加括号,SQL 的运算符优先级直接让条件串了味。这种 bug 的可怕之处在于:它不报错,它只是悄悄把客户藏起来的文章挂到了首页上。 等客户发现,可能搜索引擎都已经收录了。
坑二:ALTER TABLE ADD COLUMN DEFAULT ... 会立即回填存量行。
在 MySQL 里给已有表加一个带默认值的列,老数据会立刻被填上默认值。本来的迁移计划是"先加列,再根据老字段 is_enabled 逐行映射状态",结果列加完一看------映射依据还在,但默认值已经抢先污染了所有行,好在当时默认值设计得和映射结果撞不了车,才算惊险过关。最后的方案是引入 schema_migrations 版本记录表:每一步迁移只执行一次、记录在案,重跑自动跳过,保证幂等且绝不覆盖运行期数据。
这两个坑后来沉淀成一条原则:迁移脚本默认假设自己会被跑第二遍,也默认假设库里有真实数据。 这条原则在后来的 Alembic 时代依然成立。
三、v2.1:发版当天就打补丁,不丢人
v2.0.0 和 v2.1.0 是同一天(08-29)。听起来很赶,实际是 v2.0 一落地就被实测教育了。
后台用户列表恒显"暂无用户"------模板取值字段写错了,外加每行单独查角色,一个经典的 N+1 bug 套餐。菜单"显示但点了 403"------没权限的菜单应该直接隐藏,而不是摆个按钮逗用户玩。这两个都是"开发者自测用超管账号,怎么点都对"才漏过去的。
更隐蔽的一个问题出在审计日志上。写审计的函数里直接访问了 request 和 current_user,在 HTTP 请求里岁月静好,可一定时任务和命令行脚本调用,没有请求上下文,当场抛异常------而且异常被外层静默吞掉,审计日志就这么无声无息地丢了。v2.1 的修复思路是把"从请求里取信息"变成"有就取、没有就用系统身份":
python
# app/models/audit.py
@classmethod
def record(cls, op_type, module, target_id=None, target_name=None, detail=None,
user=None, ip=None, user_agent=None):
"""无请求上下文(定时任务/命令行)时跳过 request/current_user 兜底,
保证审计写入不会因此静默丢失。"""
from flask import has_request_context, request
from flask_login import current_user as _cu
try:
if user is not None:
u = user
elif has_request_context():
u = _cu if _cu.is_authenticated else None
else:
u = None # CLI/定时任务:系统身份
_ip = ip or (request.remote_addr if has_request_context() else '')
_ua = user_agent
if _ua is None and has_request_context() and request.user_agent:
_ua = request.user_agent.string[:255]
log = cls(user_id=u.id if u else None, username=u.username if u else '',
ip=_ip or '', user_agent=_ua or '', ...)
从此审计写入有个铁规矩:任何依赖请求对象的代码,先问 has_request_context()。
伪静态分页:一个正则调教了好几天
v2.1 还做了一个当时看来很小、后来证明很英明的功能:伪静态分页规范化 。规则是:第 1 页用 /{slug}.html,第 N 页用 /{slug}-{N}.html,page<=1 自动 302 回第一页;老的 /column/{slug}?page=N 继续返回 200,向后兼容。
为了让 8 个列表模板在"伪静态开/关"之间无缝切换链接格式,专门抽了一个全局 Jinja 函数,所有模板不许自己拼 URL,只能走它:
python
# app/frontend/views.py
def frontend_pager_url(column, page):
"""前台列表分页 URL 生成(伪静态适配)。
- seo_rewrite_enable=on:第一页 /{slug}.html,第 N 页 /{slug}-{N}.html
- 关闭:/column/{slug}?page=N(动态查询参数,原有行为)
"""
try:
page = int(page or 1)
except (TypeError, ValueError):
page = 1
if Setting.get('seo_rewrite_enable') == 'on':
if page <= 1:
return f'/{column.slug}.html'
return f'/{column.slug}-{page}.html'
return url_for('frontend.column_detail', slug=column.slug, page=page)
真正刺激的是路由解析。slug 里允许带横杠,那 dash-2024-tester-2.html 到底是"名为 dash-2024-tester 的栏目第 2 页",还是"名为 dash-2024-tester-2 的栏目第 1 页"?正则的顺序和贪婪度调了好几轮,最后定下两条铁律:article- 静态前缀规则的优先级必须高于分页规则;含 -数字 的 slug 先整体当 slug 查库,查不到才切分页后缀。 分页缓存也必须按页独立,防止第 1 页和第 2 页串内容。
v2.1.1 干了件"社死但正确"的事:环境变量前缀从项目早期拼错的 ZHOCMS_ 统一改成 ZHYCMS_(ZHYCMS_ENV、ZHYCMS_SECRET_KEY、ZHYCMS_DB_URI)。旧前缀保留兼容回退,但丑话说在前面:未来主版本必删。另外把初始化向导里的数据库选项重排了------MySQL/PostgreSQL 置顶并标注"生产推荐",SQLite 标注"仅开发/测试",旁边红字提示 SQLite 并发写入风险。
四、v2.2:插件优先------把"什么都想加"关进笼子
到 v2.1 为止,所有功能都长在核心里。每接一个客户需求,核心代码就胖一圈。v2.2 做了一个架构层面的决定:插件优先(Plugin First)------除了内容管理最内核的东西,其他一切优先做成插件。
一个插件长什么样
一个插件就是 plugins/<slug>/ 目录 + 一份 manifest.json + 一个继承 PluginBase 的类。基类把插件能声明的东西列得明明白白:
python
# app/plugin_api.py
class PluginBase:
"""插件基类:子类按需覆写钩子方法。"""
# ---- 元数据(与 manifest.json 保持一致,以类属性为准) ----
slug = ''
name = ''
version = '1.0.0'
min_core_version = '' # 核心版本低于此值时拒绝启用
requires = [] # 启用前这些依赖插件必须已启用
extends = '' # 父插件 slug(基于谁二次开发)
# ---- 声明式注册信息 ----
permissions = [] # [(code, label, desc)] 启用时幂等种子权限点
preset_role_grants = {} # {role_code: [perm_code, ...]} 给预设角色补授权
audit_modules = [] # [(module_code, label)] 审计筛选下拉聚合
# ---- 代码钩子(节选) ----
def get_migration_files(self): ... # v2.4:插件自带 Alembic 迁移
def get_storage_drivers(self): ... # v2.4:注册云存储驱动
def get_search_provider(self): ... # v2.5.2:插件内容进全文搜索
def get_builder_widgets(self): ... # v3.0:注册页面构建器组件
def get_builder_layouts(self): ... # v3.0:贡献默认布局片段
def get_frontend_guard(self): ... # v2.6.4:前台访问守卫(会员鉴权)
插件能注册的东西几乎无所不包:蓝图、模板函数、后台菜单、API 端点、数据表、sitemap、演示数据钩子、审计模块。但真正的设计精华是启停机制 ------启动时全量导入所有插件并完成注册,运行时只用一个设置项 enabled_plugins 做门控:
python
# app/plugin_system.py
SETTING_KEY = 'enabled_plugins'
def enabled_slugs():
"""读取当前启用插件 slug 集合(带 60s 缓存)。"""
cached = cache.get(_ENABLED_CACHE_KEY)
if cached is not None:
return cached
raw = Setting.get(SETTING_KEY, '') or ''
slugs = {s.strip() for s in raw.split(',') if s.strip()}
try:
cache.set(_ENABLED_CACHE_KEY, slugs, timeout=_ENABLED_CACHE_TTL)
except Exception:
pass # 缓存失败不影响功能
return slugs
门控体现在三个层面:插件的视图函数进去先查启用清单,禁用直接 404;菜单聚合时跳过禁用插件;模板函数包一层守卫,禁用时返回空值------所以前台模板里写着 {{ banner_items() }},禁用轮播图插件后页面也不会报错,只是那块区域安静地消失。
这个设计白送了三个好处:启停只改一个设置值,即时生效、无需重启;天然兼容多 worker(所有进程都读同一份设置,没有进程内状态);禁用是可逆的 ------禁用只移除清单,不删表不丢数据,客户手抖关错了,再开就行。启用时才做三件事:种子权限点 → 给预设角色补授权 → db.create_all() 补建插件表。
模板解析也定了三级链:主题覆盖 → 插件自带 → default 主题兜底,任何一层缺失都不会白页。
上传插件?先过八关
允许上传第三方插件压缩包,等于允许别人往你的服务器上写代码。所以上传校验做成了流水线,任何一步失败,整包拒绝:
markdown
1. 扩展名白名单(仅 .zip / .tar.gz / .tgz)
2. 压缩包损坏检测(打不开直接拒)
3. 路径穿越预检(条目名含 ../、绝对路径 → 拒)
4. 解压后 realpath 二次校验(防符号链接绕过预检,symlink 静默跳过)
5. manifest.json 必须是合法 JSON 且含 slug
6. slug 必须符合 ^[a-zA-Z0-9_-]{2,32}$
7. __init__.py 必须存在
8. 禁止覆盖已存在插件(先备份并删除旧目录再传)
全程在临时目录操作,任何失败都不写入 plugins/,finally 里彻底清理临时文件。主题上传是同款思路的 8 步校验,还额外要求必备模板(index/list/article/page/base/404/500.html)全量存在,缺一个都不许启用;8 套内置主题的 manifest 里标了 builtin: true,禁止覆盖、禁止删除。
危险操作(删除插件/主题)还配了独立的一次性图形验证码:弹窗里必须渲染验证码图片和输入框,不先请求验证码端点就提交,必然提示"验证码已过期"------这条经验后来在删除确认弹窗上又救过一次场,那是 v3.1.1 发布日补丁的故事了。
"核心功能插件化"的迁移艺术
v2.2 还干了件容易挨骂的事:把友情链接从核心挪进了插件。后台路由没了?菜单项没了?老站不炸才怪。最后的迁移方案是"四不变"原则:
- 表名不变 (
friend_links)------数据零搬迁; - 审计模块代码不变 (
friend_link)------历史日志翻译正常; - 后台路由路径与端点名不变 (插件蓝图挂同一个
admin_bp)------书签和肌肉记忆零变化; - 设置键不变------通知配置、历史数据全部保留。
老站升级后首次启动自动启用一次友链插件,用 friend_link_plugin_migrated 这个 Setting 标记保证只做一次,新装站点不触发。前台模板统一改成调用插件全局函数,核心自动包裹启用守卫。后来 v2.3 的表单插件化用的是同一套配方,再后来 v3.1.0 的数据库迁移、各种核心/插件边界调整,"四不变"成了标准动作。
同期 REST 内容 API 也上线了:/api/v1 蓝本,统一响应包 {code, message, data, meta},带总开关、Token 鉴权和 CORS 白名单。当时觉得鉴权写得挺稳------这个错觉在 v3.1.1 被一个高危 issue 狠狠干碎,后面细说。
五、v2.3:双语世界与"插件独立翻译域"
企业客户有出海需求,v2.3 引入 Flask-Babel 做界面国际化(i18n 1.0,此时只管界面文案,内容数据的多语言是 v2.5 的事)。Locale 选择搞了一条完整的优先级链:URL 的 ?lang=xx(一次性,写回 session)→ session → cookie → Accept-Language 自动匹配 → 默认 zh 兜底。后台有可视化配置页:总开关、默认语种、可用语种清单,改动写审计。
这里有个关键克制:i18n 默认关闭,关闭时全站按中文渲染,与 v2.2 行为完全一致。 做企业软件,"默认行为不变"这六个字值得贴在显示器上------任何新功能的默认状态都应该是"不装不知道、装了吓一跳"的反面:不开就和没有一样。
但真正有意思的工程问题是:插件的文案怎么翻译?
如果所有插件的译文都塞进核心的 messages.po,那插件就无法独立打包分发------第三方开发者做个插件还得给核心提翻译 PR,荒谬。方案是插件独立翻译域:
javascript
plugins/<slug>/translations/<lang>/LC_MESSAGES/messages.po
模板里:{{ _p('banner', '轮播图') }}
Python 层:self._('轮播图')
每个插件自带译文目录,核心渲染时按插件 slug 找对应翻译域;插件启停不影响核心译文,打包时译文随目录一起走,装到别的站点上译文立即可用。
同期还加了两套英文主题(default_en、manufacturing_en),并做了一个对成交帮助极大的联动:安装向导里选英文制造业演示数据时,系统会自动开启 i18n(i18n_enable=1、默认 en、可用 zh,en)、自动启用 banner/product/friend_link/form 四个插件,并生成全英文的栏目、文章、产品、轮播图、友链和表单------客户装完第一眼看到的就是一个完整的英文企业站,而不是一堆"请添加内容"的空壳。
这个周期还修了一个 .gitignore 引发的悬案:app/static/uploads/ 整个被 ignore,结果 demo/ 目录下 15 张演示图片进不了仓库,客户装完首页全是裂图。改成 uploads/* 加一行白名单 !uploads/demo/ 才解决。ignore 规则也是代码,也得 review,也会出生产事故。
六、v2.4:四天塞下四大件
9 月 4 日,又一个大版本,一口气上了四个大件。
6.1 Alembic:终于有了正经迁移框架
裸 SQL 迁移脚本的日子过够了,引入 Flask-Migrate(封装 Alembic)。但历史包袱摆在眼前:已经存在的老库怎么办?让客户手写 flask db stamp head?不现实。
方案是启动时自动探测:发现库中没有 alembic_version 表(即 v2.3.0 及更早的库),自动 stamp 一个 baseline(基线迁移 0001 标记的就是 v2.3.0 的完整 schema),此后只执行增量迁移。老站升级零手动操作,新库则从 0001 正常走完全程。插件也不被遗忘:PluginBase.get_migration_files() 把插件 migrations/versions/ 下的脚本自动合并进 Alembic 的 version_locations;没有迁移文件的插件仍由 db.create_all() 兜底建表。考虑到 SQLite 不能直接 ALTER 表的限制,全局开启了 render_as_batch=True。
6.2 全文搜索:纯 Python 方案 + 故障自动降级
默认 Whoosh(纯 Python,零外部服务)+ jieba 中文分词,索引标题、去 HTML 正文、摘要和栏目名,支持相关度排序;大站可选 Meilisearch,后台一键切换。关键设计是降级链:Whoosh 故障时自动退回 SQL LIKE,搜索功能绝不中断 。文章保存/删除时通过统一的 clear_content_cache 钩子更新索引------这个钩子后来被复用了无数次(页面缓存、搜索索引、栏目树缓存、插件索引......),是全项目最忙的函数,v3.1.3 还在给它加戏。
6.3 Docker:多阶段、非 root、健康检查
Dockerfile 采用多阶段构建,builder 阶段装依赖编译轮子,runtime 阶段基于 python:3.12-slim 只拷贝成品,以非 root 用户跑 gunicorn。容器入口 entrypoint.sh 负责一条完整的启动链:等待数据库就绪 → 执行数据库迁移 → 恢复被 volume 挂载遮盖住的演示图片 → 编译 i18n 翻译 → 启动 gunicorn。还新增了 /healthz 健康检查端点(DB ping + JSON),并且特意豁免初始化拦截------容器探针在安装向导都没跑完时也得能探活,不然编排系统会把还没配置的新站反复杀掉。
6.4 OSS:抽象层先行,默认行为不变
对象存储没有直接写进核心,而是先在核心定义了存储驱动协议,再由官方插件 oss_storage 提供阿里云 OSS / 腾讯云 COS / 七牛云 Kodo 三家实现:
python
# app/utils/storage.py
class StorageDriver:
"""存储驱动基类。云驱动由 oss_storage 插件实现并注册。"""
name = ''
keeps_local_copy = True # 本地驱动 True,云驱动 False
def save(self, local_path, key): ... # 发布文件,返回 URL
def save_bytes(self, data, key): ... # 字节流上传(子类可直接对接 SDK)
def delete(self, key): ...
def exists(self, key): ...
def public_url(self, key): ...
def health_check(self): ... # 连通性自检 (ok, message)
上传唯一入口 save_upload_file() 改成"先在本地完成校验/压缩/缩略图,再发布到当前驱动"。云 SDK 全部懒加载,没装 oss2 之类的包时配置页给出安装指引,不拖慢不用云存储的站点。三条设计决策值得一说:默认本地驱动,行为与旧版完全一致 ;uploaded_files 表新增 storage 列记录每个文件归属哪个驱动,切换厂商后旧文件 URL 不失效;驱动注册按"归属插件"门控,插件禁用后其驱动自动回退本地,而不是写死某个插件名。
七、v2.4.1:安全审计教我们做人
v2.4 发布第二天,安全审计报告甩在桌上,8 个问题,第一个就是严重(Critical)。
硬编码默认 SECRET_KEY(CWE-798)
app/config.py 里有一个写死的回退密钥,而源码是公开的。Flask 的默认 session 是客户端签名 Cookie------知道密钥就能离线伪造任意管理员会话,不需要爆破,不需要请求,拿着伪造好的 Cookie 直接进后台。
修复后的密钥解析是三级优先,且每一级都不回退到硬编码:
python
# app/config.py(节选)
# 1) 环境变量 ZHYCMS_SECRET_KEY 优先
if env_key:
return env_key
# 2) instance/secret_key 持久化文件
key_path = os.path.join(BASE_DIR, 'instance', 'secret_key')
try:
if os.path.exists(key_path):
with open(key_path, 'r', encoding='utf-8') as f:
key = f.read().strip()
if key:
return key
except OSError:
pass
# 3) 首次启动自动生成并落盘(权限 600)
key = secrets.token_hex(32)
try:
os.makedirs(os.path.dirname(key_path), exist_ok=True)
with open(key_path, 'w', encoding='utf-8') as f:
f.write(key)
os.chmod(key_path, 0o600)
except OSError:
# 无法落盘时也使用本次随机密钥(至少不用硬编码值),仅影响重启后会话失效
pass
return key
代价是存量部署升级后旧会话全部失效,所有人重新登录一次。但这根本不算代价------这是续命。
搜索高亮:转义与包裹的顺序之争
高危问题第二个:搜索结果页存储型 XSS(CWE-79)。文章标题是作者可以随意填的,如果标题里塞 <script>,而搜索高亮过滤器为了把关键词包进 <mark> 直接对原文做字符串替换、模板里再 |safe 渲染,脚本就执行了。修复原则后来被抽象成全项目处理富文本/纯文本的标准动作------先整体转义,再只给转义后的匹配文本包标签,顺序永远不能反过来:
python
# app/utils/helpers.py
@app.template_filter('highlight')
def highlight_filter(value, keyword=''):
"""安全修复(v2.4.1):先对原文与关键词做 HTML 转义,
再仅对转义后的匹配文本包裹 <mark>,杜绝存储型 XSS。"""
from markupsafe import escape, Markup
if not value or not keyword:
return value or ''
import re as _re
safe_value = escape(str(value))
safe_keyword = escape(str(keyword))
pattern = _re.escape(str(safe_keyword))
result = _re.sub(
pattern, f'<mark>{safe_keyword}</mark>', str(safe_value),
flags=_re.IGNORECASE
)
return Markup(result)
开放重定向:// 开头的 URL 不是相对路径
登录和语种切换的 next 参数会原样 302,传 //evil.com 时浏览器会按当前协议跳去 evil.com------钓鱼链接可以长得和你家站点一模一样。修复不长,但条件一个都不能少:
python
# app/admin/auth.py
# 拒绝协议相对 URL(如 //evil.com),只允许站内相对路径
next_url = request.args.get('next') or ''
if (not next_url
or not next_url.startswith('/')
or next_url.startswith('//')
or next_url.startswith('/\\')):
next_url = url_for('admin.dashboard')
return redirect(next_url)
同批修复的还有:run.py 硬编码 debug=True(CWE-489,生产环境开 Werkzeug 调试器约等于裸奔,改为跟随配置类);Cookie 显式声明 SameSite=Lax、生产环境 Secure;全站 after_request 统一注入安全响应头基线(X-Frame-Options、nosniff、Referrer-Policy、CSP 含 frame-ancestors 'self');备份恢复文件强制落在非 Web 目录 instance/backups 且恢复完立即删除;表单导出 Excel 防公式注入(= + - @ 开头的单元格前置单引号,CWE-1236)。
这一天学到的最大一课:安全配置不能有"默认值能跑就行"的心态,默认值必须是安全的(secure by default)。 这句话在 v3.1.1 还要再复习一次,而且是更疼的方式。
八、v2.4.2:安全加固会"反伤",Docker 坑也有连环套
安全策略不是写上去就完事了,v2.4.2 深刻体会了什么叫加固反伤。
CSP 把自己的静态资源全拦了
v2.4.1 上了严格 CSP(script-src/style-src 'self'),第二天后台直接"裸奔":AdminLTE、FontAwesome、jQuery、icheck-bootstrap,外加 6 套前台主题的 Bootstrap,全走 jsdelivr CDN,全被浏览器按 CSP 拦在门外。
团队面临一个选择:放宽 CSP 把 cdn.jsdelivr.net 加白,一劳永逸;或者把资源全部本地化,保持 CSP 严格。最后选了难的那条:新增 app/static/vendor/ 目录,把全部第三方静态资源拉回自己服务器,9 个模板共 36 处外链逐一改成 url_for('static') 本地引用,CSP 一个字符都不放宽。顺带还治了 CDN 抽风断供和 Referer 外带两个隐忧。
CSRF 第二道防线:同源校验
光有 SameSite=Lax 不够------老浏览器根本不认识 SameSite,同站子域场景也有缺口。v2.4.2 增加了 Origin/Referer 同源校验钩子,与 Cookie 属性形成纵深:
python
# app/__init__.py
@app.before_request
def _csrf_same_origin_check():
if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'):
return
# REST 内容 API 使用 X-API-Token 头鉴权(不依赖 Cookie),豁免
if request.path.startswith('/api/'):
return
source = request.headers.get('Origin') or request.referrer
# 非浏览器客户端(curl/SDK/脚本)无 Origin/Referer:放行------
# 此类请求无法被跨站页面诱导发起,且已有 SameSite=Lax 兜底
if not source:
return
source_host = (urlparse(source).netloc or '').lower()
if source_host and source_host != (request.host or '').lower():
abort(403, description='跨站请求被拒绝(CSRF origin check failed)')
注意"无头请求放行"这个决定:没有 Origin/Referer 的请求来自 curl/SDK 这类非浏览器客户端,跨站页面无法诱导它们发出,硬拦只会误伤正常集成。安全策略的边界要画在"攻击实际发生的路径"上,而不是为了数字好看一刀切。
Docker 连环套
容器化上线后坑是成套出现的。第一坑:PyMySQL 连 MySQL 8 默认的 caching_sha2_password 认证需要 cryptography 库,缺了它 worker 启动即崩,补上依赖只是第一步。第二坑:容器不能以 root 跑应用,entrypoint 要先用 root 修正 volume 挂载目录的属主,再通过 gosu 降权执行。第三坑最有代表性------容器里向导填 localhost 连库失败 :初始化向导让用户填数据库主机,用户很自然地写 localhost,可容器里的 localhost 是容器自己,不是宿主机。修复思路后来成了通用原则:
检测到
ZHYCMS_DB_URI环境变量时,向导直接隐藏数据库配置区,POST 完全忽略数据库表单字段。环境已经替你决定的事,就别再问用户。
还修了维护模式页面 500:关站模板渲染时漏传 seo 变量,而主题 base.html 的 meta keywords 依赖它,结果一关站所有页面反而报 UndefinedError。上线前要把"每个错误路径"本身也测一遍------503 关站页自己不能 500。
九、v2.5:出海刚需------内容级多语言与 Redis
v2.3 的 i18n 只翻译界面按钮,客户真正要的是"同一篇文章有中文版和英文版"。v2.5 上了内容级多语言:核心新增 article_translations、column_translations、fragment_translations 三张翻译关联表,插件侧产品、表单、招聘、友链四张翻译表跟进。主表保留默认语言字段(冗余但简单,列表查询不用 join),翻译表存非默认语言,查不到时回退主表。
前台模板侧只加一个全局函数 t(obj, field),6 套主题 44 个模板逐一接入。它的实现把所有边界情况都考虑到了:dict 形态的碎片值、i18n 关闭态、目标语言即默认语言、翻译存在但为空......
python
# app/utils/i18n_content.py
def t_field(obj, field, locale=None):
"""取对象的多语言字段值;无翻译或为空时 fallback 主表默认语言字段。"""
if isinstance(obj, dict): # Fragment.get_dict() 形态
return obj.get(field, '')
if not i18n_enabled(): # i18n 关闭:直接回主表
return getattr(obj, field, None)
target = locale or _safe_current_locale()
if target == get_default_locale(): # 默认语言:主表即权威
return getattr(obj, field, None)
translations = getattr(obj, 'translations', None)
if translations:
for tr in list(translations):
if getattr(tr, 'locale', None) == target:
val = getattr(tr, field, None)
if val not in (None, ''):
return val
return getattr(obj, field, None) # 没翻译?回退默认语言,绝不留空
两个冷静的设计决定值得划重点。第一,slug 不随语言变化 ------URL 永远稳定,切语言只切内容,SEO 和外链不会分裂。第二,页面缓存键必须加 locale ------中文缓存和英文缓存物理隔离,绝不串页。串页缓存这类亏我们已经吃过一次:v2.6.3 之前切换主题不清缓存、缓存键又不含主题标识,旧主题渲染的 HTML 在 TTL 窗口里继续命中,客户切完主题刷新一看还是旧皮肤。后来的规矩是双保险:切主题无条件 cache.clear(),缓存键再追加 |theme=<slug> 纵深防御,多 worker 或清理失败时也不串。
Redis 的接入同样克制,不做运行时开关,启动时探测一次即可:
python
# app/__init__.py
def _resolve_redis_url(app):
"""REDIS_URL 有值 → 尝试 ping,可达则启用,不可达回退 SimpleCache。
无环境变量 → None(SimpleCache + Cookie Session)。"""
env_url = os.environ.get('REDIS_URL', '').strip()
if not env_url:
return None
try:
conn = _redis.from_url(env_url, decode_responses=False)
conn.ping()
return env_url
except Exception as e:
app.logger.warning(f'[Redis] 连接失败({e}),回退 SimpleCache')
return None
配上 Redis 就启用 RedisCache + Flask-Session 服务端会话(支撑多实例负载均衡,也降低 XSS 窃取会话的风险);不配就回退单机零依赖组合。后台只读状态页展示探测结果,故意不提供手动开关------运行时在两套 session 机制之间横跳,比没有 Redis 危险得多。
十、v2.5.1 ~ v2.5.2:让搜索和翻译也"说人话"
v2.5.1 解决一个细想很合理、不细想会漏掉的问题:开了英文站后,用英文关键词搜不到英文文章。原因是索引只有一套。修复后每个 locale 一份独立索引(Whoosh 按 instance/search_index/<locale>/ 分目录,Meilisearch 按索引名分),每篇文章的每种语言都写一份文档,写入内容经 t_field 解析------有翻译用翻译,没翻译回退默认语言,与前台渲染表现完全一致:未翻译的文章不会因为用户切了语言就"搜不到了"。
v2.5.2 的一键翻译插件支持百度/有道/Google/DeepSeek 四家(DeepSeek 走 OpenAI 兼容协议,长文排版保持最好,也能改地址接通义/智谱/Kimi),长文按块级标签和换行的安全边界自动分片,规避各家单请求长度限制,译文只回填不自动保存,留给人工校对。
这个版本顺带做了两件影响深远的小事。其一,抽象出 SearchProvider 协议:插件通过 get_search_provider() 钩子把自有内容(产品、岗位)送进全站搜索,索引文档用 uid = 类型:id 加 type 字段区分,各类型不再互相覆盖,搜索结果还能按类型生成正确的详情链接。其二,启动时自动编译翻译 :发现 messages.mo 缺失或落后于 .po 就自动编译。此前 .mo 不入库,忘了本地编译就发版,英文站全站回退中文,这种"我本地明明好好的"事故值得用一劳永逸的自动化终结。
十一、v2.6 系列:三天集中还债,测试从 34 到 113
功能一路狂奔到 2.5,回头一看工程基建几乎为零:没有 CI,没有发布检查,常量散落各处,错误处理各写各的。2.6 的四个版本(09-10 到 09-12)就是集中还债。
v2.6.0 先立规矩。 RELEASE.md 发布 Checklist 把"版本一致性 → 迁移 → 翻译 → 文档 → 测试 → tag → 发布后验证"固化成流程,配 scripts/check_release.py 自动校验 CMS_VERSION / CHANGELOG / README / wiki.html 四处版本号一致。pytest 脚手架落地(隔离 SQLite + 临时索引目录 + session 注入登录),GitHub Actions 三 Python 版本矩阵(3.10/3.11/3.12)跑测试、发布检查、常量门禁三个 job。
统一异常处理接管 400/403/404/405/500 以及兜底 Exception,按请求路径分发三种协议------API 调用方要 JSON,后台管理员看后台错误页,访客看主题错误页,绝不能让 API 消费者收到一段 HTML:
python
# app/errors.py
def _resolve_protocol():
"""判断当前请求应返回哪种协议:'api' / 'admin' / 'frontend'。"""
path = request.path
if path.startswith('/api/'):
return 'api'
# X-Requested-With: XMLHttpRequest 或 Accept: application/json
if (request.headers.get('X-Requested-With') == 'XMLHttpRequest'
or request.accept_mimetypes.best == 'application/json'):
return 'api'
if path.startswith('/admin'):
return 'admin'
return 'frontend'
常量治理同样是 v2.6.0 的硬仗:工作流状态、上传扩展名白名单、搜索引擎名、角色预设名......17 处硬编码字面量散落在 models、utils、admin、plugins 里,收编进零依赖的 app/constants.py(Workflow / Upload / Locales / Search / Roles / Pagination 命名空间),再加 scripts/check_constants.py 做 grep 门禁------已收编的裸字面量谁敢写回来,CI 直接红。
v2.6.1 理架构。 蓝图注册从 create_app 抽离;admin 按业务域拆成 content/ 和 system/ 子包;最重要的是引入 service 层------路由只做 HTTP 协议转换,表单校验、DB 写入、审计日志、缓存清理这些业务逻辑进 app/services/,且 service 函数不依赖 request/current_user 全局对象,数据全部参数显式传入,返回 (result, messages) 让调用方 flash。这个决定让后来的单元测试好写了十倍:测业务逻辑不用伪造整个 HTTP 请求。
v2.6.2 暴补测试 ,用例数从 34 跳到 113:RBAC 栏目级权限边界 17 项(无授权/单栏目/多栏目/全站授权矩阵 + 跨栏目数据隔离)、插件全生命周期 20 项(启用→使用→禁用→重启用,验证路由/菜单/模板函数/API 的注册与清理)、Alembic 迁移 9 项(版本图无分叉、up/downgrade 幂等、模拟生产 stamp baseline 再 upgrade)、API 契约 17 项、存储驱动 16 项。期间踩了个很阴的测试坑:flask-login 会在 g 上缓存登录用户,种子数据的 app context 没关干净,导致"明明登录了 A,current_user 却是 B",最后把种子流程移入独立 context 并及时关闭、先建权限点再建角色才根治。
v2.6.3 升级 API 鉴权。 单一静态 Token 升级为 JWT access/refresh 双 token(access 15 分钟、refresh 7 天),支持 token/jwt/both 三种模式双轨兼容,flask-limiter 限流上线(登录 5 次/分钟、只读 120 次/分钟,超限 429)。双轨模式的判断顺序很直白:
python
# app/api/views.py
auth_mode = (Setting.get('api_auth_mode') or 'both').lower()
if auth_mode == 'token':
return _check_api_token()
if auth_mode == 'jwt':
return _check_jwt()
# both(默认):有 Bearer 就验 JWT,否则回退静态 Token
has_bearer = request.headers.get('Authorization', '').lower().startswith('bearer ')
if has_bearer:
return _check_jwt()
return _check_api_token()
踩了个 JWT 新手坑:identity 必须是字符串,传整数直接抛 InvalidSubjectError,老老实实 str(user.id),额外信息放 additional_claims。
v2.6.4 上线前台会员体系 (member 插件)和插件依赖治理。会员表 members 与后台管理员完全隔离------独立 session 键 member_id,独立密码体系,两套登录态互不干扰。核心不直接认识"会员",而是通过 PluginBase.get_frontend_guard() 钩子解耦:插件返回 {'is_authenticated': callable, 'login_url': callable},核心才对 member_only 栏目做 302 跳登录;没装插件,核心一行鉴权代码都不执行。插件依赖同时上了 requires/extends/min_core_version 启用前强校验,禁用插件时反向检查依赖者并拒绝------依赖治理的规则顺序(最低版本 → requires → extends)和友好的错误提示,让插件生态第一次有了"包管理"的雏形。
十二、v3.0:可视化页面构建器------最大的一场豪赌
9 月 15 日,v3.0.0。自研拖拽式页面构建器:原生 HTML5 拖拽 + 组件注册表 + iframe 内服务端真实渲染(不是前端模拟个长得像的 DOM),覆盖首页/单页/列表页/文章详情四种全页面类型,24 个 Bootstrap4 组件,草稿/发布/修订回滚、中英双语文案、语言白名单与会员可见性、桌面/平板/手机三端预览、全局页面样式。开发周期一口气新增 139 个测试,总量到 280。
最关键的战略决策是双轨制:存在已发布布局的页面才走构建器渲染,其余全部继续渲染既有手写模板;删除构建记录立即回退,老站升级后零行为变化。这让"上构建器"从不可控的大爆炸变成了可灰度、可回退的开关。
安全上一点没敢放松。布局是用户在浏览器里拼出来的 JSON,服务端必须按白名单严格校验------类型未知、嵌套过深、节点过多、体积过大,统统 400:
python
# app/builder/schema.py
def validate_layout(layout, check_widgets=True):
"""校验布局文档。成功返回规整后的 dict,失败抛 LayoutError。"""
if not isinstance(layout, dict):
raise LayoutError('布局必须是 JSON 对象')
root = layout.get('root')
if not isinstance(root, list):
raise LayoutError('布局缺少 root 数组')
payload = json.dumps(layout, ensure_ascii=False).encode('utf-8')
if len(payload) > _B.MAX_PAYLOAD_BYTES: # 256KB
raise LayoutError(f'布局文档超过 {_B.MAX_PAYLOAD_BYTES // 1024}KB 上限')
valid_types = set(_registry.keys()) if check_widgets else None
count = 0
def walk(node, depth, allowed):
nonlocal count
count += 1
if count > _B.MAX_NODES: # 300 节点
raise LayoutError(f'单页节点数超过 {_B.MAX_NODES} 上限')
if depth > _B.MAX_DEPTH: # 8 层
raise LayoutError(f'嵌套深度超过 {_B.MAX_DEPTH} 层上限')
ntype = node.get('type')
if valid_types is not None and ntype not in valid_types:
raise LayoutError(f'未知组件类型:{ntype}')
# ... props 白名单、select 双向类型、repeater 子字段、
# localized 语言键、visibility 语言/会员可见性逐一校验
预览端点 /admin/builder/preview/<id> 必须登录才可以访问,且只注入真实数据渲染、不产生任何匿名可访问的页面。插件生态也全面接入:插件可通过 get_builder_widgets() 注册自有组件,通过 get_builder_layouts() 向默认布局贡献片段------但片段启用前必须跑同一套 validate_layout,非法片段直接拒绝插件启用;合并快照时撞 id 自动加 owner 前缀重写,单个片段失败隔离跳过;插件卸载前扫描所有页面的布局引用,还有页面用着它的组件就拒绝卸载并列出引用清单。默认布局按"当前启用主题 fixture → 核心回退 → 插件片段"三层合并,而且只做一次性快照------后续主题切换、插件停启,绝不改写用户已有的草稿和已发布页面。
十三、v3.1.0:数据库迁移做成插件,顺便硬扛 MySQL 5.6
"本地 SQLite 开发,上线切 MySQL/PostgreSQL"是刚需,但没人愿意天天在核心里维护迁移逻辑。于是 db_migrator 成为默认不启用的官方插件,权限点默认不授予任何预设角色,只有超管能用。
迁移流程是一条教科书式的数据管道:目标库建表 → 按外键依赖拓扑序逐表复制数据 → PostgreSQL 重置自增序列 → 同步 Alembic 版本号 → 可选热切换引擎免重启。拓扑序和单事务是两个关键:
python
# plugins/db_migrator/migrate.py
# 逐表复制:sorted_tables 已按外键依赖拓扑排序(父表先于子表);
# 整批复制在一个事务里,任一表失败整体回滚,目标库不残留半截数据
with src_engine.connect() as sc, tgt_engine.begin() as tc:
for table in metadata.sorted_tables:
count = _copy_table(sc, tc, table) # 内部每批 500 行
summary.append({'table': table.name, 'rows': count})
目标库非空保护是另一条硬规矩:同名表只要有一行数据就拒绝迁移,必须显式勾选"覆盖目标库已有数据"才先 drop 再建;迁移前还要先测连通性。同步 Alembic 版本号是为了让目标库重启后被识别为"已管理",直接 upgrade head 无增量,避免对着 create_all 建出来的完整 schema 重复执行中间迁移而报错。
MySQL 5.6 的 1709 暴击
这是整个周期最"考古"的 bug。低版本 MySQL 5.6 使用 Antelope 行格式、innodb_large_prefix=OFF,utf8mb4 编码下单列索引前缀上限只有 767 字节。我们照惯例把字符串列建成 255 字符(255×4=1020 字节),凡是带索引或唯一约束的列,建表即报错:
arduino
ERROR 1709 (HY000): Index column size too large. The maximum column size is 767 bytes.
修复不是去要求客户改数据库参数,而是在迁移建表前对 metadata 动个小手术------把所有带索引/唯一约束的超长字符串列统一收口到 191(191×4=764,卡线过关):
python
# plugins/db_migrator/migrate.py
from sqlalchemy import String, UniqueConstraint
for table in metadata.tables.values():
indexed_keys = set()
for idx in table.indexes:
indexed_keys.update(c.key for c in idx.columns)
for cons in table.constraints:
if isinstance(cons, UniqueConstraint):
indexed_keys.update(c.key for c in cons.columns)
for col in table.columns:
if not (col.index or col.unique or col.key in indexed_keys):
continue
length = getattr(col.type, 'length', None)
if isinstance(col.type, String) and length and length > 191:
col.type.length = 191
都 2026 年了还要兼容 2013 年的数据库默认配置------文档里写多少遍"请使用 MySQL 5.7+",都不如报错弹一次脸印象深刻。能在代码里自动收口的,就不要把负担甩给运维。
十四、v3.1.1:两个高危,脸有点疼
10 月 11 日,安全报告指出两个高危(H1/H2)。其中一个打在我们 v2.2 就上线、一直自以为稳的内容 API 上。
H1:API 默认免鉴权(fail-open)
当年的令牌校验逻辑是:配置了令牌就校验,没配置就匿名放行 。问题是默认安装的 api_token 本来就是空的------两个"合理"的默认值凑在一起,结论荒诞:任何开箱即用的站点,/api/v1/* 全部内容端点对全世界匿名敞开,草稿之外的栏目、文章随便拉。
修复只有一句话,但这句话值得刻在墙上:未配置令牌时一律 401(fail-closed),而不是放行。
python
# app/api/views.py
def _check_api_token():
"""静态令牌校验:X-API-Token,恒时比较。
v3.1.1 fail-closed:未配置令牌时默认拒绝,避免默认安装全 API 匿名可读。"""
token = Setting.get('api_token') or ''
provided = request.headers.get('X-API-Token', '')
if not token:
if not provided:
return api_err(401, 'API 未配置访问令牌,请在后台「内容 API」中生成')
return api_err(401, 'API Token 无效或缺失')
if not provided or not hmac.compare_digest(token, provided): # 恒时比较防时序侧信道
return api_err(401, 'API Token 无效或缺失')
return None
api_enable=off 时则全端点返回 404------对外的语义是"这功能不存在",而不是"功能在但你没权限",减少暴露面。
H2:全站缺一个和 Session 绑定的 CSRF Token
v2.4.2 的 Origin/Referer 校验有它的覆盖盲区,且本质上依赖浏览器行为。v3.1.1 补上同步 Token 机制:Token 存在用户 Session 里,表单和 AJAX 都必须携带,服务端恒时比较:
python
# app/utils/csrf.py
def generate_csrf_token():
"""取当前 Session 的 CSRF Token,没有则生成并写入。"""
token = session.get('_csrf_token')
if not token:
token = secrets.token_urlsafe(32)
session['_csrf_token'] = token
return token
def validate_csrf():
"""before_request 钩子:状态变更请求强制校验。"""
if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'):
return
if request.path.startswith('/api/') or request.path.startswith('/healthz'):
return # 头鉴权/健康检查豁免
expected = session.get('_csrf_token')
if not expected:
# 首个匿名 POST(如登录表单第一次提交):放行并借机建立 Token
generate_csrf_token()
return
provided = (request.form.get('_csrf_token')
or request.headers.get('X-CSRF-Token', ''))
if not provided or not hmac.compare_digest(expected, provided):
abort(403, description='CSRF 校验失败,请刷新页面重试')
落地工作量在"最后一公里":所有前后台 base 模板注入 <meta name="csrf-token"> 和一段全局捕获脚本,普通表单提交自动加 _csrf_token 隐藏字段,jQuery 的 $.post/$.ajax 全局加 X-CSRF-Token 头;但项目里还有一堆手写 fetch/XHR(Word 导入、CKEditor 图片上传、构建器保存/发布/回滚、一键翻译),这些只能逐处补头,漏一个就是一个 403。与 v2.4.2 的同源校验同时保留,两层机制互为纵深 。测试基建也顺带升级:client fixture 统一自动携带 CSRF Token 和 API Token,后来 v3.1.2 全部安全用例都建立在这个 fixture 上。
发布日补丁与三平台的脾气
v3.1.1 发布当天又追了四个修复,tag 强制移到新提交、三个平台的 Release 删了重建:后台登录 CSRF 要用独立模板;迁移 1709 收口;删除确认弹窗必须渲染验证码图片(不先请求验证码端点,提交必提示"验证码已过期");审计日志清理前后端字段错位------前端下拉传 days,后端读 keep_days,用户选了"清空全部"(0 值特殊逻辑),页面却提示"已清理 90 天前的审计日志,共删除 0 条",修复时新旧参数都兼容、0 值单独走清空分支,并把清理动作本身记成一条 clear_all 审计。
这次操作还摸清了三个代码托管平台 API 的脾气,全记进了备忘录:Gitee 建 Release 的 target_commitish 必须全量哈希,短哈希直接 422;GitHub 上 tag 已存在时创建 Release 不要带 target_commitish;GitCode 的 Release 不能删除也不暴露 id,只能 PATCH 原地更新 body,target 跟随 tag 自动移动。发版流程里每一个平台的坑都值得文档化,下次就不必再用一次失败的 API 调用交学费。
十五、v3.1.2:中危五连,专治"不起眼但能要命"
GitCode issue #2 列了五项(M1-M5),全是代码 review 时一眼扫过去不会停、但扫描器和真实攻击不会客气的地方。
M1:一个裸 int() 引发的 500
文章、栏目、碎片管理的视图里,sort_order、page_size、parent_id、分页 page 这些参数到处直接 int(...)。用户手滑或扫描器提交个 ?page=abc,ValueError 原地爆炸,前台/后台 500。统一收口成一个 8 行的工具函数,再扫遍全仓库替换所有裸调用点,分页类参数外面再套 max(1, ...) 拒收负数:
python
# app/utils/helpers.py
def safe_int(value, default=0):
"""安全整数转换(v3.1.2 M1):非数字/空串回退默认值,杜绝 ValueError→500。"""
if value is None:
return default
try:
return int(str(value).strip())
except (TypeError, ValueError):
return default
# 调用点示例:
article.sort_order = safe_int(form_data.get('sort_order'), 0)
page = max(safe_int(request.args.get('page'), 1), 1)
收口范围包括 article_service、column_service、碎片管理、前后台文章列表、搜索页、构建器数据组件------一个都不能漏,因为这类函数的价值就在于"最后一个裸调用点被替换掉"。
M2:上传子目录路径穿越
通用上传接口的 type 参数会被拼进存储路径 os.path.join(UPLOAD_FOLDER, sub_dir, date_dir)。type=../../../tmp 会发生什么,不用多解释。修复是白名单加段正则,而且在上传入口和 save_upload_file 内部各校验一次------纵深防御的意义在于,以后任何人新增上传调用方,都绕不开内部这道关:
python
# app/utils/uploads.py
ALLOWED_SUB_DIRS = frozenset({
'common', 'image', 'file', 'avatar', 'editor', 'article', 'theme',
'site', 'column', 'fragment',
})
_SAFE_SUB_SEGMENT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,32}$')
def _safe_sub_dir(sub_dir):
"""1. 空值 → 'common'
2. 剥离两侧 / 与 \\
3. 含 '..' 段或绝对路径特征 → 'common'
4. 首段必须在白名单,其余段匹配段格式正则
5. 任一不通过 → 'common'"""
if not sub_dir:
return 'common'
s = str(sub_dir).strip().replace('\\', '/').strip('/')
if not s or '..' in s.split('/'):
return 'common'
parts = [p for p in s.split('/') if p]
if not parts or parts[0] not in ALLOWED_SUB_DIRS:
return 'common'
if any(not _SAFE_SUB_SEGMENT_RE.match(p) for p in parts[1:]):
return 'common'
return '/'.join(parts)
M3:CKEditorFuncNum 反射型 XSS
富文本编辑器上传图片后,服务端要返回一段 <script> 调回父窗口的 CKEditor,回调编号 CKEditorFuncNum 是用户可控的。老代码直接字符串拼接,传入 </script><script>alert(1)</script> 一气呵成。修复双管齐下:编号只接受 1-6 位纯数字,URL 和错误信息进 JS 字符串前全部 HTML 转义:
python
# app/admin/upload.py
_CKEDITOR_FUNC_NUM_RE = re.compile(r'^\d{1,6}$')
func_num = (request.args.get('CKEditorFuncNum')
or request.form.get('CKEditorFuncNum') or '').strip()
if func_num and not _CKEDITOR_FUNC_NUM_RE.match(func_num):
return jsonify({'error': '参数无效'}), 400
# 失败回调(err 转义后再拼)
if func_num:
return (
f'<script>window.parent.CKEDITOR.tools.callFunction('
f'{func_num}, "", "{html.escape(err, quote=True)}");</script>'
), 200
# 成功回调(file_url 同样转义)
M4:SVG------能画矢量图,也能装 JavaScript
LOGO 上传允许 svg 扩展名,而 SVG 本质是 XML,里面可以内嵌 <script>、事件处理器甚至 <foreignObject>,配合当时相对宽松的 CSP,就是一个现成的存储型 XSS 载体。修复打的是组合拳。第一道:默认 upload_allow_svg=off,后台"上传安全"页新增开关,历史上允许 SVG 的白名单表单改为按开关动态决定。第二道,开启后内容嗅探 + 大小上限:
python
# app/utils/uploads.py
if ext == 'svg':
if Setting.get('upload_allow_svg') != 'on':
return None, None, 'SVG 上传默认关闭,请到「上传安全」开启后再上传'
SVG_MAX_SIZE = 512 * 1024
if size > SVG_MAX_SIZE:
return None, None, f'SVG 文件过大(最大 {SVG_MAX_SIZE // 1024} KB)'
file_storage.stream.seek(0)
head = file_storage.stream.read(SVG_MAX_SIZE).decode('utf-8', errors='ignore').lower()
file_storage.stream.seek(0)
danger_tokens = ('<script', 'onload=', 'onclick=',
'href="javascript:', "href='javascript:",
'<foreignobject', 'onerror=', 'onmouseover=')
if any(tok in head for tok in danger_tokens):
return None, None, 'SVG 含危险脚本片段,已拒绝上传'
allowed_exts = list(allowed_exts) + ['svg']
第三道是兜底------哪怕是上线前就存在于服务器上的存量 SVG,响应出去时也要关进沙箱。在全站安全响应头钩子里对 .svg 请求特别处理:
python
# app/__init__.py
if req_path.endswith('.svg'):
response.headers['Content-Type'] = 'image/svg+xml; charset=utf-8'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Security-Policy'] = (
"sandbox; default-src 'none'; "
"style-src 'unsafe-inline'; img-src 'self' data:"
)
M5:验证码形同虚设
登录密码错误的代码分支里没有消费验证码------验证码一旦生成,可以配合不同密码无限重试,防暴破锁定数倒是在涨,可验证码这道人形门槛等于没装。修复简单到只有一句话:无论密码对错,先一次性消费,再比对。
python
# app/admin/auth.py
captcha = (request.form.get('captcha') or '').strip().lower()
# 无论比对结果先 pop,杜绝同码复用枚举密码
session_captcha = (session.pop('captcha', None) or '').lower()
这个版本新增 26 项专项测试(tests/test_security_m.py),总量 346。写测试时也有两个实战教训值得一提:SVG 用例必须先把 SVG 检查挪到扩展名白名单判断之前(默认后缀清单里没有 svg,会被提前拒掉,根本走不到嗅探逻辑);中文错误信息在 JSON 响应里是 \uXXXX 转义形态,断言要 resp.get_json()['error'] 取出来再判断,不能直接在原始响应文本里找中文。
十六、v3.1.3:功能都对之后,账要算一算
GitCode issue #3 是四项中危性能/并发问题(M7-M10)。这类问题的共同特点是:功能完全正确,只是系统会在压力下慢慢流血。
M7:一个页面 20+ 次设置查询
Setting.get(key) 的实现是每次一条 SELECT ... WHERE key=? LIMIT 1,而单页渲染链路要读 20 多个设置项------导航要读、SEO 要读、主题要读、缓存开关要读......一页 20 多条几乎一模一样的 SQL。为什么不用跨请求缓存?因为设置项要求改了立刻生效,长 TTL 缓存在多 worker 下必然有一致性麻烦。最后的方案是请求级缓存:用 flask.g 存整表映射,同一请求内首查整表一次,之后零 SQL,请求结束缓存自然消亡,多 worker 下天然无一致性问题:
python
# app/models/setting.py(节选两个类方法)
@classmethod
def _request_cache_map(cls):
"""单页渲染链路 Setting.get() 调用 20+ 次,逐次 first() 造成写放大;
同一请求内首查整表建映射,后续读零 SQL。set() 写后失效当前请求。"""
if not has_app_context():
return None # CLI / 裸单测:保持逐键查询
cache_map = g.get('_settings_map')
if cache_map is None:
cache_map = {r.key: (r.value or '') for r in cls.query.all()}
setattr(g, '_settings_map', cache_map)
return cache_map
@classmethod
def get(cls, key, default=None):
cache_map = cls._request_cache_map()
if cache_map is not None:
if key in cache_map:
return cache_map[key]
# 键不在整表映射中:查一次库解析默认值并回填,避免同请求重复落库
item = cls.query.filter_by(key=key).first()
if item is None:
resolved = default if default is not None else cls.DEFAULTS.get(key, '')
else:
resolved = item.value or ''
cache_map[key] = resolved
return resolved
... # 无应用上下文:保持原有逐键查询行为
set() 写入后同时失效数据库和当前请求的映射,保证同请求内"写后立即可见"。
M8:每看一篇文章就 UPDATE + COMMIT
文章详情每被访问一次,老代码就 article.viewed += 1 然后 db.session.commit()。高并发下这是对同一行的持续写锁竞争;更尴尬的是整页缓存命中时视图函数根本不执行------缓存 TTL(默认 900 秒)内浏览量完全不增长,开了缓存反而把统计功能关了。
改造为"缓存累加 + 合并回库"。每次访问只往缓存里的 dict 加一,不碰数据库:
python
# app/frontend/views.py
_VIEW_PENDING_KEY = 'article:viewed:pending'
_VIEW_FLUSH_THRESHOLD = 200 # 请求内累计达 200 触发合并(多 worker 无调度器兜底)
_VIEW_PENDING_TTL = 86400 # 兜底 24h,防调度器长期停止时增量滞留
def _incr_view(aid):
"""优先缓存累加(消除每详情请求一次写事务);缓存不可用回落直接写库。"""
try:
pending = cache.get(_VIEW_PENDING_KEY)
pending = dict(pending) if isinstance(pending, dict) else {}
pending[aid] = pending.get(aid, 0) + 1
cache.set(_VIEW_PENDING_KEY, pending, timeout=_VIEW_PENDING_TTL)
if sum(pending.values()) >= _VIEW_FLUSH_THRESHOLD:
_flush_view_counts()
except Exception:
... # 回落旧行为,计数功能不中断
合并任务由 APScheduler 每 60 秒跑一次,用一条增量 UPDATE 把攒下来的数字一次性写回,而不是读出来再写;合并失败就把增量放回缓存等下一轮,不丢数:
python
def _flush_view_counts():
pending = cache.get(_VIEW_PENDING_KEY)
if not isinstance(pending, dict) or not pending:
return
cache.delete(_VIEW_PENDING_KEY)
try:
for aid, n in pending.items():
if not n:
continue
db.session.query(Article).filter(Article.id == aid).update(
{Article.viewed: func.coalesce(Article.viewed, 0) + n},
synchronize_session=False,
)
db.session.commit()
except Exception:
db.session.rollback()
# 合并失败:增量放回缓存(与期间新增量合并),等下一轮
...
整页缓存的命中路径也显式调用 _incr_view(),修掉"缓存窗口计数不涨"的老 bug。代价是浏览量从此变成近似值 (误差窗口 ≤ 60 秒或 200 次访问)------这个语义变化我们在 UPGRADE.md 和发版说明里用大字标注了。会改变用户可观测行为的优化,必须明说,让依赖精确计数的用户有选择权。
M9:首页 N+1,9 个区块 20+ 条 SQL
首页要渲染 9 个行业区块,老实现对每个 slug 调一次 _home_column_articles():查栏目、查子栏目、查文章,每块 3 条 SQL,叠加最新文章和导航树,单页 20 多条 SQL。重写后收口为 3 条------目标栏目一次 IN 查全、全部子栏目一次、跨栏目文章一次,在内存里分组并取每组前 N:
python
# app/frontend/views.py
def _home_section_data():
"""原实现 9 个 slug 各发 1~3 条 SQL,单页首页约 20+ 条;
收口为 3 条 SQL:目标栏目一次、子栏目一次、跨栏目文章一次,
内存分组取前 N,排序语义与原实现一致。"""
导航栏的栏目树同理,加 Column.get_tree_cached() 请求级缓存------导航渲染和上下文处理器对同一棵树只查一次,内容 CRUD 后由 clear_content_cache() 统一失效,同请求内立即读到新树。
M10:并发重名,唯一索引替你拦下了 500
"先 SELECT 查重,不存在再 INSERT"在并发下有竞态窗口:两个同名请求同时穿过查重线,双双走到插入,users.username 唯一索引兜底时抛出 IntegrityError------没人接,冒泡成 500。修复就是老老实实在数据库的最后一道防线处接异常,翻译成业务错误:
python
# app/services/user_service.py
try:
db.session.flush()
except IntegrityError:
db.session.rollback()
_flash(_gettext('用户名已存在'), 'danger')
return None, messages
...
try:
db.session.commit()
except IntegrityError: # flush 与 commit 两处都要接
db.session.rollback()
_flash(_gettext('用户名已存在'), 'danger')
return None, messages
应用层查重只是体验优化(能给出友好提示),数据库唯一约束才是最终防线------这条数据库课上的老话,又实锤了一次。
v3.1.3 新增 14 项性能与并发用例(SQL 计数断言、缓存失效、浏览量合并、缓存页计数、竞态回退、端点无 500),全量 360 项通过。测试里也沉淀了两条经验:服务层函数内部用了 i18n 翻译时,裸 app_context 里 select_locale 会访问 request 而 RuntimeError,必须用 app.test_request_context('/') 包裹;SQL 计数断言要用"SELECT 且 FROM 某表"的正则口径,i18n 翻译表惰性加载和建数 INSERT 都会干扰朴素的关键字计数。
十七、66 天,我们到底学会了什么
1. 默认值必须是安全的。 默认密钥、默认放行、默认允许 SVG------每一个"默认能跑就行"最后都会变成安全事故。fail-closed 永远比 fail-open 贵得值。
2. 升级体验是产品的一部分。 四不变原则(表名/路由/设置键/审计代码不变)、自动 stamp baseline、默认关闭的新功能、写得明明白白的 UPGRADE.md。客户不怕你加功能,怕的是升级完首页裂了、数据没了、会话全掉。
3. 缓存是功能,不是优化。 缓存键必须包含一切影响渲染的维度(分页、语言、主题、会员状态);写操作要有统一失效入口(clear_content_cache);引入近似语义要大张旗鼓告诉用户;缓存命中路径也必须执行业务计数。
4. 校验要纵深,要收口。 入口校验一次,内部再校验一次;散落各处的 int() 收口成 safe_int;白名单思维(只允许什么)永远优于黑名单思维(拦截什么);数据库约束是最后防线,应用层校验只是体验。
5. 测试和门禁是发版的胆。 从 0 到 360 项测试、CI 三版本矩阵、常量门禁、版本一致性检查------这些不会让写代码变快,但会让点"发布"按钮的手不再抖。
6. 小步快跑,但每步可回退。 23 个版本里没有"大爆炸重构":插件化有自动迁移,构建器有双轨制,迁移插件默认不启用,Redis 探测不到就回退。能回退的变更才敢频繁发。
7. 报错信息和文档是给未来的自己看的。 "验证码已过期"却不告诉你该先请求图片、选了"清空全部"却提示"删除 0 条"------这类 bug 不崩不炸,但毁掉的是信任。同样,环境已经决定的事不要再问用户(容器里的数据库配置),会自动收口的兼容不要甩给运维(MySQL 191)。
尾声
v1.0 的时候,它只是一个能发文章的小站。
v3.1.3 的时候,它有 RBAC、工作流、插件生态、八大内置主题、可视化构建器、全文搜索、内容级多语言、会员体系、数据库一键迁移、JWT API,以及 360 个替我们值班的测试用例。
但说实话,最让人有成就感的不是功能清单,而是那些没有出现在 changelog 显眼位置的东西:默认安全的配置、升级无感的迁移、失效即清理的缓存、出错时说人话的提示、半夜发版也敢点下去的门禁。
代码还在继续长。下一个版本见。
关于项目 & 共建
ZhyCMS 是一个开源的 Flask 企业内容管理系统,代码全程公开,三个镜像仓库实时同步,国内访问任选:
- Gitee :gitee.com/pollybird/z...
- GitHub :github.com/pollybird/z...
- GitCode :gitcode.com/pollybird/Z...
如果这篇文章里的某个坑你也踩过、某个方案让你觉得"我们项目也该这么改",欢迎:
- ⭐ 点个 Star,这是对独立开源项目最直接的鼓励;
- 🐛 到任一仓库的 Issues 提 bug、报安全问题、聊新功能想法------v3.1.x 那一连串安全版本,最早的触发点就是一条用户反馈;
- 🔧 直接 Pull Request 参与共建:插件、主题、翻译、文档都在欢迎清单里,不一定要写核心代码。