1. 拼接 SQL 的四种写法为什么只有一种能防注入Python 后端接口里SQL 注入最常见的入口就是「把用户传进来的参数直接拼进 SQL 字符串」。你可能觉得参数只是个 uid、只是个页码能出什么事我先把四种常见写法摆出来你对照自己的代码看一眼。第一种%占位符先拼再执行sql SELECT vip, coin FROM user_asset WHERE uid%s % uid cursor.execute(sql)第二种formatsql SELECT vip, coin FROM user_asset WHERE uid{}.format(uid) cursor.execute(sql)第三种f-stringsql fSELECT vip, coin FROM user_asset WHERE uid{uid} cursor.execute(sql)第四种把占位符和参数分开交给驱动sql SELECT vip, coin FROM user_asset WHERE uid%s cursor.execute(sql, (uid,))前三种本质是同一类Python 先把完整 SQL 字符串生成好再整条丢给数据库。第四种是另一类SQL 模板和参数分开传由数据库驱动内部做转义后再拼装。问题就出在前三种。假设客户端传进来的 uid 是 or 1 or 第一种写法会生成SELECT vip, coin FROM user_asset WHERE uid or 1 or 这个条件恒为真等于把整张表都查出来了。如果传的是; delete FROM test_user_asset WHERE 生成的 SQL 里就多了一条删除语句。这不是理论推演是拼接字符串必然导致的结果——因为用户输入被当成了 SQL 语法的一部分而不是数据。所以防注入的核心原则只有一句话永远不要让用户输入参与 SQL 语法结构的生成。参数化查询第四种就是这条原则的落地方式。下面我会从参数化写法切入再配合 TaoToken 统一 Key 做请求侧配置校验给出一套能直接复制的骨架。2. 用 TaoToken 统一 Key 管住请求侧入口参数化查询解决的是「SQL 层不被注入」但请求侧还有一层问题接口参数从哪来、有没有做类型和范围校验、多个服务是不是各写各的 Key 和配置。这一层如果散乱参数化写得再好也可能被绕过——比如某个分支忘了用参数化或者校验逻辑在某个服务里被漏掉。TaoToken 在这里的作用是提供一个统一的 API 通道和 Key 管理入口把模型调用、配置校验这类请求收敛到一处。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 这个不加 UTM。你需要先拿到 Key入口在 API Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到之后请求侧的统一配置就可以围绕这个 Key 来组织而不是每个服务各存一份。这里要区分清楚TaoToken 管的是「请求侧的统一 Key 与配置校验通道」SQL 参数化管的是「数据库层的注入防护」两者是配合关系不是替代关系。别指望一个 Key 能替你写参数化查询也别指望参数化能替你管住接口入口。3. 可复制的 settings.json 骨架与参数化查询示例先给一份 settings.json 骨架把请求侧配置集中管理。字段名你可以按自己项目调整结构参考这个{ taotoken: { api_base: https://taotoken.net/api, api_key_env: TAOTOKEN_API_KEY, timeout_seconds: 15, max_retries: 2 }, request_validation: { uid: { type: string, pattern: ^[A-Za-z0-9_]{1,32}$, max_length: 32 }, page_size: { type: integer, min: 1, max: 100 }, order_by: { type: enum, allowed: [created_at, coin, vip] } }, db: { host: 127.0.0.1, port: 3306, user: test, password_env: DB_PASSWORD, database: test, charset: utf8mb4 } }几个关键点api_key_env和password_env存的是环境变量名不是明文密钥这样配置文件可以进版本库而不泄露凭据。request_validation里对 uid 做了正则约束只允许字母数字下划线长度不超过 32——这一层挡掉大部分畸形输入但它不能替代参数化只是多一道防线。接着是参数化查询的落地写法。以 MySQLdb 为例import json import os import MySQLdb with open(settings.json, r, encodingutf-8) as f: cfg json.load(f) conn MySQLdb.connect( hostcfg[db][host], portcfg[db][port], usercfg[db][user], passwordos.environ[cfg[db][password_env]], dbcfg[db][database], charsetcfg[db][charset], ) def get_user_asset(uid): sql SELECT vip, coin FROM user_asset WHERE uid%s cursor conn.cursor() try: cursor.execute(sql, (uid,)) return cursor.fetchone() finally: cursor.close()注意这里sql里用的是%s但没有加引号。MySQLdb 的%s不是 Python 的字符串格式化它是驱动自己的占位符字符串和整数都用%s驱动内部会负责转义和加引号。如果你写成uid%s再传参数反而会多出一层引号导致语法错误。如果你用的是 psycopg2PostgreSQL占位符是%s同样适用如果是 sqlite3占位符是?sql SELECT vip, coin FROM user_asset WHERE uid? cursor.execute(sql, (uid,))不同驱动占位符不同但原则一致SQL 模板里只留占位符参数走第二个参数传入。4. 验证请求与拦截效果写完参数化得验证它真的挡住了注入。我试过用一组注入样例直接打接口看返回是否符合预期。先构造三个测试输入test_cases [ u123456, or 1 or , ; delete FROM test_user_asset WHERE , ]然后分别走参数化查询观察生成的 SQL 和返回结果。如果你想看驱动内部最终拼出的 SQL 长什么样可以用conn.literal做调试输出注意官方注释明确说这是内部方法仅限调试不要用在线上业务逻辑里for uid in test_cases: args (uid,) safe_sql (bSELECT vip, coin FROM user_asset WHERE uid%s % tuple(map(conn.literal, args))).decode() print(input:, uid) print(final:, safe_sql)预期输出里注入样例中的单引号会被转义成\input: u123456 final: SELECT vip, coin FROM user_asset WHERE uidu123456 input: or 1 or final: SELECT vip, coin FROM user_asset WHERE uid\ or 1 or \\\ input: ; delete FROM test_user_asset WHERE final: SELECT vip, coin FROM user_asset WHERE uid\; delete FROM test_user_asset WHERE \\\可以看到恶意输入里的引号全部被转义整段内容被当成一个普通字符串值而不是 SQL 语法。这就是参数化查询的拦截效果。请求侧再配合 TaoToken 的配置校验把 uid 的正则约束也跑一遍import re def validate_uid(uid, rule): if not isinstance(uid, str): return False if len(uid) rule[max_length]: return False return re.fullmatch(rule[pattern], uid) is not None对 or 1 or 这种输入正则^[A-Za-z0-9_]{1,32}$直接不匹配请求在进入数据库之前就被拦下。两层配合请求侧挡畸形输入SQL 层挡语法注入。5. 本篇常见错排查报错一TypeError: not all arguments converted during string formatting原因通常是 SQL 里用了%s占位符但execute第二个参数传的不是元组或字典。比如写成cursor.execute(sql, uid)而不是cursor.execute(sql, (uid,))。单个参数也要包成元组末尾那个逗号不能省。报错二ProgrammingError: (1064, You have an error in your SQL syntax)检查是不是在占位符两边多加了引号。写成uid%s再传参数驱动转义后会变成uidvalue这种畸形结构。正确写法是uid%s引号交给驱动处理。报错三注入样例没被拦住还是查出了全表先确认你用的是第四种写法而不是前三种。如果代码里还有% uid、.format(uid)、f...{uid}这类拼接参数化就没生效。全局搜一遍execute(的调用点逐个核对。报错四conn.literal调用报AttributeErrorliteral是 Connection 实例方法必须先建立连接才能调用。另外它返回的是 bytes需要 decode 才能当字符串打印。再强调一次这个方法只用于调试线上逻辑不要依赖它。报错五配置文件里 Key 读不到确认api_key_env指向的环境变量确实存在。可以用os.environ.get(cfg[taotoken][api_key_env])先打印一下如果是 None说明环境变量没设或者名字写错了。别把明文 Key 直接写进 settings.json。6. 接入与验证入口参数化查询的写法确定之后请求侧的统一 Key 和配置校验可以走 TaoToken 的接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你要验证模型侧的调用是否正常可以用模型对话页面试一条请求https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。长期做编码和 Agent 场景的话Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。最后留一个实操建议把execute(的调用点做成一个检查清单每次代码评审时过一遍确认没有字符串拼接的 SQL。这个习惯比任何工具都管用。