项目:PEXELS 壁纸应用 | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本项目没有自建后端、没有账号体系、没有用户输入的持久化(《00-项目总览》第 1 节),传统 Web 应用最重视的"服务端注入、越权访问、会话劫持"三类风险在本项目里没有落点。它的攻击面收缩为四个,每一个都能在源码里找到对应的暴露点或防线:
| 攻击面 | 具体载体 | 现状 | 风险级 |
|---|---|---|---|
| 凭据泄露 | VITE_PEXELS_API_KEY(env 文件 + 构建产物) | 🔴 真实 Key 明文落盘且已打进 dist | 高 |
| 内容注入 | Pexels/Picsum 外链图片与视频、第三方统计脚本 | 无 CSP,依赖内容源自律 | 中 |
| 供应链 | npm 依赖、pnpm-lock.yaml、第三方 script 标签 | 依赖精简(11 个直接依赖),无 lock 完整性校验 | 中低 |
| 运行时注入 | XSS 常用通道(v-html/eval/innerHTML) | 源码零使用,XSS 面极小 | 低 |
"权限"一侧同样被压缩到最小:应用唯一的安全边界是 Pexels API Key 的调用权限(每小时约 200 次的配额就是它的全部价值),唯一的用户数据是 localStorage 里一个主题字符串。因此本篇的重心不是纵深防御体系,而是把"一个 Key 的生命周期安全"讲透,再把剩下的面逐条盘点。docs/dependency-upgrade-plan.md 的"安全现状"章节是官方的历史档案,本篇在其基础上补齐了工作区实证与处置步骤。
这一问题是全项目当前最严重的安全隐患,证据链有三条,均可在工作区直接复现:
PEXELS/.env.development 与 PEXELS/.env.production 两个文件中,VITE_PEXELS_API_KEY 均填写了同一个真实 Key(56 位字符串)。两文件虽被 .gitignore 的 .env.* 规则忽略(git check-ignore -v 实测命中第 17 行规则,仅 !.env.example 放行模板),但文件本身以明文存在于工作区,任何能读到这个目录的进程、备份、同步盘、打包归档都能带走它。dist/assets/pexels-*.js 中实测存在该 Key 的明文(对该文件做包含性 grep 可命中 1 处)——因为 src/api/pexels.ts 第 13 行 const API_KEY = import.meta.env.VITE_PEXELS_API_KEY || '' 在构建期被静态内联。这意味着这份 dist 若被原样部署,任何访客打开 DevTools 的 Sources 面板搜索即可提取 Key 盗用配额。dist/ 在 .gitignore 中,但它此刻就在工作区,且"产物含 Key"是直连方案的结构性结果而非偶然。c5f41bd("chore: 将环境变量文件移出版本库并清除历史中的 API Key")的提交信息直接证明:env 文件曾在版本库中、Key 曾被提交过,随后做了一次移出与历史清理。当前 git log --all -- .env.development .env.production 已无任何记录,git ls-files | grep -i env 只剩 .env.example 与 src/env.d.ts,说明本仓库当前历史里已经检索不到 env 文件(清理动作大概率采用了重写历史的手段)⚠️ 待确认:仓库内无清理工具的执行记录(如 git filter-repo 的报告文件),无法从仓库本身确认清理的彻底性与方式。影响范围由 Key 的能力决定,逐条列出:Pexels 的 API Key 只授予只读的内容检索能力(搜索、精选、详情、合集枚举),不包含写操作、不关联个人隐私数据,因此泄露的直接后果不是数据泄露而是资源盗用——泄露方可以无限消耗每 Key 每小时约 200 次的配额,导致正常用户大面积遭遇 429(前端已有 5 分钟熔断兜底,见《05-核心流程》第 5 节,但熔断期间封面暂停、列表报错,产品基本不可用);其次是被 Pexels 风控封禁 Key、连带封禁注册账号的可能 ⚠️ 待确认(Pexels 具体封禁策略以其服务条款为准)。对部署者还有一层间接影响:若 Key 被滥用触发 Pexels 的账号级处罚,轮换成本从"换个字符串"上升为"重新注册账号"。
需要强调的是:这个问题在本项目的语境下被"前端产物必然暴露 Key"部分稀释,但不能被它完全豁免。.env.example 的注释写得很清醒:"VITE_ 前缀的变量会被打包进前端产物,任何值都不具备真正的保密性"——所以"Key 会进产物"是直连方案的已知属性;但"真实 Key 同时躺在两个 env 文件和一个已构建的 dist 里"是当前工作区的实际状态,两者叠加意味着:哪怕部署者决定立即改走代理方案,只要不轮换 Key,已经泄露出去的旧 Key 依然有效。
https://www.pexels.com/api/)作废当前 Key 并生成新 Key。这是唯一能对"已经离开工作区的副本"生效的措施——文件可以删,但无法证明它没被复制过。轮换后应用侧只需替换 env 变量值并重建,无代码改动(src/api/pexels.ts 对 Key 的消费只有一处模块级常量)。VITE_PEXELS_PROXY_BASE 指向服务端反向代理(契约要求见《03-接口设计》第 4 节"代理模式的服务端契约":转发路径、服务端注入 Authorization 头、不改响应体、正确透传 4xx——代理若把 429 吞成 200,前端熔断将完全失效)。新 Key 只放在代理服务端的环境变量里,本地与前端仓库从此不再持有明文。Vite 开发代理(vite.config.ts 的 server.proxy)就是这一契约的参考实现。dist/ 目录(它内嵌旧 Key 明文,留着即是长期暴露面),重建时确认新 dist 中 grep 不到任何 Key(验证命令见《06-部署运行》第 4.2 节发布校验清单)。git remote -v 实测本仓库没有配置任何远端,只要从未推送过即可排除 ⚠️ 待确认(无法从仓库证明"从未推送");其二,docs/dependency-upgrade-plan.md"安全现状"第 2 条自述:更早的"接口签名盐值"(含个人标识的字符串)曾硬编码在已删除的 src/utils/index.ts与登录页中,"旧提交的对象里仍能检出该字符串"——即该仓库的历史对象里**确实还存在一个已作废敏感值**,文档同时说明该值当年就被打进前端产物、本不具备保密性,清理理由是隐私而非安全。若要把仓库分享给第三方,建议先做一次git history` 全量审计或干脆以当前快照重新初始化历史。理解这个问题为什么"无解但可控",需要看清 Vite 的静态内联机制。所有 VITE_ 前缀的环境变量在构建期被替换为字面量(这是 Vite 的安全约定:不带前缀的变量不会进客户端代码),因此 src/api/pexels.ts 里的 API_KEY 常量在产物中就是明文字符串;请求发出时它被放进 Authorization 请求头(无 Bearer 前缀,src/utils/pexels.ts 文件头注释强调),在浏览器 DevTools 的 Network 面板中同样可见。也就是说,直连方案下 Key 有三个暴露点:产物 JS 文本、运行时请求头、env 文件本身。
代理方案把三个暴露点都消除:产物里只有代理地址(PROXY_BASE),请求头由代理服务端注入,env 文件里只有 URL。它的边界条件也要说清楚:代理本身必须部署在可信环境;若代理与前端不同源,还引入了新的 CORS 配置面;代理成为唯一瓶颈后,配额保护从"前端自觉"升级为"服务端可管控",这也正是《06-部署运行》建议在生产叠加服务端限流与短 TTL 缓存的原因。
另一个容易忽视的边界是开发代理的同源化:dev 下未配置 VITE_PEXELS_PROXY_BASE 时自动走 /api/pexels(vite.config.ts 的 server.proxy),此举的动机是规避 localhost 来源直连被 Cloudflare 拦截(src/api/pexels.ts 注释),它同时让开发期的 Key 不出现在跨域请求里,但 Key 仍会出现在浏览器到 Vite 的同源请求头中——对真机调试场景(手机访问开发机 IP)来说,Key 依然在同一局域网内明文传输,仅限于受控网络使用。
index.html 与 dist/index.html 中均无任何 Content-Security-Policy 声明(实测 grep 计数为 0),也没有任何服务端响应头可依赖(静态托管配置里同样未见,见《06-部署运行》第 4 节的 Nginx 示例)。对本项目而言 CSP 的缺失意味着:一旦出现注入点(当前没有),脚本可不受限地外联。建议的最小 CSP 基线(按实际资源清单推导):default-src 'self';img-src 放行 https://api.pexels.com https://picsum.photos https://images.pexels.com(Pexels 图片域 ⚠️ 待确认:以实际返回的 CDN 域名为准,建议先用 Content-Security-Policy-Report-Only 观察再收紧);media-src 放行 Pexels 视频域与 https://commondatastorage.googleapis.com(降级片源);script-src 若保留统计脚本则需放行 https://analytics.zhangzhengyang.com,若不需要统计(见第 6 节)则可完全自闭合;connect-src 放行 https://api.pexels.com。值得注意的是,本项目改成 CSP 的成本很低——没有内联事件处理器、没有 eval、样式全部经构建产物外链——这属于"现在不做、以后随时可做"的低 hanging fruit。
内容注入面的另一半是外链媒体本身。全站图片来自 Pexels CDN 与 Picsum、视频来自 Pexels 文件服务器与 Google 示例桶,<img>/<video> 渲染外链内容本身不执行脚本,但存在两类次级风险:一是内容来源不可控(Pexels 是开放平台,图片内容合规性依赖平台审核,alt 描述等文本字段直接渲染进 DOM——当前全部经 Vue 文本插值,自动转义,无 v-html,实测 src/ 中 v-html 使用次数为 0);二是降级片源 commondatastorage.googleapis.com 是第三方基础设施,若未来失效仅影响无 Key 演示模式。
把 XSS 与脚本执行的常见通道逐一对照源码(全部实测):v-html 0 处;eval/new Function 0 处;innerHTML 直接赋值 0 处;模板全部经 Vue 的文本插值自动转义;唯一的 target="_blank" 外链(src/components/media-viewer/index.vue 的「原页」链接)已带 rel="noreferrer",同时切断 window.opener 反向操控与 referrer 泄露;history.pushState/replaceState 的写入值全部来自内部构造的 /pins/{id}、/videos/{id} 路径(id 源自 Pexels 数据或路由参数,仅进入 URL 而非 HTML)。剪贴板写入(src/components/media-hover-actions/index.vue)使用 navigator.clipboard.writeText 且只写"下载地址"这一受控字符串。综合看,运行时注入面在当前代码习惯下是收敛的,CSP(第 4 节)是把它变成"结构性收敛"的最后一步。
存储面同样极小:业务代码不直接触碰 localStorage(实测 0 处直接调用),唯一的持久化经 pinia-plugin-persistedstate 完成,key 为 theme、值为主题字符串(见《04-数据模型》第 2 节)。localStorage 里没有任何可被注入利用的动态数据读取回显路径——插件恢复的值只会被 changeTheme 的 switch 消化,非法值落空即维持现状(《07-模块详解》第 9 节)。
index.html 引入了一个带 defer 的第三方脚本:https://analytics.zhangzhengyang.com/script.js(带 data-website-id,形如自托管 umami 访问统计)。它的安全属性取决于三件事 ⚠️ 待确认(仓库内无该统计服务的服务端代码与配置):该域名的可控性与 HTTPS 健康度、脚本是否变更、是否收集可识别信息。从供应链角度,它是全站唯一在构建产物之外的动态代码来源——CSP 收紧时它是第一个需要决策的对象;从合规角度,公开部署时它涉及隐私政策与 Cookie 声明的评估(《06-部署运行》第 4.2 节已提示部署者自行决定去留)。除此之外 index.html 干净:无其它 SDK、无内联脚本、无 iframe。《03-接口设计》提到的历史第三方脚本(QQ/微信登录、微博分享)已在依赖升级中整体移除(`docs/dependency-upgrade-plan.md"安全现状"第 3 条)。
应用自身的权限边界由 Pexels API 的能力决定,逐条映射:检索类端点(/search、/curated、/videos/*、/collections/featured、/collections/:id、/photos/:id、/videos/videos/:id)仅需 Key;/collections(我的合集)依赖用户级 Pexels 登录态——前端直连方案下请求方是浏览器而非任何已登录会话,绝大多数访客拿不到数据,门面层因此把它的所有失败都归一化为空数组并提示"需要在 Pexels 登录"(《03-接口设计》3.2 节),这实际上意味着"我的合集"在当前架构下是功能性留白而非真实可用的登录能力;liked 字段只是 Pexels 服务端的点赞态透传,应用无任何点赞写入。应用本地不存任何用户数据:无收藏、无历史、无偏好(除主题),因此也不存在"用户数据被提取"的问题——这是无账号架构在安全上的天然红利。
反过来看"应用被滥用"的方向:页面本身无鉴权、无速率限制,任何访客都能驱动前端发起检索请求,消耗的是同一把 Key 的共享配额。公开部署时这构成可用性风险(多个访客合力打爆 200 次/小时),缓解手段全部在服务端(代理层限流、缓存),前端已有的手段(熔断、排队、失活不发请求)只能自保,无法防滥用,详见《10-性能设计与优化》第 4 节。
| 编号 | 问题 | 位置 | 证据 | 影响 | 建议 |
|---|---|---|---|---|---|
| S-01 | 🔴 真实 Key 明文落盘 | .env.development、.env.production | 两文件同一 56 位 Key;dist/assets/pexels-*.js 实测含明文 | 配额盗用、账号风控 | 轮换 + 代理 + 清 dist(第 2.3 节) |
| S-02 | 历史 Key 曾入库 | git 历史 | 提交 c5f41bd 信息自述;现历史已检索不到 env | 旧 Key 视为已泄露 | 已轮换前提下消除;分享仓库前审计历史 |
| S-03 | CSP 缺失 | index.html | grep 计数 0 | 注入防线全靠代码习惯 | 按 4 节基线加 Report-Only 试运行 |
| S-04 | 遗留环境变量类型声明 | src/env.d.ts | VITE_BASE_API、VITE_APP_SECRET 声明仍在 | 误导后来者以为仍有签名链路 | 随下一次清理删除两个声明 |
| S-05 | 遗留 env 值 | .env.development(/api)、.env.production(/prod-api) | VITE_BASE_API 在两文件仍有赋值,代码 0 消费 | 无直接风险,属配置噪声 | 一并删除 |
| S-06 | 遗留类型声明文件 | src/types/vendor.d.ts | 仅为已移除的 md5 依赖声明 | 无风险,死代码 | 删除 |
| S-07 | 第三方统计脚本 | index.html | analytics.zhangzhengyang.com + data-website-id | 供应链与合规评估项 | 部署时决定去留;CSP 决策 |
| S-08 | sourcemap | vite.config.ts | sourcemap: false 已配置 | 无(已防护) | 保持;发布校验时抽查产物无 .map |
| S-09 | 私密合集可见性 | src/views/collections/ | 私密合集仅显示"私密"标签,媒体仍按 id 可拉取 | 无(接口侧能力,前端只读透传) | 属 Pexels 平台侧权限,前端无需处理 |
把上述结论收敛成一张可执行的检查单,与《06-部署运行》第 4.2 节的发布校验清单配合使用:构建产物审计(grep 产物 JS 不含 Key 明文——直连方案必然命中,命中即说明仍处于 S-01 风险中;确认无 .map 文件);env 卫生(git ls-files 无 env 文件;.env.example 之外的 env 不进版本库;VITE_ 变量清单与 src/env.d.ts 声明一致且无死项);传输与外链(统计脚本去留已决策;若加 CSP 先 Report-Only 观察);历史与分发(若仓库要给第三方,先决定是否重写历史或快照重建)。这套检查的本质只有一句话:纯前端应用的安全 = 管好一把 Key + 管住一个 script 标签 + 保持代码的注入纪律,三者在本项目里都有明确的落点与责任人可依。