一个看似无害的CSS类名,却绕过了我们部署在边缘节点的Web应用防火墙(WAF),在生产环境触发了一起存储型XSS事件。事件复盘时,我们发现攻击载荷被巧妙地拆分并嵌入到了由UnoCSS动态生成的原子化类名中。传统的WAF依赖于对原始HTTP请求体进行模式匹配,当恶意代码被分散到多个CSS类、并通过CSS选择器和伪元素在浏览器渲染时才被重组时,这种防御机制就失效了。
这个攻击的载荷大致是这样的:
<!-- 一个简化的攻击向量示例 -->
<div class="
before:content-['javas']
after:content-['cript:alert(document.domain)']
hover:[--payload:url('javascript:alert(1)')]
">
<a href="javascript:void(0)">...</a>
</div>
这里的content
属性在常规审查中可能被认为是安全的,因为它只是字符串。然而,在特定交互或通过JavaScript脚本操纵后,这些碎片化的字符串可以被拼接并执行。我们的WAF规则库里没有针对这种在CSS content
属性中分段注入javascript:
协议的检测逻辑,因为它在原始HTML中并不构成一个完整的、可直接识别的攻击签名。
这暴露了一个日益严峻的问题:随着现代前端框架和CSS-in-JS库的普及,应用的大量状态和逻辑被移到了客户端。攻击面也随之从可预测的服务器端输入,扩展到了复杂、动态、难以静态分析的客户端渲染逻辑中。我们需要一种新的检测机制,它不能只看“原材料”(原始HTML/JS/CSS),而必须看到“成品”(浏览器最终渲染出的DOM和CSSOM)。
初步的构想是,在CI/CD流水线中引入一个模拟真实用户环境的检测阶段。这个阶段会使用一个无头浏览器加载我们的应用页面,然后像一个安全研究员一样,检查渲染后的页面是否存在已知的漏洞模式。这本质上是一种“渲染层扫描”,或者可以称之为“E2E安全测试”。
技术选型上,Playwright成为了我们的首选。相比其他浏览器自动化工具,它提供了对浏览器内部工作机制更深层次的访问能力,包括但不限于网络拦截、精细的DOM操作API以及在页面上下文中执行复杂脚本的能力。它的性能和稳定性也足以支撑在每次代码提交时运行的全量扫描。
我们的目标是构建一个自动化的扫描器,它能完成以下核心任务:
- 启动一个无头浏览器实例,并导航到待测应用的各个页面。
- 在页面完全加载并渲染后,深度扫描整个DOM树和应用的样式表(CSSOM)。
- 识别出那些可能被用于注入攻击的、由UnoCSS等工具生成的动态CSS规则。
- 生成一份结构化的安全报告,如果发现高风险问题,则中断CI/CD流程。
第一步:搭建扫描器骨架与测试环境
我们首先需要一个基础的Node.js项目,并安装Playwright。同时,为了能稳定复现问题,我们用Express创建一个简单的Web服务,用于托管一个包含潜在漏洞的页面。
项目结构:
.
├── scanner/
│ ├── index.ts # 扫描器主入口
│ ├── detectors/ # 具体的检测逻辑模块
│ │ ├── attribute-detector.ts
│ │ └── cssom-detector.ts
│ ├── config.json # 扫描配置
│ └── report.json # 输出的报告
├── test-server/
│ ├── server.ts
│ └── public/
│ └── vulnerable.html
└── package.json
package.json
关键依赖:
{
"name": "render-layer-scanner",
"version": "1.0.0",
"scripts": {
"start-server": "ts-node test-server/server.ts",
"scan": "ts-node scanner/index.ts"
},
"dependencies": {
"playwright-chromium": "^1.39.0",
"express": "^4.18.2"
},
"devDependencies": {
"@types/node": "^20.8.9",
"@types/express": "^4.17.20",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
}
}
test-server/server.ts
:
这个服务器的作用是提供一个稳定的测试目标。
import express from 'express';
import path from 'path';
const app = express();
const PORT = process.env.PORT || 3001;
// 提供静态文件服务
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`[Test Server] Running at http://localhost:${PORT}`);
});
test-server/public/vulnerable.html
:
这个HTML文件包含了我们想要检测的、利用UnoCSS语法的攻击向量。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vulnerable Page</title>
<!-- 引入UnoCSS -->
<script src="https://cdn.jsdelivr.net/npm/@unocss/runtime"></script>
</head>
<body class="p-8">
<h1 class="text-2xl font-bold mb-4">Render-Layer Security Scan Target</h1>
<!-- 向量1: 在任意属性中注入 background-image -->
<div class="bg-[url('javascript:alert(`XSS-BG-URL`)')] w-40 h-40 border">
Vector 1: Background URL
</div>
<!-- 向量2: 利用伪元素和content属性拼接payload -->
<div id="vector2" class="
relative
mt-4 p-4 border
before:content-['java']
after:content-['script:alert(`XSS-CONTENT`)']
">
Vector 2: Sliced content property
</div>
<!-- 向量3: 利用CSS变量传递恶意URL -->
<div class="mt-4 p-4 border" style="--malicious-url: url('javascript:alert(`XSS-CSS-VAR`)');">
<a class="[background-image:var(--malicious-url)] p-2 text-white">
Vector 3: CSS Variable Injection
</a>
</div>
</body>
</html>
第二步:实现核心扫描逻辑
扫描器的主入口 scanner/index.ts
负责流程编排:启动Playwright、导航到目标页面、执行所有检测器、最后汇总结果。
scanner/config.json
:
将目标URL等配置信息抽离出来,方便维护。
{
"targetUrl": "http://localhost:3001/vulnerable.html",
"timeout": 30000,
"detectors": [
"attribute",
"cssom"
]
}
scanner/index.ts
:
import { chromium, Browser, Page } from 'playwright-chromium';
import fs from 'fs/promises';
import path from 'path';
// 导入配置和检测器
import config from './config.json';
import { runAttributeDetector, AttributeFinding } from './detectors/attribute-detector';
import { runCssomDetector, CssomFinding } from './detectors/cssom-detector';
interface ScanResult {
url: string;
timestamp: string;
findings: (AttributeFinding | CssomFinding)[];
}
async function main() {
let browser: Browser | null = null;
console.log('[Scanner] Starting...');
try {
browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
console.log(`[Scanner] Navigating to ${config.targetUrl}`);
await page.goto(config.targetUrl, {
waitUntil: 'networkidle', // 等待网络空闲,确保所有资源加载完毕
timeout: config.timeout,
});
// UnoCSS运行时需要一点时间来解析和注入样式
await page.waitForTimeout(1000);
const findings: (AttributeFinding | CssomFinding)[] = [];
// 执行各个检测器
if (config.detectors.includes('attribute')) {
console.log('[Scanner] Running Attribute Detector...');
const attributeFindings = await runAttributeDetector(page);
findings.push(...attributeFindings);
}
if (config.detectors.includes('cssom')) {
console.log('[Scanner] Running CSSOM Detector...');
const cssomFindings = await runCssomDetector(page);
findings.push(...cssomFindings);
}
const report: ScanResult = {
url: config.targetUrl,
timestamp: new Date().toISOString(),
findings,
};
const reportPath = path.join(__dirname, 'report.json');
await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
console.log(`[Scanner] Scan complete. Report saved to ${reportPath}`);
if (report.findings.length > 0) {
console.error(`[Scanner] Vulnerabilities found! Failing process.`);
process.exit(1); // 在CI环境中,非零退出码会使步骤失败
}
} catch (error) {
console.error('[Scanner] An error occurred during the scan:', error);
process.exit(1);
} finally {
if (browser) {
await browser.close();
}
}
}
main();
第三步:开发检测器模块
这是扫描器的核心。每个检测器专注于一类特定的漏洞模式。
detectors/attribute-detector.ts
这个检测器负责扫描DOM元素的所有属性,查找包含危险协议或模式的属性值。这能覆盖一些基础的注入场景。
import { Page } from 'playwright-chromium';
export interface AttributeFinding {
type: 'Attribute';
severity: 'High';
message: string;
element: {
tagName: string;
id: string;
classes: string;
};
attribute: {
name: string;
value: string;
};
}
// 定义危险的协议和模式
const DANGEROUS_PATTERNS = [
'javascript:',
'data:text/html',
'vbscript:',
];
export async function runAttributeDetector(page: Page): Promise<AttributeFinding[]> {
// page.evaluate 在浏览器上下文中执行代码,可以访问DOM
const findings = await page.evaluate((patterns) => {
const results: AttributeFinding[] = [];
const allElements = document.querySelectorAll('*');
allElements.forEach(element => {
// 将NamedNodeMap转换为普通数组以进行迭代
const attributes = Array.from(element.attributes);
attributes.forEach(attr => {
const attrValue = attr.value.toLowerCase().trim();
const foundPattern = patterns.find(p => attrValue.startsWith(p));
if (foundPattern) {
results.push({
type: 'Attribute',
severity: 'High',
message: `Dangerous pattern '${foundPattern}' found in attribute '${attr.name}'.`,
element: {
tagName: element.tagName,
id: element.id,
classes: element.className,
},
attribute: {
name: attr.name,
value: attr.value,
},
});
}
});
});
return results;
}, DANGEROUS_PATTERNS);
return findings;
}
这里的关键是 page.evaluate
。我们将服务端的 DANGEROUS_PATTERNS
数组作为参数传递给浏览器端的执行函数,避免了在浏览器端硬编码。这使得我们的检测规则可以集中管理。
detectors/cssom-detector.ts
这个模块是本次任务的核心,专门用于应对隐藏在CSS中的攻击。它会遍历页面加载的所有样式表(CSSOM),检查每一条规则,特别是那些由UnoCSS动态生成的规则。
import { Page } from 'playwright-chromium';
export interface CssomFinding {
type: 'CSSOM';
severity: 'High' | 'Medium';
message: string;
rule: {
selector: string;
property: string;
value: string;
};
}
// 匹配CSS中可能包含脚本的url()值
const URL_SCRIPT_REGEX = /url\s*\(\s*['"]?\s*(javascript|data|vbscript):/i;
export async function runCssomDetector(page: Page): Promise<CssomFinding[]> {
const findings = await page.evaluate((regexStr) => {
const scriptRegex = new RegExp(regexStr, 'i');
const results: CssomFinding[] = [];
// 遍历所有样式表
for (const sheet of Array.from(document.styleSheets)) {
try {
if (!sheet.cssRules) continue;
// 遍历样式表中的所有规则
for (const rule of Array.from(sheet.cssRules)) {
// 我们只关心标准的样式规则
if (rule instanceof CSSStyleRule) {
// 检查 'content' 属性,寻找拼接的payload
const contentValue = rule.style.getPropertyValue('content');
if (contentValue && /script/i.test(contentValue.replace(/['"\s]/g, ''))) {
results.push({
type: 'CSSOM',
severity: 'Medium',
message: `Suspicious 'content' property found. Potential payload slicing.`,
rule: {
selector: rule.selectorText,
property: 'content',
value: contentValue,
},
});
}
// 遍历规则中的所有声明
for (let i = 0; i < rule.style.length; i++) {
const propName = rule.style[i];
const propValue = rule.style.getPropertyValue(propName);
// 检查属性值是否匹配危险的url()模式
if (propValue && scriptRegex.test(propValue)) {
results.push({
type: 'CSSOM',
severity: 'High',
message: `Malicious protocol found in CSS URL for property '${propName}'.`,
rule: {
selector: rule.selectorText,
property: propName,
value: propValue,
},
});
}
}
}
}
} catch (e) {
// 忽略跨域样式表的安全错误
if (e instanceof DOMException && e.name === 'SecurityError') {
// console.warn('Could not access cross-origin stylesheet:', sheet.href);
} else {
// throw e;
}
}
}
return results;
}, URL_SCRIPT_REGEX.source); // 将正则表达式的源字符串传递给浏览器
return findings;
}
这个检测器有几个实现要点:
- 访问CSSOM: 通过
document.styleSheets
获取页面上所有的样式表,然后遍历sheet.cssRules
。这是一个实时表示,包含了所有通过<link>
,<style>
以及像UnoCSS运行时注入的样式。 - 跨域处理: 必须用
try...catch
包裹对sheet.cssRules
的访问,因为如果样式表是跨域加载且没有正确的CORS头部,访问会抛出安全异常。 - 正则传递: 和上一个检测器一样,我们将正则表达式的
source
字符串传递给page.evaluate
,然后在浏览器端重新构造RegExp
对象。这比传递整个对象更可靠。 - 检测
content
属性: 我们特别增加了一个针对content
属性的检查。这里的逻辑比较粗糙(test(/script/i)
),在真实项目中需要更复杂的启发式算法来检测拼接,但它展示了核心思路:识别可能用于数据拼接的CSS属性。
第四步:集成到CI流水线
最后一步是将这个扫描器集成到我们的CI流程中,例如GitHub Actions。当有新的代码推送到主分支或创建Pull Request时,自动执行此安全扫描。
.github/workflows/security-scan.yml
:
name: Render-Layer Security Scan
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start test server in background
run: npm run start-server & # 使用 '&' 使其在后台运行
- name: Wait for server to be ready
run: sleep 5 # 简单等待,生产环境应使用更健壮的等待机制
- name: Run security scan
run: npm run scan # 脚本在发现问题时会以非零状态退出
当扫描脚本 npm run scan
执行时,如果 scanner/index.ts
中的 process.exit(1)
被调用,GitHub Actions会标记这个步骤为失败,从而阻止不安全的代码合并。
局限性与未来展望
这个方案并非银弹。它是一个纵深防御体系中的一个环节,旨在将安全问题尽可能地“左移”。它存在一些固有的局限性:
- 性能开销: 启动一个完整的浏览器实例并渲染页面是有成本的。对于大型应用,全站扫描可能会显著延长CI/CD的执行时间。策略上可以优化为只扫描发生变更的页面。
- 覆盖率问题: 扫描器只能检测到它被编程去寻找的模式。对于未知的、更复杂的攻击向量(例如利用复杂的CSS选择器组合和交互才能触发的漏洞),它可能无能为力。规则库需要持续更新。
- 动态交互: 当前的实现只扫描了页面的静态渲染结果。对于需要用户交互(如点击、输入)后才会出现的DOM变化,此扫描器无法覆盖。可以扩展Playwright脚本来模拟一些基本的用户行为,但这会进一步增加复杂性和执行时间。
未来的迭代方向可以包括:
- 启发式分析: 引入更智能的检测算法,比如分析CSS变量的传递链,或者检测DOM中是否存在异常数量的、带有
content
属性的空元素,这些都可能是攻击的前兆。 - 与AST结合: 结合静态代码分析(AST),在源码层面识别出可能产生危险UnoCSS类名的模式,作为渲染层扫描的补充。
- 组件级扫描: 在组件库(如Storybook)的层面集成扫描,对单个组件进行隔离测试,能更早、更快地发现问题。
通过这种方式,我们利用Playwright的能力,弥补了传统WAF在面对现代前端技术时的不足,将安全检测的视角从“请求”层面深入到了“渲染”层面,为我们的应用增加了一道重要的防线。