Nuxt3 项目部署 Nginx 防盗链后特定 JS 文件 403 问题修复方案
问题描述
Nuxt3 前端项目打包部署到配置了防盗链规则的 Nginx 服务器后,浏览器访问页面时部分 JavaScript 文件返回 403 Forbidden
受影响的文件
以下 JS 文件在浏览器中均返回 403:
bash
/_nuxt/DApBM9Zf.js → pages/index/[lang]/productcenter.vue
/_nuxt/ByoahgwI.js → pages/index/[lang]/productcenter/sishan.vue
/_nuxt/teKmI3we.js → sishan 页面的 CSS chunk
/_nuxt/GsJ1Y5_8.js → pages/index/[lang]/foodcenter.vue
/_nuxt/BB-wqL4x.js → pages/index/[lang]/aboutus.vue
/_nuxt/C1eDSpvq.js → pages/index/[lang]/aboutus/company.vue
/_nuxt/BEHuFA0U.js → pages/index/[lang]/contactus.vue
/_nuxt/BhVIYuKy.js → pages/index/[lang]/productcenter/sishanfeature.vue
/_nuxt/C9OvFcjC.js → pages/index/[lang]/productcenter/sishanmax.vue
环境信息
| 项目 | 说明 |
|---|---|
| 框架 | Nuxt 3.13.0 |
| 构建工具 | Vite (Rollup) |
| 部署架构 | CDN(含WAF) → Nginx → Nuxt Node.js SSR |
| Nginx 防盗链配置 | valid_referers *.域名.com 域名.com localhost; |
根因分析
1. Nginx 防盗链规则缺少 none
项目 Nginx 配置中的防盗链规则:
nginx
location ~* .*\.(gif|jpg|ico|png|css|js|swf|flv|mp4|webp)$ {
valid_referers *.域名.com 域名.com localhost;
# ↑ 缺少 none
if ($invalid_referer) {
return 403;
}
proxy_pass http://localhost:3000;
}
valid_referers 中没有 none 关键字,意味着没有 Referer 头的请求会被直接拦截返回 403。
2. Nuxt3 构建产物中 JS 文件有两种加载方式
通过分析构建产物和 Nginx 访问日志,发现 JS 文件有两种加载方式,其 Referer 行为截然不同:
方式一:<link rel="modulepreload/prefetch"> 标签(返回 200)
Nuxt3 对当前路由直接依赖的模块,在 SSR 渲染的 HTML 中生成 <link> 标签:
html
<link rel="modulepreload" as="script" crossorigin href="/_nuxt/DYp9iSB5.js">
<link rel="modulepreload" as="script" crossorigin href="/_nuxt/CXwAfdS5.js">
浏览器通过 <link> 标签加载资源时始终携带 Referer 头(值为当前页面 URL),因此不会被防盗链拦截。
方式二:import() 动态加载(返回 403)
Nuxt3 对非当前路由的页面组件使用 Vite 的代码分割功能,生成独立的动态 chunk。当用户通过 NuxtLink 导航到新路由时,才通过 import() 动态加载:
javascript
// DYp9iSB5.js 中的路由定义
component: () => Cn(
() => import("./DApBM9Zf.js"), // productcenter 页面
__vite__mapDeps([45, 11, 8, 1, 2, 3, 13, 46]),
import.meta.url
)
浏览器对 import() 动态加载的请求不发送 Referer 头,触发防盗链规则返回 403。
3. Nginx Access Log 证据
| 请求文件 | Referer | 状态码 | 加载方式 |
|---|---|---|---|
/_nuxt/DYp9iSB5.js |
http://localhost/zh |
200 | <link rel="modulepreload"> |
/_nuxt/CXwAfdS5.js |
http://localhost/zh |
200 | <link rel="modulepreload"> |
/_nuxt/DApBM9Zf.js |
-(空) |
403 | import() 动态加载 |
/_nuxt/ByoahgwI.js |
-(空) |
403 | import() 动态加载 |
/_nuxt/BEHuFA0U.js |
-(空) |
403 | import() 动态加载 |
问题链路总结
scss
Nuxt3 构建过程
→ 页面路由组件被 Vite 代码分割为独立 chunk (isDynamicEntry)
→ 非当前路由的页面组件不在 HTML 的 <link rel="modulepreload/prefetch"> 列表中
→ 用户通过 NuxtLink 导航时,Nuxt3 用 import() 动态加载这些 chunk
→ 浏览器对 import() 请求不发送 Referer 头
→ Nginx 防盗链规则 valid_referers 缺少 none,拦截无 Referer 请求
→ 返回 403
修复方案
步骤一:Nitro Plugin 注入 Modulepreload
在 HTML 渲染时,将所有页面路由组件以 <link rel="modulepreload"> 注入到 <head> 中。浏览器对 <link> 标签加载的资源始终携带 Referer,从而绕过防盗链限制。后续 import() 时浏览器直接从缓存读取,不再发起新请求。
实现文件
server/plugins/preload-all-routes.js
javascript
/**
* Nitro Plugin: 预加载所有页面路由组件
*
* 问题根因:
* Nuxt3 对非当前路由的页面组件使用 import() 动态加载,
* 浏览器对 import() 请求不发送 Referer 头,
* nginx 防盗链规则 (valid_referers 缺少 none) 会拦截无 Referer 的请求返回 403。
*
* 解决方案:
* 在 HTML 渲染时,将所有页面路由组件以 <link rel="modulepreload"> 注入到 <head> 中,
* 浏览器对 <link> 标签加载的资源始终携带 Referer,从而绕过防盗链限制。
*/
export default defineNitroPlugin((nitroApp) => {
let cachedManifest = null
async function getManifest() {
if (cachedManifest) return cachedManifest
try {
// 通过 Nitro 虚拟模块导入 manifest,开发和生产环境均可正确解析
const mod = await import('#build/dist/server/client.manifest.mjs')
cachedManifest = mod.default || mod
if (typeof cachedManifest === 'function') cachedManifest = await cachedManifest()
return cachedManifest
} catch (e) {
console.warn('[preload-all-routes] Failed to import client manifest:', e.message)
return null
}
}
nitroApp.hooks.hook('render:html', async (html, { event }) => {
const manifest = await getManifest()
if (!manifest || !html.head) return
const runtimeConfig = useRuntimeConfig(event)
const buildAssetsDir = runtimeConfig.app?.buildAssetsDir
? `/${runtimeConfig.app.buildAssetsDir}`.replace(/\/+/g, '/').replace(/\/$/, '') + '/'
: '/_nuxt/'
const preloadLinks = []
// 收集已有的 preload 文件名,避免重复注入
const existingFiles = new Set()
for (const h of html.head) {
const match = h.match(/href="[^"]*\/([^/"]+\.js)"/)
if (match) existingFiles.add(match[1])
}
// 遍历 manifest,找到所有页面路由动态 chunk
for (const [key, entry] of Object.entries(manifest)) {
if (
entry.isDynamicEntry &&
entry.resourceType === 'script' &&
!existingFiles.has(entry.file)
) {
const href = buildAssetsDir + entry.file
preloadLinks.push(
`<link rel="modulepreload" as="script" crossorigin href="${href}">`
)
existingFiles.add(entry.file)
// 同时注入该 chunk 的依赖(imports)
if (entry.imports && Array.isArray(entry.imports)) {
for (const importKey of entry.imports) {
const importEntry = manifest[importKey] || manifest[`_${importKey}`]
if (importEntry && importEntry.file && !existingFiles.has(importEntry.file)) {
const importHref = buildAssetsDir + importEntry.file
preloadLinks.push(
`<link rel="modulepreload" as="script" crossorigin href="${importHref}">`
)
existingFiles.add(importEntry.file)
}
}
}
}
}
if (preloadLinks.length > 0) {
html.head.push(...preloadLinks)
}
})
})
工作原理
- 通过 Nitro 虚拟模块
#build/dist/server/client.manifest.mjs导入 Vite 构建生成的 manifest,获取所有 chunk 的映射关系(开发和生产环境均可正确解析) - 在
render:html异步钩子中,遍历 manifest 找到所有isDynamicEntry的 JS chunk(即页面路由组件) - 通过
useRuntimeConfig(event)获取buildAssetsDir配置,确保资源路径正确 - 检查 HTML
<head>中是否已存在该文件的 preload 声明,避免重复注入 - 将缺失的动态 chunk 以
<link rel="modulepreload">注入,同时注入其依赖链 - 浏览器通过
<link>标签预加载这些资源(携带 Referer),后续import()直接命中缓存
步骤二:启用 Prerender 爬取链接
在步骤一的基础上,启用 crawlLinks 选项,使 Nuxt 在构建时自动发现并预渲染可爬取的页面为静态 HTML,进一步减少运行时 SSR 的压力。
配置修改
nuxt.config.js
javascript
nitro: {
prerender: {
crawlLinks: true, // 自动遍历链接,发现可预渲染的页面
failOnError: false,
},
server: {
host: '0.0.0.0',
},
}
说明 :
crawlLinks: true让 Nuxt 在预渲染时自动爬取页面中的链接,无需手动枚举所有路由。jobdetail/[id]和newsdetail/[id]等动态路由因参数依赖 API 数据,不会被自动爬取,仍由运行时 SSR 处理。
验证结果
修复前
通过 Nginx 访问,所有页面路由组件返回 403:
perl
DApBM9Zf.js (no referer) => Forbidden
ByoahgwI.js (no referer) => Forbidden
BEHuFA0U.js (no referer) => Forbidden
修复后
HTML 中已注入所有动态 chunk 的 modulepreload 声明:
ini
DApBM9Zf modulepreload => True
ByoahgwI modulepreload => True
BEHuFA0U modulepreload => True
GsJ1Y5_8 modulepreload => True
BB-wqL4x modulepreload => True
C1eDSpvq modulepreload => True
BhVIYuKy modulepreload => True
C9OvFcjC modulepreload => True
浏览器通过 <link> 标签加载时携带 Referer,Nginx 防盗链放行,返回 200。
补充说明:其他可选方案(未采用)
方案 A:修改 Nginx 防盗链规则
在 valid_referers 中添加 none,允许无 Referer 的请求通过:
nginx
valid_referers none *.your-domain.com your-domain.com localhost;
# ^^^^
或仅对媒体资源启用防盗链,JS/CSS 不做限制:
nginx
location ~* .*\.(gif|jpg|ico|png|svg|swf|flv|mp4|webp)$ {
valid_referers none *.your-domain.com your-domain.com localhost;
if ($invalid_referer) { return 403; }
}
未采用原因:用户明确要求从构建层面解决问题,不修改 Nginx 配置。且生产环境的 CDN/WAF 配置可能不受前端团队控制。
方案 B:修改 Vite 构建配置修改文件路径
将 buildAssetsDir 从 /_nuxt/ 改为 /assets/,并在 Nginx 中为 /assets/ 配置独立的 location 规则跳过防盗链。
未采用原因 :本质上仍是依赖 Nginx 配置变更,且未解决 import() 不携带 Referer 的根本问题。
涉及文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
server/plugins/preload-all-routes.js |
新增 | Nitro 插件,通过 Nitro 虚拟模块导入 manifest,注入所有动态 chunk 的 modulepreload |
nuxt.config.js → nitro.prerender |
修改 | 启用 crawlLinks 自动爬取预渲染路由,配置 server.host 为 0.0.0.0 |