Web 安全基础
1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:
官方文档:https://owasp.org/www-project-top-ten/
适用版本:OWASP Top 10 2021(2026-05-07 核实)
OWASP Top 10 概览
| 排名 | 漏洞类型 | 简述 |
|---|---|---|
| A01 | 访问控制失效 | 用户可访问未授权的资源 |
| A02 | 加密机制失效 | 敏感数据明文传输/存储 |
| A03 | 注入 | SQL 注入、命令注入、XSS 等 |
| A04 | 不安全设计 | 设计阶段缺乏威胁建模 |
| A05 | 安全配置错误 | 默认密码、错误信息泄露 |
| A06 | 自带缺陷和过时的组件 | 使用有漏洞的依赖库 |
| A07 | 认证和验证机制失效 | 弱密码、会话固定 |
| A08 | 软件和数据完整性失效 | 反序列化漏洞、CI/CD 供应链攻击 |
| A09 | 安全日志和监控失效 | 无法检测攻击、响应迟缓 |
| A10 | 服务端请求伪造(SSRF) | 服务器被迫发请求到内网 |
XSS(跨站脚本攻击)
攻击类型
| 类型 | 原理 | 典型场景 |
|---|---|---|
| 存储型(持久型) | 恶意脚本存入数据库,每次渲染时执行 | 评论区、用户资料 |
| 反射型(非持久) | 恶意脚本包含在 URL 参数中,服务端直接返回 | 搜索页、错误页 |
| DOM 型 | 客户端 JS 直接操作 DOM,未经过服务端 | 前端路由 hash、innerHTML |
防御措施
1. HTML 转义(服务端渲染必须):
import html
user_input = '<script>alert("xss")</script>'
safe_output = html.escape(user_input)
# 输出:<script>alert("xss")</script>
2. CSP(Content Security Policy):
# FastAPI 添加 CSP 头
from fastapi import Response
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'nonce-{nonce}'; "
"style-src 'self' 'unsafe-inline';"
)
return response
3. HttpOnly Cookie:防止 JS 读取会话 Cookie:
response.set_cookie(
key="session_id",
value=session_token,
httponly=True, # 禁止 JS 访问
secure=True, # 仅 HTTPS 传输
samesite="strict"
)
4. 前端框架防护:
- React:
dangerouslySetInnerHTML要经过DOMPurify.sanitize() - Vue:
v-html同上,避免直接渲染用户内容
SQL 注入
攻击原理
# 危险写法:字符串拼接
user_id = request.query_params["id"]
query = f"SELECT * FROM users WHERE id = {user_id}"
# 攻击者传入:1 OR 1=1
# 实际执行:SELECT * FROM users WHERE id = 1 OR 1=1
防御:参数化查询
# SQLAlchemy ORM(天然安全)
user = await User.get(id=user_id)
# SQLAlchemy Core 参数化
from sqlalchemy import text
result = await db.execute(
text("SELECT * FROM users WHERE id = :id"),
{"id": user_id}
)
# aiomysql 参数化
await cursor.execute(
"SELECT * FROM users WHERE id = %s",
(user_id,)
)
检测工具
- sqlmap:自动化 SQL 注入检测
- 手工测试:输入
'、1 OR 1=1、1; DROP TABLE users--观察响应
CSRF(跨站请求伪造)
攻击原理
攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。
触发条件:
- 用户已登录目标网站(Cookie 有效)
- 目标 API 仅凭 Cookie 识别用户身份
- 请求来源未验证
防御方案
1. CSRF Token(推荐):
import secrets
from fastapi import Request, HTTPException
def generate_csrf_token() -> str:
return secrets.token_urlsafe(32)
async def verify_csrf_token(request: Request):
"""依赖注入:验证 CSRF Token"""
token_from_header = request.headers.get("X-CSRF-Token")
token_from_session = request.session.get("csrf_token")
if not token_from_header or token_from_header != token_from_session:
raise HTTPException(status_code=403, detail="CSRF token invalid")
2. SameSite Cookie:
response.set_cookie(
key="session_id",
value=session_token,
samesite="strict", # 第三方站点请求不携带此 Cookie
)
| SameSite 值 | 行为 |
|---|---|
Strict |
只有同站请求才携带 Cookie(最严格) |
Lax |
GET 导航可携带,POST 等不行(浏览器默认值) |
None |
总是携带,必须同时设置 Secure=True |
3. 验证 Origin/Referer 头:
ALLOWED_ORIGINS = {"https://example.com", "https://www.example.com"}
async def check_origin(request: Request):
origin = request.headers.get("Origin") or request.headers.get("Referer", "")
if not any(origin.startswith(allowed) for allowed in ALLOWED_ORIGINS):
raise HTTPException(status_code=403)
认证与授权
密码存储
永远不要明文存储密码,不要用 MD5/SHA1:
# 推荐:bcrypt 或 argon2
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
bcrypt vs argon2 对比:
| 算法 | 内存硬度 | 抗 GPU | 推荐度 |
|---|---|---|---|
| bcrypt | 否 | 一般 | 广泛使用,稳定 |
| argon2id | 是 | 强 | 新项目推荐(Password Hashing Competition 冠军) |
| PBKDF2 | 否 | 弱 | 仅用于合规要求 |
JWT 安全使用
参考 JWT完全指南,关键安全要点:
| 要点 | 说明 |
|---|---|
| 算法指定 | 验证时必须指定 algorithms=["HS256"],禁止 alg: none |
| Access Token 短有效期 | 建议 15 分钟到 1 小时 |
| Refresh Token 轮换 | 每次使用后颁发新的 Refresh Token |
| 黑名单 | 登出时将 Token 加入 Redis 黑名单 |
| 敏感信息 | JWT Payload 不存放密码等敏感信息(Base64 可解码) |
OAuth2 授权码流程
1. 用户点击"第三方登录"→ 跳转到授权服务器(带 client_id + redirect_uri + state)
2. 用户在授权服务器登录并同意授权
3. 授权服务器重定向回 redirect_uri,携带 code(授权码,一次性)
4. 后端用 code + client_secret 向授权服务器换取 access_token
5. 用 access_token 调用资源服务器获取用户信息
state 参数:防止 CSRF 攻击,客户端生成随机值,回调时验证是否一致。
SSRF(服务端请求伪造)
危害
攻击者控制服务端发起的请求,可访问:
- 内网服务(数据库、Redis、内部 API)
- 云服务商元数据接口(
http://169.254.169.254,获取 IAM 凭证)
防御
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # 云元数据
]
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
try:
ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
except Exception:
return False
return not any(ip in net for net in BLOCKED_NETWORKS)
async def fetch_external(url: str) -> bytes:
if not is_safe_url(url):
raise ValueError("URL not allowed")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=5)
return response.content
文件上传安全
| 校验项 | 实现方式 |
|---|---|
| 文件类型 | 读取文件头魔术字节,不信任 Content-Type 和扩展名 |
| 文件大小 | 限制最大上传大小(FastAPI UploadFile + 中间件) |
| 文件名 | 重命名为随机 UUID,防止路径穿越(../../etc/passwd) |
| 存储位置 | 存储在 Web 根目录之外或 OSS,禁止直接执行 |
| 病毒扫描 | 可选:ClamAV 集成 |
import uuid
import magic # python-magic
from pathlib import Path
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
async def save_upload(file: UploadFile) -> str:
content = await file.read()
# 检查大小
if len(content) > MAX_SIZE:
raise ValueError("File too large")
# 检查真实 MIME 类型(读魔术字节)
mime = magic.from_buffer(content, mime=True)
if mime not in ALLOWED_MIME_TYPES:
raise ValueError(f"File type not allowed: {mime}")
# 随机文件名
ext = {"image/jpeg": ".jpg", "image/png": ".png",
"image/gif": ".gif", "image/webp": ".webp"}[mime]
filename = f"{uuid.uuid4().hex}{ext}"
upload_dir = Path("/var/uploads") # Web 根目录之外
upload_dir.mkdir(parents=True, exist_ok=True)
(upload_dir / filename).write_bytes(content)
return filename
HTTPS 配置安全
HSTS(强制 HTTPS)
# Nginx 配置
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# FastAPI 中间件
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
app.add_middleware(HTTPSRedirectMiddleware)
禁用弱密码套件(Nginx)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
安全响应头速查
| 响应头 | 推荐值 | 作用 |
|---|---|---|
X-Content-Type-Options |
nosniff |
禁止 MIME 嗅探 |
X-Frame-Options |
DENY 或 SAMEORIGIN |
防止点击劫持 |
Referrer-Policy |
strict-origin-when-cross-origin |
控制 Referer 信息 |
Permissions-Policy |
geolocation=(), camera=() |
限制浏览器 API 权限 |
Content-Security-Policy |
见 XSS 节 | 防 XSS |
最佳实践
纵深防御原则
安全不能依赖单一防线。每个层次都要设置独立防御:
| 层次 | 防御措施 |
|---|---|
| 网络层 | WAF、DDoS 防护、IP 白名单 |
| 传输层 | HTTPS / TLS 1.2+,HSTS |
| 应用层 | 输入校验、参数化查询、CSP |
| 认证层 | 强密码策略、MFA、短会话 |
| 数据层 | 加密静态数据、字段级权限 |
| 监控层 | 异常登录检测、审计日志 |
输入验证三原则
- 在服务端验证:永远不要仅依赖前端校验,所有用户输入都必须在服务端再次验证
- 白名单而非黑名单:明确允许什么格式,而不是过滤已知危险字符
- 最小化信任:即使是内部服务之间的请求也要验证身份
from pydantic import BaseModel, constr, validator
import re
class UserCreateRequest(BaseModel):
username: constr(min_length=3, max_length=32, pattern=r'^[a-zA-Z0-9_]+$')
email: str
age: int
@validator('email')
def validate_email(cls, v):
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', v):
raise ValueError('Invalid email format')
return v.lower()
密钥与凭证管理
# 错误:硬编码密钥
SECRET_KEY = "mysecretkey123"
# 正确:从环境变量读取
import os
SECRET_KEY = os.environ["SECRET_KEY"] # 启动时若缺失会立即报错
# 生产环境推荐:HashiCorp Vault / AWS Secrets Manager / GCP Secret Manager
- 使用
.env文件存储本地开发密钥,.env加入.gitignore - 定期轮换密钥(JWT secret、API key、数据库密码)
- 不同环境(dev/staging/prod)使用不同密钥
安全编码检查清单
| 检查项 | 工具/方法 |
|---|---|
| 依赖漏洞扫描 | pip audit、safety check、Snyk |
| SQL 注入防护 | 使用 ORM 或参数化查询,禁止字符串拼接 |
| 敏感信息泄露 | truffleHog / git-secrets 扫描 Git 历史 |
| 弱密码策略 | 强制最小长度 12 位,含大小写+数字+符号 |
| 权限最小化 | 数据库用户只给 SELECT/INSERT,不给 DROP |
| 错误处理 | 生产环境返回通用错误,详情只写日志 |
常见陷阱
陷阱:SQL 拼接字符串导致注入漏洞
现象: 用户输入 ' OR '1'='1 绕过登录验证,或输入 '; DROP TABLE users; -- 删除数据。
原因: 将用户输入直接拼接进 SQL 字符串,攻击者可以改变 SQL 语义。
解决: 始终使用参数化查询(ORM 或 cursor.execute(sql, params)),永远不要用字符串格式化拼接 SQL。
# 错误:字符串拼接,存在注入风险
cursor.execute(f"SELECT * FROM users WHERE name = '{username}'")
# 正确:参数化查询
cursor.execute("SELECT * FROM users WHERE name = %s", (username,))
陷阱:生产环境开启 debug 模式暴露内部信息
现象: 接口报错时,响应体包含完整的 Python 堆栈、数据库连接字符串、本地文件路径等敏感信息。
原因: 框架的 debug 模式(FastAPI debug=True、Django DEBUG=True)会在响应中返回详细错误信息,方便开发调试,但在生产环境会暴露内部实现。
解决: 生产环境通过环境变量控制 debug 开关,错误响应只返回通用错误码和 ID,详细错误记录到日志(不包含敏感字段)。
陷阱:JWT Secret 使用弱密钥或硬编码
现象: 攻击者通过暴力破解或代码泄露获得 JWT Secret,伪造任意用户的 Token,绕过认证。
原因: 使用简短密钥(如 secret、123456)容易被字典攻击;硬编码在源码中一旦仓库泄露立即失效。
解决: JWT Secret 至少 256 位随机字节(openssl rand -hex 32),通过环境变量注入,定期轮换;对高价值接口添加额外的时效校验(不依赖 JWT exp 单点失效)。
参见
- JWT完全指南 — JWT 认证的安全实现与常见漏洞
- HTTP协议深度指南 — HTTPS、Cookie 安全属性、安全响应头详解
- FastAPI完全指南 — FastAPI 安全最佳实践(认证、权限、依赖注入)