🛢️ 第23章 Python+MySQL实现在线学习笔记
📄 一句话概括

在线笔记的需求一句话说完:每人只能看自己的笔记,还要能写、改、删、按标签找。 【自己的】这个词决定了整个系统的安全底线,它在每一条 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 秒实验

  1. 把用户数从 10 改成 100,重跑第一段,看「多看见多少条」变成多少。
  2. 在 notes.user_id 上建索引,重跑第一段,看两次计数分别快了多少。
  3. 写一条只带 is_deleted = 0 不带 user_id 的列表查询,看看它会返回哪些人的笔记。

常见误区

  1. 以为「按人隔离」是一个功能。它是每一条 SQL 上的一个条件。 做成一个「我的笔记」页面不叫隔离,那只是「另一个入口」。
  2. 用 affected_rows 判断成功,不判断是不是 0。0 行意味着「没权限或记录不存在」, 这时候提示「修改成功」是在骗用户。
  3. 以为这个漏洞能靠测试发现。它不报错、不异常,功能全通。 只能靠「SQL 只有一处能写」这种结构来防。
自测三问(点开对答案) 1. 为什么说「按人隔离」不是一个功能,而是每一条 SQL 上的条件? —— 做成一个「我的笔记」页面只是「另一个入口」,用户照样能通过改 id 访问别人的。 真正的隔离必须在每条查询、修改、删除上加 `AND user_id = ?`。 2. 为什么这个漏洞很难靠测试发现? —— 它不报错、不异常,页面照常显示,功能全通。用户不知道自己看到了不该看的,测试也不会报。 所以只能靠「SQL 只有一处能写」这种结构来防。 3. `UPDATE` 返回 0 行意味着什么? —— 意味着「没权限」或「记录不存在」。 代码必须把它当成失败处理,否则会出现「提示修改成功、实际什么都没改」。

需求里最关键的词已经拎出来了,下一篇是系统设计:业务流程、目录结构、界面预览, 以及列表页那两条查询该怎么写。

相关笔记