现在正文里的外链会先进入一个确认页,展示目标域名、连接状态和证书信息,再决定要不要继续。它不是安全边界,只是一道减速带。
起因
昨天看到优世界的这篇文章,里面提到外链被利用、备案域名被拿去做跳板之类的问题。看完之后有点坐不住。
我的域名也是备案的。以前总觉得“文章里的链接都是自己放的,能有什么问题”,但实际情况要复杂得多:
- 旧文章里的链接会越来越多,目标站点可能过期、换域名,甚至被劫持
- 转载、友链、评论里的地址,不一定都经过我确认
- 读者点出去之后,很难再解释“这个页面不是我的”
静态博客没有服务端可以做统一跳转,这确实是个限制。Vercel 有 Edge Function,Cloudflare 有 Worker,但为了一个确认页再引入一套运行时,我暂时不太想折腾。所以先做了一个折中方案:离开之前,多问一句。
做法:一个页面,加一次拦截
目标很简单:
- 正文里的站外链接先进入
/jump - 页面展示目标域名、完整地址和连接状态
- 用户确认后再打开,默认新标签页
- 只接受
http/https,拒绝伪协议和带账号密码的 URL - 不额外引入后端,检测接口复用 Nuxt 的 server route
跳转页
/jump?target=...,target 同时支持明文 URL 和 Base64URL。后者主要是避免一些平台或统计工具把参数拆得很难看。
function decodeTarget(raw: string) {
const value = raw.trim()
if (!value)
return ''
if (/^https?:\/\//i.test(value))
return value
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
return new TextDecoder().decode(bytes).trim()
}
校验只认 http/https,并且不允许 URL 里带用户名密码:
const isValid = computed(() => {
const url = parsedTarget.value
return !!url
&& (url.protocol === 'http:' || url.protocol === 'https:')
&& !url.username
&& !url.password
})
页面本身是普通 Vue 页面,关掉默认布局,卡片居中。目标域名单独显示,完整 URL 可以一键复制;继续访问用 target="_blank",并带上 rel="noopener noreferrer nofollow"。
正文外链拦截
没有去改 Markdown 渲染器,而是在客户端加了一个全局 click 监听:
const anchor = (event.target as Element).closest('a')
if (!anchor?.closest('#main-content'))
return
if (anchor.dataset.noJump !== undefined || anchor.hasAttribute('download'))
return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return
if (!isExternal(url))
return
const from = `${location.pathname}${location.search}${location.hash}`
event.preventDefault()
router.push(`/jump?target=${encodeTarget(url.href)}&from=${encodeURIComponent(from)}`)
只拦 #main-content, .blog-footer 里的链接,正文和页脚会进入确认页,侧栏和导航保持原生行为;按住 Ctrl/Cmd 或中键仍然按浏览器默认行为打开。需要放行的链接,加一个 data-no-jump 就行。
返回按钮也踩过一个坑:不要用 history.back()。外链如果在新标签页打开,历史栈并不可靠,返回时可能只渲染出页脚。现在会把来源路径放进 from,返回时用 window.location.replace() 整页回到来源地址;没有来源时才回首页。
顺手加的目标站点检测
确认页如果只写一句“这是外链”,信息量其实不够。既然博客在 Vercel 上本来就有 Nitro server route,就顺手做了一个 /api/link-check,让页面在打开时自动检测目标站点:
- 只允许
http/https和 80/443 标准端口 - 拒绝 localhost、内网 IP,并在真正连接前再次校验 DNS 解析结果,防止 DNS rebinding
- 8 秒超时,最多跟随 4 次重定向
- 返回状态码、首字节延迟、HTTPS/HSTS、证书有效期和重定向链
- 按来源限流 12 次/分钟,结果缓存 5 分钟,避免被当成探测工具
页面这边只负责展示:
const data = await $fetch('/api/link-check', {
query: { target: target.value },
timeout: 12000,
})
检测中显示骨架屏,完成后给出“连接安全 / 需要留意 / 存在风险”的状态、延迟、HTTPS 和证书剩余天数;失败时显示具体原因,比如域名解析失败、连接超时或证书过期。
它只能说明目标站点能不能连上、证书是否有效、有没有异常跳转,不能判断页面里写了什么。真正的内容风险,还是得靠自己判断。
踩到的坑:预渲染把 query 吃掉了
第一次构建完,直接打开 /jump?target=...,页面显示“链接不可用”;但在站内点进去,hydration 之后又正常。
原因是 nuxt.config.ts 里开了 prerender.crawlLinks,/jump 被当成普通页面预渲染成静态 HTML。之后所有带 query 的请求都命中这份没有 target 的静态页,服务端渲染自然拿不到参数。
修复只有一行:
routeRules: {
'/jump': { prerender: false },
}
这也是做静态博客时很容易忽略的一点:只要页面依赖 query,就不能让它进预渲染。
它能做什么,不能做什么
能做的:
- 让读者在离开前看到目标域名,减少误点
- 统一处理正文里的外链,不用逐篇改 Markdown
- 对
javascript:、伪协议、带凭据的 URL 做基础过滤 - 展示连接层状态,至少能提前发现域名失效、超时、证书过期
- 不额外引入服务,检测接口跟站点一起部署
不能做的:
- 不能阻止用户继续访问,确认页只是提示
- 不能判断目标页面的内容是否可信
- 不能替代服务端 302、
Referrer-Policy和长期的链接巡检 - JS 被禁用时拦截不生效,链接仍然可以直接打开
所以它更像一道减速带,而不是安全边界。
后面还想做的
- 外链白名单:常用站点直接放行,不弹确认
- 链接巡检:定期检查文章里的外链状态,失效的标出来
- 点击统计:看看哪些外链真的有人点
- 真正的 302 中间页:用 Nitro route 在服务端做跳转,顺便控制 Referrer
现在先这样。静态博客有静态博客的限制,但在限制里把能做的做好,也比什么都不做好。
评论区
评论加载中...