API接口的SQL注入与XSS防护实战

简介: 本文以真实线上事故为引,剖析SQL注入与XSS防护中的典型误区:ORM不等于安全、参数化≠防注入、输入校验≠输出转义。通过Python/Flask实战案例,详解ORDER BY拼接、二次注入、假参数化、上下文转义等高频踩坑点,并给出白名单校验、真参数化、CSP纵深防御等可落地方案。(239字)

API 接口的 SQL 注入与 XSS 防护实战:从原理到绕过的踩坑记录

写这篇文章的起因是前段时间应急一个线上事故:一个看起来"已经用了 ORM"的查询接口被人扫到了盲注,DAST 工具报了三个高危。复盘的时候发现,团队对"参数化查询"和"输入校验/输出转义"的理解普遍停留在"我知道要这么做"的层面,但具体到代码里,几乎每一处都踩了坑。

这篇不讲教科书定义,只讲我在实际代码评审和应急里反复看到的几类问题。代码用 Python(Flask + SQLAlchemy),其他语言思路一致。


一、先说一个反直觉的事:用了 ORM 不等于防住了注入

很多人对 SQL 注入的认知停留在这种代码:

# 经典反面教材
@app.route("/user")
def get_user():
    uid = request.args.get("id")
    sql = f"SELECT * FROM users WHERE id = {uid}"
    rows = db.execute(sql)
    return jsonify(rows)

这种拼接当然一眼就能看出来有问题。但真实业务里,绝大多数注入不是这么赤裸裸的,而是藏在那些"看起来还好"的写法里。我归纳成三类,按踩坑频率从高到低排:

1.1 ORDER BY / LIMIT 字段拼接(最高频)

分页接口里这种代码我见过不下二十次:

@app.route("/orders")
def list_orders():
    sort = request.args.get("sort", "created_at")
    order = request.args.get("order", "asc")
    page = request.args.get("page", 1)

    # 看起来很正常对吧
    sql = text(f"""
        SELECT id, order_no, amount, created_at
        FROM orders
        ORDER BY {sort} {order}
        LIMIT :offset, :size
    """)
    rows = db.session.execute(sql, {
   
        "offset": (int(page) - 1) * 20,
        "size": 20,
    })
    return jsonify([dict(r) for r in rows])

offsetsize 用了参数绑定,看上去"很规范"。但 sortorder 直接拼进了 SQL——ORDER BY 后面是不能用参数绑定的,因为绑定参数会被当成字面值(字符串常量),而不是列名或方向。这是很多人没意识到的坑。

攻击 payload:

GET /orders?sort=(case when (select substring(password,1,1) from users where id=1)='a' then id else order_no end)&order=asc

基于响应里数据排序的不同,盲注就成立了。这种接口在安全扫描里经常漏报,因为排序结果"看起来正常"。

正确做法是白名单:

ALLOWED_SORT_FIELDS = {
   "id", "order_no", "amount", "created_at"}
ALLOWED_ORDER = {
   "asc", "desc"}

def safe_sort(sort, order):
    if sort not in ALLOWED_SORT_FIELDS:
        sort = "created_at"
    if order.lower() not in ALLOWED_ORDER:
        order = "asc"
    return sort, order

列名、表名、排序方向这类"标识符"位置,永远不要拼用户输入,只能走白名单。

1.2 ORM 的 raw / text 滥用

SQLAlchemy 的 text() 不是安全护栏,它只是允许你写原生 SQL。参数绑定要你自己手动做:

# 错误:text 里又拼了字符串
db.session.execute(text(f"SELECT * FROM users WHERE name LIKE '%{kw}%'"))

# 正确:用 :name 占位
db.session.execute(
    text("SELECT * FROM users WHERE name LIKE :kw"),
    {
   "kw": f"%{kw}%"}
)

Django ORM 的 extra()raw(),Peewee 的 SQL(),都是同理——一旦你绕过 ORM 进了原生 SQL 通道,防注入的责任就完全在你手上。

1.3 二次注入

这个最阴险。用户输入第一次入库时做了转义或参数化,看起来安全;但下次从库里读出来再拼到别的 SQL 里,就炸了。

# 注册时参数化,安全
db.session.execute(
    text("INSERT INTO users(username) VALUES (:u)"),
    {
   "u": username}
)

# 修改密码时把 username 直接拼进 SQL(开发觉得"反正是从库里读的,可信")
db.session.execute(
    text(f"UPDATE users SET password='{new_pwd}' WHERE username='{user.username}'")
)

如果注册时 username 是 admin'--,入库没问题,但改密码时就变成了:

UPDATE users SET password='xxx' WHERE username='admin'--'

直接改了 admin 的密码。库里的数据不是可信源,这点和很多人的直觉相反。


二、参数化查询到底防住了什么:预编译绑定的本质

讲参数化查询的文章很多,但很少有讲清楚"为什么"的。我以前也只会说"用占位符就行",直到有一次被问"那为什么占位符就行",才发现自己没真懂。

2.1 数据库引擎层的预编译

参数化查询的核心是预编译(prepare)+ 绑定(bind)两步分离:

  1. 预编译阶段:数据库拿到带占位符的 SQL 字符串(WHERE id = ?),先做完整的词法、语法分析,生成执行计划。这个阶段,? 是一个"未知值的占位",不是字符串的一部分。
  2. 绑定阶段:把具体参数值传给数据库,数据库直接把值塞进执行计划里的占位位置,不会再做语法解析

这意味着,无论参数值里有多少个 '--union select,数据库都只会把它当成一个普通的字符串字面值去匹配,不会改变 SQL 的语义结构。

换句话说,参数化防注入的本质不是"转义了特殊字符",而是"SQL 的结构和数据从一开始就是分离的两条通道"。

2.2 "假参数化"的几种伪装

理解了本质,就能识别那些"看起来参数化、实际没防住"的写法:

# 假参数化 1:用 f-string 格式化后再传给 text
kw = request.args.get("kw")
sql = text(f"SELECT * FROM goods WHERE name LIKE '%{kw}%'")
db.session.execute(sql)  # 没有任何绑定参数,注入成立

# 假参数化 2:自己手动转义单引号(脆弱)
kw = kw.replace("'", "''")
sql = text(f"SELECT * FROM goods WHERE name = '{kw}'")
# 看似防住了,但遇到 GBK/双字节编码场景下的宽字节注入会失效

# 假参数化 3:ORM 的 like 字符串拼接
Goods.query.filter(f"name LIKE '%{kw}%'").all()  # 不要这么写

第三个尤其容易漏。SQLAlchemy 推荐写法:

from sqlalchemy import or_

Goods.query.filter(Goods.name.like(f"%{kw}%")).all()
# 或者更明确
Goods.query.filter(Goods.name.ilike(f"%{kw}%")).all()

ORM 的 Column.like() 会自动把参数当作绑定值处理,这才是真正的参数化。

2.3 一个容易被忽略的细节:LIKE 的通配符

参数化防住了引号注入,但 LIKE 的 %_ 通配符不是 SQL 语法字符,参数化管不到:

# 用户输入 "%",会匹配所有记录,可能造成全表扫描
kw = request.args.get("kw")
Goods.query.filter(Goods.name.like(f"%{kw}%")).all()

如果业务不允许用户用通配符,要主动 escape:

def escape_like(s: str) -> str:
    return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

Goods.query.filter(Goods.name.like(f"%{escape_like(kw)}%")).all()

这不是注入问题,是可用性问题,但实战里经常一起出现。


三、输入校验和输出转义:两件事,别混在一起

这是我在团队里反复强调的一点,也是 CSDN 上多数文章讲得最模糊的地方。输入校验和输出转义是两道独立的防线,目的不同、位置不同、不能互相替代。

3.1 各自的职责

维度 输入校验 输出转义
目的 拒绝不合业务规则的输入 防止数据在目标上下文里被解析为代码
位置 API 入口、模型层 渲染层、序列化层
依据 业务规则(类型、长度、格式、白名单) 输出上下文(HTML / JS / URL / CSS)
失败处理 拒绝请求 转义后照常输出

很多人犯的错是:在输入处做 HTML 转义,然后存进库,再原样返回给前端。这有两个问题:

  1. 转义后的数据被存进库,下次如果输出到非 HTML 上下文(比如 JSON API、PDF、Excel),就是乱码。
  2. 转义规则是和输出上下文绑定的,输入时你根本不知道这份数据将来会被渲染到哪里。

正确做法是:输入只校验,不转义;转义发生在输出的最后一刻,根据具体上下文选择转义方式。

3.2 输入校验:白名单优先

Python 里 Pydantic 是目前最顺手的选择。校验的核心思路是白名单而不是黑名单——黑名单永远列不全。

from pydantic import BaseModel, constr, conint, validator
import re

class CreateUserDTO(BaseModel):
    username: constr(min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")
    email: constr(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
    age: conint(ge=0, le=150)
    bio: constr(max_length=500)

    @validator("bio")
    def no_control_chars(cls, v):
        # 拒绝控制字符(包括 \x00, \r, \n 在不允许的场景)
        if re.search(r"[\x00-\x1f\x7f]", v):
            raise ValueError("包含非法控制字符")
        return v

@app.route("/users", methods=["POST"])
def create_user():
    try:
        dto = CreateUserDTO(**request.json)
    except ValidationError as e:
        return jsonify({
   "error": e.errors()}), 400
    # 入库时用参数化查询,不再做任何转义
    ...

几点要注意:

  • pattern 用的是白名单正则(只允许字母数字下划线),不是"拒绝出现 ' < >"这种黑名单。
  • 长度限制不只是防 DoS,很多注入 payload 都很长,长度卡死能挡掉一部分。
  • 拒绝控制字符,尤其是 \x00,它在某些 C 后端的数据库里会被截断字符串(MySQL 5.x 的 sql_mode 默认行为)。

3.3 输出转义:上下文决定一切

XSS 的本质是"数据被当成了代码执行"。防 XSS 的本质是"让数据永远停留在数据语义里,不进入代码语义"。同一个数据,输出到 HTML、JS、URL、CSS 里,需要的转义完全不同。

3.3.1 HTML 上下文

<div>用户名:{
  { username }}</div>

Jinja2 默认开启自动转义,会把 < > & " ' 转成实体。但如果你用了 |safe 或者 Markup(),就关掉了这道防线——这是最常见的自伤。

# 错误:手动 Markup 绕过转义
return render_template("profile.html", bio=Markup(user.bio))

# 正确:让模板自己转义
return render_template("profile.html", bio=user.bio)

3.3.2 JavaScript 上下文(最容易出事)

# 错误:把用户数据直接塞进 JS
return render_template_string("""
<script>
    var profile = {
   { user.bio | tojson }};
    document.getElementById('bio').innerHTML = profile.bio;
</script>
""", user=user)

tojson 看起来安全(它做了 JSON 编码),但问题出在 innerHTML——如果 profile.bio 里包含 <img src=x onerror=alert(1)>,赋给 innerHTML 时一样会执行。

规则:JS 上下文里能不接收用户数据就不接收,必须接收时用 textContent 而不是 innerHTML

3.3.3 URL 上下文

from urllib.parse import quote

redirect_url = request.args.get("next", "/")
# 错误:直接拼
return f'<a href="{redirect_url}">跳转</a>'
# 攻击:next=javascript:alert(document.cookie)

# 正确:校验协议白名单 + 转义
ALLOWED_SCHEMES = {
   "http", "https", ""}
def safe_url(u):
    if u.startswith("/") and not u.startswith("//"):
        return u
    for scheme in ALLOWED_SCHEMES:
        if u.startswith(scheme + ":") or u == "":
            return quote(u, safe=":/?#=&")
    return "/"

javascript:data:vbscript: 这些协议在 href 里都会被执行,白名单只允许 http/https/相对路径

3.3.4 一个完整的最小化 Flask API 防护模板

把上面的东西拼起来:

from flask import Flask, request, jsonify
from sqlalchemy import text
from pydantic import BaseModel, constr, validator
import re

app = Flask(__name__)
# db = ... 初始化省略

class ListGoodsDTO(BaseModel):
    kw: constr(max_length=50) = ""
    sort: str = "created_at"
    order: str = "asc"
    page: conint(ge=1) = 1

    @validator("sort")
    def check_sort(cls, v):
        allowed = {
   "id", "name", "price", "created_at"}
        return v if v in allowed else "created_at"

    @validator("order")
    def check_order(cls, v):
        return v.lower() if v.lower() in {
   "asc", "desc"} else "asc"

def escape_like(s: str) -> str:
    return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

@app.route("/api/goods")
def list_goods():
    try:
        dto = ListGoodsDTO(**request.args)
    except Exception as e:
        return jsonify({
   "error": "参数错误"}), 400

    # 真参数化:列名走白名单,值走绑定
    sql = text(f"""
        SELECT id, name, price FROM goods
        WHERE name LIKE :kw
        ORDER BY {dto.sort} {dto.order}
        LIMIT :offset, :size
    """)
    rows = app.db.session.execute(sql, {
   
        "kw": f"%{escape_like(dto.kw)}%",
        "offset": (dto.page - 1) * 20,
        "size": 20,
    })
    # 输出阶段:JSON 序列化本身就是一种"转义",
    # 但如果前端要把 name 渲染进 HTML,前端那边仍需做 HTML 转义
    return jsonify([
        {
   "id": r.id, "name": r.name, "price": float(r.price)}
        for r in rows
    ])

注意三点:

  1. sort / order 经过白名单校验后才拼进 SQL,剩下所有值都走 :param 绑定。
  2. kw 在 LIKE 里手动 escape 了通配符。
  3. 返回 JSON 时没有做 HTML 转义——因为这是 API 接口,前端拿到的应该是原始数据,转义由前端渲染层负责。这是输入校验和输出转义分离的典型体现。

四、绕过手段:防护方必须知道的攻防细节

这一节是和大多数"防护文章"拉开差距的地方。不懂绕过,写出来的防护代码多半是纸糊的。

4.1 SQL 注入的常见绕过

4.1.1 大小写与注释绕过

简单 WAF 用关键字黑名单:

UNION SELECT

绕过:

UnIoN SeLeCt
UN/**/ION SE/**/LECT
UNION%0aSELECT  -- 用换行符切断关键字
/*!50000UNION*//*!50000SELECT*/  -- MySQL 版本注释

/**/ 内联注释在 MySQL 里是合法语法,能把关键字切成两半还能执行。这就是为什么关键字黑名单不可靠——正确防护只有参数化这一条路

4.1.2 编码绕过

URL 编码、双重编码、Unicode 编码、Hex 编码:

'              ->  %27
%27            ->  %2527(双重编码,某些中间件会解码两次)
'admin'        ->  0x61646d696e(MySQL hex)
SELECT         ->  %u0053ELECT(IIS Unicode 解析)

实战里我遇到过最阴的:某 WAF 只解码一次 URL,业务层又解码了一次,结果双重编码的 %2527 在 WAF 眼里是无害的 %2527,到了业务层变成 %27 再变成 '

4.1.3 宽字节注入

这是 MySQL + GBK 编码场景下的经典绕过。PHP 时代的 addslashes()' 转成 \',攻击者发 %df'addslashes 后变成 %df\',在 GBK 解码下 %df\ 是一个合法汉字,' 就逃逸出来了。

Python 里这种场景相对少(默认 UTF-8),但如果你的库连接 charset 设置成 gbk 又用了字符串拼接,一样会中招:

# 危险配置
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://user:pwd@host/db?charset=gbk"

4.1.4 二次注入(前面讲过)

入库时安全,出库使用时拼接。防护原则:所有进入 SQL 的值,无论来源(用户、数据库、配置文件),都一律参数化。

4.2 XSS 的常见绕过

4.2.1 黑名单过滤的常见漏网

一个典型但脆弱的过滤:

def xss_filter(s):
    for kw in ["<script", "javascript:", "onerror", "onload", "onclick"]:
        s = s.replace(kw, "")
    return s

绕过方式多到写不完:

<ScRiPt>           <!-- 大小写 -->
<scr<script>ipt>   <!-- 嵌套,删一次后拼出完整关键字 -->
<img src=x onerror=alert(1)>   <!-- 换个标签 -->
<svg/onload=alert(1)>           <!-- svg 一样能触发 -->
<img src=x:alert(alt) alt=1>    <!-- 不在黑名单里的事件 -->
<body onload=alert(1)>
<iframe src=javascript:alert(1)>
<details/open/ontoggle=alert(1)>

黑名单永远跟不上 HTML 演化。XSS 防护只能靠上下文转义,不能靠关键字过滤。

4.2.2 data: 协议

<a href="data:text/html,<script>alert(1)</script>">click</a>
<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="></iframe>

很多前端只过滤了 javascript:,漏了 data:。Chrome 在某些版本里对 top-level navigation 的 data: 做了限制,但 iframe、object、低版本浏览器仍然能执行。

4.2.3 JavaScript 上下文的字符逃逸

var name = "用户输入";

如果用户输入 ";alert(1);//,且没做 JSON 编码:

var name = "";alert(1);//";

直接逃逸出字符串。这就是为什么 tojson 是必须的——它会把 " 转成 \"

4.2.4 模板注入混淆

Jinja2、Jinjaja、Tornado 模板如果直接渲染了用户输入:

# 致命错误
template = f"Hello {user_input}"
render_template_string(template)

用户输入 { { ''.__class__.__mro__[1].__subclasses__() }},直接 RCE。这是 SSTI(服务端模板注入),但和 XSS 经常被混在一起讨论。

4.2.5 DOM XSS

document.getElementById('content').innerHTML = location.hash.slice(1);

这种 XSS 不经过服务器,所有服务端防护都管不到。只能靠前端的 textContenttextContent、还是 textContent(重要的事说三遍),以及 CSP。

4.3 防护侧该记住的几条铁律

绕过手段列举不完,但规律是清晰的:

  1. 黑名单不可靠,无论是 SQL 关键字还是 HTML 标签。
  2. 参数化是 SQL 注入的唯一正确解,没有替代品。
  3. 输出转义必须基于上下文,HTML 转义放到 JS 里是无效的。
  4. 库里的数据不可信,二次注入的根源。
  5. DOM XSS 服务端管不到,CSP 是最后一道防线。

五、CSP:被低估的纵深防御

最后补一个常被忽略的点。即便前面所有防护都做对了,前端万一有 DOM XSS 漏洞,整条防线就破了。CSP(Content Security Policy)是兜底:

@app.after_request
def set_csp(resp):
    resp.headers["Content-Security-Policy"] = (
        "default-src 'self'; "
        "script-src 'self' https://cdn.example.com; "
        "object-src 'none'; "
        "base-uri 'self'"
    )
    return resp

CSP 不是用来替代转义的,它是纵深防御的一层。配置时注意:

  • 不要用 unsafe-inline,用了等于没设。
  • 不要用 unsafe-eval,用了等于没设。
  • 上线前先用 Content-Security-Policy-Report-Only 观察一段时间,确认没有误伤。

写在最后

做完那次应急复盘,团队改了几个习惯:

  • 代码评审里专门有一栏查"原生 SQL",凡是 text()raw()f-string 拼 SQL 的,必须给出白名单或参数化说明。
  • Pydantic DTO 成了所有 API 入口的强制要求,不接受裸 request.args
  • 前端渲染层禁用了 innerHTML,统一用 textContent 或框架的自动转义。
  • 加了 CSP 报告收集,DOM XSS 一旦触发能第一时间看到。

防护从来不是某一处做对了就行,而是一整条链路都不能掉链子。注入和 XSS 这两个老话题,每年 OWASP Top 10 都在,不是因为技术难,而是因为每一处细节都可能被忽略。

把每个"看起来还好"的写法都问一句"这个值来自哪里,会去到哪里",能挡掉大半的事故。

目录
相关文章
|
1天前
|
人工智能 文字识别 安全
AIGC 广告素材审核实践:从垂类模型到多模态合规治理
AIGC 广告素材审核的核心挑战,是在广告素材千万量级增长、秒级审核诉求和行业监管细化背景下,准确识别虚假误导、版权争议、行业准入、业务质量、品牌安全和多模态上下文风险。实践上,可采用垂类模型与大模型结合的架构,通过四级风险标签、CLIP 图文对齐、LLM 增强理解、知识蒸馏、策略引擎和人工复核形成治理闭环。
|
1天前
|
人工智能 自然语言处理 安全
招投标垂直AI工具选型技术解析:垂直大模型+RAG架构的落地实践
本文剖析AI标书工具的技术分野:通用大模型存在领域理解浅、隐性风险漏判、内容幻觉等致命短板;而专业方案依托自研垂直大模型+RAG架构,深度融合招投标规则,实现高准确率结构化解析、100%事实可溯生成,并通过硬规则引擎与语义校验双轨风控、国密级私有化部署,保障央国企等高合规场景零废标、零泄密。
|
2月前
|
缓存 JSON 安全
1688 买家端交易 API 全链路实战:订单创建
本文详解1688官方交易接口全链路实践,覆盖账号授权、地址标准化、订单预校验、快速下单、多渠道支付、状态同步及异常容错,适用于分销ERP、跨境SaaS与企业集采系统开发,附生产级容错方案与高频踩坑总结。(239字)
770 0
|
3月前
|
Java Go API
Python/Java/Go 准备的详细指南,涵盖环境搭建、基础语法、实战项目
本教程涵盖Python、Java、Go三门语言的零基础实战入门:Python实现天气查询工具(含API调用),Java开发学生管理系统(控制台交互),Go构建RESTful用户API服务。每篇含环境搭建、工具配置、语法精讲与避坑指南,助你快速上手核心开发技能。(239字)
227 1
|
3月前
|
弹性计算 数据库 数据安全/隐私保护
SaaS系统技术实践,架构设计及应用场景
本文深入解析SaaS系统的技术实践(多租户隔离、微服务、自动化运维、安全合规)、分层架构设计(基础设施至前端五层)及典型应用场景(CRM、HRM、电商、政务、教育等),兼顾理论深度与落地可行性,助力构建高可用、可扩展、低成本的云原生SaaS系统。(239字)
413 7
|
3月前
|
缓存 供应链 API
1688商品详情API(1688.item_get)Python实战:构建B2B供应链数据中台
本文详解1688开放平台2.0官方API(`1688.item_get`)接入实战,涵盖HMAC-MD5签名算法、环境配置、Python完整代码及高频问题解决方案,助力企业构建稳定、合规的B2B供应链数据同步系统。
496 1
|
3月前
|
数据采集 存储 API
阐述:淘宝 API 商品列表数据采集实战经验
本文分享淘宝商品列表API(taobao.items.search)合规采集实战经验,涵盖接口要点、签名加密避坑、限流应对及数据清洗技巧,强调“技术守规、艺术筛数、算术控本”,助力高效低成本获取高质量商品数据。(239字)
|
2月前
|
数据可视化 jenkins 测试技术
Python + Pytest 接口自动化测试方案
本文介绍一套企业级Python+Pytest接口自动化测试框架,覆盖接口封装、YAML数据驱动、Allure可视化报告及Jenkins CI/CD集成,结构清晰、开箱即用,助力测试工程师高效落地自动化,支撑从小型项目到大型分布式系统的质量保障。
361 0
|
3月前
|
Java Go 开发者
开发效率三剑客:代码格式化、接口调试与文档生成
本文系统讲解现代软件开发三大关键环节:代码格式化(统一风格、提升可读性)、接口调试(精准验证、Mock协同)与文档生成(代码即文档、实时同步)。涵盖Python/Java/Go等主流语言工具推荐及CI/CD集成实践,助力零基础开发者高效入门、规避低级错误。(239字)
266 0
|
17天前
|
数据采集 机器学习/深度学习 人工智能
田间杂草定位与检测4200张YOLO智慧农业数据集分享
本数据集含4200张真实农田图像,YOLO格式,单类别(杂草)高质量标注,覆盖多作物、多光照、多生长阶段等复杂场景,专为智慧农业杂草检测与智能除草设备研发设计,支持YOLOv5/v8/v10等主流模型训练。
427 94