利用Playwright构建针对UnoCSS动态样式的渲染层安全扫描器


一个看似无害的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以及在页面上下文中执行复杂脚本的能力。它的性能和稳定性也足以支撑在每次代码提交时运行的全量扫描。

我们的目标是构建一个自动化的扫描器,它能完成以下核心任务:

  1. 启动一个无头浏览器实例,并导航到待测应用的各个页面。
  2. 在页面完全加载并渲染后,深度扫描整个DOM树和应用的样式表(CSSOM)。
  3. 识别出那些可能被用于注入攻击的、由UnoCSS等工具生成的动态CSS规则。
  4. 生成一份结构化的安全报告,如果发现高风险问题,则中断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;
}

这个检测器有几个实现要点:

  1. 访问CSSOM: 通过 document.styleSheets 获取页面上所有的样式表,然后遍历 sheet.cssRules。这是一个实时表示,包含了所有通过<link>, <style>以及像UnoCSS运行时注入的样式。
  2. 跨域处理: 必须用 try...catch 包裹对 sheet.cssRules 的访问,因为如果样式表是跨域加载且没有正确的CORS头部,访问会抛出安全异常。
  3. 正则传递: 和上一个检测器一样,我们将正则表达式的 source 字符串传递给 page.evaluate,然后在浏览器端重新构造 RegExp 对象。这比传递整个对象更可靠。
  4. 检测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会标记这个步骤为失败,从而阻止不安全的代码合并。

局限性与未来展望

这个方案并非银弹。它是一个纵深防御体系中的一个环节,旨在将安全问题尽可能地“左移”。它存在一些固有的局限性:

  1. 性能开销: 启动一个完整的浏览器实例并渲染页面是有成本的。对于大型应用,全站扫描可能会显著延长CI/CD的执行时间。策略上可以优化为只扫描发生变更的页面。
  2. 覆盖率问题: 扫描器只能检测到它被编程去寻找的模式。对于未知的、更复杂的攻击向量(例如利用复杂的CSS选择器组合和交互才能触发的漏洞),它可能无能为力。规则库需要持续更新。
  3. 动态交互: 当前的实现只扫描了页面的静态渲染结果。对于需要用户交互(如点击、输入)后才会出现的DOM变化,此扫描器无法覆盖。可以扩展Playwright脚本来模拟一些基本的用户行为,但这会进一步增加复杂性和执行时间。

未来的迭代方向可以包括:

  • 启发式分析: 引入更智能的检测算法,比如分析CSS变量的传递链,或者检测DOM中是否存在异常数量的、带有content属性的空元素,这些都可能是攻击的前兆。
  • 与AST结合: 结合静态代码分析(AST),在源码层面识别出可能产生危险UnoCSS类名的模式,作为渲染层扫描的补充。
  • 组件级扫描: 在组件库(如Storybook)的层面集成扫描,对单个组件进行隔离测试,能更早、更快地发现问题。

通过这种方式,我们利用Playwright的能力,弥补了传统WAF在面对现代前端技术时的不足,将安全检测的视角从“请求”层面深入到了“渲染”层面,为我们的应用增加了一道重要的防线。


  目录