在线笔记的需求一句话说完:每人只能看自己的笔记,还要能写、改、删、按标签找。 【自己的】这个词决定了整个系统的安全底线,它在每一条 SQL 上都是一个条件。 漏掉这个条件不会报错,只会安安静静地多显示几百条别人的笔记。
第 23 章是第二个项目:Python 加 MySQL 做一个在线学习笔记。 和 22 章那个物流网比,业务简单得多——没有多级审核、没有复杂状态机, 核心就是「谁写的东西谁能看」。 但这个「简单」里藏着一个所有多用户系统都要面对的问题:按人隔离。 它比物流网那些状态机难,因为它没有「报错」这个反馈——漏了就是漏了,页面照常显示。 沙箱里没有 Flask 和 PyMySQL,但有 Python 和 sqlite3。 所以表结构、SQL、权限判定全部真跑;框架层面的东西照教材贴原码、标「不执行」。
一、需求:四句话和三个词
教材 23.1 的需求分析写了登录、注册、写笔记、管笔记四块。压到最简,其实就是四句话:
| 需求 | 翻译成约束 |
|---|---|
| 要注册才能用 | 每个页面都得先判登录 |
| 笔记是自己的 | 每条 SQL 都得带 user_id |
| 能按标签找 | 多对多,需要中间表 |
| 删掉的不能出现 | is_deleted = 0 或真删 |
右边四条里,第一条和第二条是「安全」问题,第三条是「结构」问题,第四条是「口径」问题。 三类问题的共同点是:写错了都不会报错。 第一条漏了,页面还是能打开,只是不用登录;第二条漏了,列表还是能显示,只是显示了别人的; 第四条漏了,删掉的笔记又冒出来。 所以这一章真正要讲的不是「怎么写功能」,是「怎么让这些检查不漏」。
二、为什么「按人隔离」比状态机难
物流网那套状态机(待发车 → 在途 → 已到达)写错了会立刻暴露:状态不对,下一步操作就会失败。 按人隔离写错了,什么都不会发生。
| 类型 | 写错的反馈 | 发现时间 |
|---|---|---|
| 状态机 | 下一步操作失败 | 当场 |
| 按人隔离 | 多显示了一些数据 | 可能永远发现不了 |
第二行就是这一类漏洞的可怕之处:它是一个「正常工作的系统」,只是权限比设计的大。 用户不会报 bug,因为他们不知道自己看到了不该看的;测试也不会报,因为功能是通的。 所以这一类问题只能靠「结构」来解决,不能靠「测出来」。 具体做法是:所有碰笔记表的 SQL 都从一个地方出去,那个地方固定带上归属条件。 这也是 23.5 节那个「数据库操作类」真正的价值——它不是为了少写代码, 是为了「只有一处能写 SQL」。
三、演示里的那三个数
演示里灌了 10 个用户、1000 篇笔记,然后对比两种写法:
| 写法 | 查出 | 含义 |
|---|---|---|
WHERE user_id = 3 |
100 条 | 正确 |
没有 WHERE |
1000 条 | 多看见 900 条 |
100 对 1000——这个比例说明「漏一个条件」的后果不是「多一条」,是「多九倍」。
而且用户数越多,这个倍数越大:100 个用户的时候就是多 99 倍。
更值得注意的是「改」和「删」那两个动作。
不带归属条件的 UPDATE 会真的改到别人的笔记,而且返回「影响 1 行」——看起来完全成功。
带归属条件的同一个 UPDATE 返回「影响 0 行」,它也不报错。
所以代码里必须把「0 行」当成「没权限」来处理,否则用户会看到「修改成功」但内容没变。
四、需求阶段就该定下的两条工程规矩
这一节最后落两条规矩,后面每一节都会用到:
| 规矩 | 为什么现在定 |
|---|---|
| SQL 只在一个类里写 | 只有一处能写,才可能保证每处都带归属 |
归属条件用 AND user_id = ? |
形式统一,代码审查时一眼能看出来 |
第一条是「收口」,第二条是「统一形状」。
两条加起来的效果是:以后要查「有没有漏」,只要看那个类里的每一条 SQL 结尾有没有那个 AND。
如果 SQL 散在几十个文件里,这件事就没法查。
这也是为什么这一章把「数据库操作类」放在数据库设计那一节(23.5)讲,而不是放在编码阶段。
它不是「实现细节」,它是「设计决定」——决定了以后能不能守住。
五、动手 Demo:按人隔离:少写一个条件,多看见九百条
这一段用 10 个用户、1000 篇笔记做三组对照:查(带不带 user_id)、改(带不带 user_id)、
以及列表页的归属过滤。三组的差别都不是「报错」,是「多做了事」。
import sqlite3
c = sqlite3.connect(u"req.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE users (id INTEGER PRIMARY KEY, uname TEXT UNIQUE);
CREATE TABLE notes (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL,
title TEXT, body TEXT, is_deleted INTEGER DEFAULT 0);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO users (uname) VALUES (?)",
[(u"user%03d" % i,) for i in range(1, 11)])
c.executemany(u"INSERT INTO notes (user_id, title, body) VALUES (?,?,?)",
[(i % 10 + 1, u"笔记%04d" % i, u"正文") for i in range(1, 1001)])
c.execute(u"COMMIT")
# ① 查:带不带 user_id
mine = c.execute(u"SELECT COUNT(*) FROM notes WHERE user_id = 3").fetchone()[0]
alln = c.execute(u"SELECT COUNT(*) FROM notes").fetchone()[0]
print(u"带 user_id: %d 条 / 不带: %d 条" % (mine, alln))
# ② 改:带不带 user_id
other = c.execute(u"SELECT id FROM notes WHERE user_id <> 3 LIMIT 1").fetchone()[0]
n1 = c.execute(u"UPDATE notes SET title='被改了' WHERE id=?", (other,)).rowcount
n2 = c.execute(u"UPDATE notes SET title='正常改' WHERE id=? AND user_id=?",
(other, 3)).rowcount
print(u"不带条件改 %d 行 / 带条件改 %d 行" % (n1, n2))
# ③ 列表页也要带
rows = c.execute(u"SELECT id, title FROM notes WHERE user_id = ? "
u"AND is_deleted = 0 ORDER BY id DESC LIMIT 5", (3,)).fetchall()
print(u"列表 %d 条,首条 %r" % (len(rows), rows[0]))
c.close()
实跑输出:
--------------------------------------------------------------------
按人隔离:少写一个条件,多看见九百条
--------------------------------------------------------------------
**教材 23.1 是需求分析。**
**在线笔记这件事,需求用一句话就能说完:每个人只能看自己的笔记,还要能写、能改、能删、**
**能按标签找。**
**但这句话里有一个词决定了整个系统的安全底线——【自己的】。**
① 同一个查询,带不带 user_id 差多少
--------------------------------------------------------------------
写法 查出几条 该不该给他看
-------------------------------------------
`WHERE user_id = 3` 100 该
没有 `WHERE` 1000 不该
**差别是 100 条对 1000 条——也就是说,漏掉一个条件,用户就多看见 900 条别人的笔记。**
**这就是「按人隔离」这件事的形态:它不是一个功能,是每一条 SQL 上的一个条件。**
**而它漏掉的时候,页面不会报错、不会白屏,只会安安静静地多显示一些东西。**
② 越权改和越权删,连报错都没有
--------------------------------------------------------------------
不带归属条件 改了 1 行(别人 id=1 的笔记)
带归属条件 改了 0 行
**第一行改成 1,第二行改成 0。**
**注意第二行不是「出错」,是「成功执行、影响 0 行」——这在代码里必须当成「没权限」来处理。**
**如果代码写成 `UPDATE ... ; flash('修改成功')`,那就是「明明没权限,却提示成功」。**
**这种 bug 用户看不出来,开发者也不知道,直到有一天有人发现标题全变了。**
③ 列表页也要带:连查询也不能忘
--------------------------------------------------------------------
id title
-------------
992 笔记0992
982 笔记0982
972 笔记0972
962 笔记0962
952 笔记0952
**这一段的结论只有一句:归属条件要写进「每一个」碰笔记表的查询里,包括列表、详情、修改、**
**删除。**
**一个可靠的工程做法是「不要暴露裸 id 查询」——所有笔记操作都从 `user_id` 出发,**
**用 `WHERE id = ? AND user_id = ?` 的形式,让「漏写」在代码审查时一眼能看出来。**
第一段那两个数是这一章最直观的一组:100 对 1000。
注意它不是「多一条」,是「多九倍」——而且用户越多倍数越大。
第二段两行更有意思:不带条件改 1 行(改到了别人的),带条件改 0 行(什么都没改)。
两个都「成功执行」了,都没有报错。区别只在一个数字上。
所以代码必须读这个数字,不读就等于没做权限。
第三段的列表查询带了三个条件:user_id、is_deleted、LIMIT。
三个都属于「漏了不报错」的类型:漏第一个越权,漏第二个删了还显示,漏第三个全表搬进内存。
这也是为什么「统一形状」这件事有价值——形状一样,漏没漏一眼就能看出来。
30 秒实验
- 把用户数从 10 改成 100,重跑第一段,看「多看见多少条」变成多少。
- 在
notes.user_id上建索引,重跑第一段,看两次计数分别快了多少。 - 写一条只带
is_deleted = 0不带user_id的列表查询,看看它会返回哪些人的笔记。
常见误区
- 以为「按人隔离」是一个功能。它是每一条 SQL 上的一个条件。 做成一个「我的笔记」页面不叫隔离,那只是「另一个入口」。
- 用
affected_rows判断成功,不判断是不是 0。0 行意味着「没权限或记录不存在」, 这时候提示「修改成功」是在骗用户。 - 以为这个漏洞能靠测试发现。它不报错、不异常,功能全通。 只能靠「SQL 只有一处能写」这种结构来防。
自测三问(点开对答案)
1. 为什么说「按人隔离」不是一个功能,而是每一条 SQL 上的条件? —— 做成一个「我的笔记」页面只是「另一个入口」,用户照样能通过改 id 访问别人的。 真正的隔离必须在每条查询、修改、删除上加 `AND user_id = ?`。 2. 为什么这个漏洞很难靠测试发现? —— 它不报错、不异常,页面照常显示,功能全通。用户不知道自己看到了不该看的,测试也不会报。 所以只能靠「SQL 只有一处能写」这种结构来防。 3. `UPDATE` 返回 0 行意味着什么? —— 意味着「没权限」或「记录不存在」。 代码必须把它当成失败处理,否则会出现「提示修改成功、实际什么都没改」。需求里最关键的词已经拎出来了,下一篇是系统设计:业务流程、目录结构、界面预览, 以及列表页那两条查询该怎么写。
- ☐ (本篇完,下一篇 23.2 系统设计)