Cloudflare反向代理如何重写加载后JS注入的动态链接
参考公开的CORSflare配置教程在Cloudflare部署了作为反向代理的Service Worker,用于将非预定义网页嵌入iFrame。页面初始加载阶段代理逻辑运行正常,但无法处理页面加载完成后JavaScript动态注入的链接,这类链接会直接基于代理服务的根路径加载,跳转行为不符合预期:
/path -> myproxy.cloudflare.com/path [错误行为] /path -> myproxy.cloudflare.com?link=query_domain.com/path [预期行为]
需要实现动态生成URL的重写逻辑,匹配预设的代理路由规则。
静态HTML替换只能处理服务端返回的原始内容,无法拦截页面运行时JS生成的DOM、动态请求和跳转,需要从三个层面补全拦截逻辑,三层逻辑互相兜底:
第一层:页面加载最早期注入JS垫片,劫持所有URL相关原生API
在返回HTML响应时,把自执行JS垫片插入到<head>标签的第一个子节点位置,保证垫片在所有业务JS之前运行,从API层面拦截所有URL生成、跳转、请求行为:- 劫持
document.createElement方法,针对a/iframe/img/script/link/form这类携带href/src/actionURL属性的元素,重写对应属性的setter,所有赋值给这些属性的地址都会先经过代理规则转换,再赋值给原生属性。 - 劫持所有跳转相关API:包括
window.location的assign/replace方法、直接给location.href/location.pathname赋值的行为、history.pushState/history.replaceState路由方法,所有触发页面跳转、路由更新的逻辑都先做URL转换再执行原生逻辑。 - 劫持所有动态请求API:重写全局
fetch、XMLHttpRequest.prototype.open方法,所有异步请求的地址也走同样的代理转换规则,避免接口、动态资源请求直接打到代理域名根路径。
垫片核心代码参考:
(function() { // URL转代理格式的核心逻辑,根据自身业务规则调整判断条件 const proxify = (rawUrl) => { if (!rawUrl) return rawUrl; // 跳过特殊协议、已经是代理格式的地址 if (rawUrl.startsWith('?link=') || /^(javascript|mailto|tel):/i.test(rawUrl)) return rawUrl; try { const urlObj = new URL(rawUrl, window.location.href); // 代理域下非代理路径的地址,统一转成link参数格式 if (urlObj.origin === window.location.origin && !urlObj.searchParams.get('link')) { return `?link=${encodeURIComponent(urlObj.pathname + urlObj.search + urlObj.hash)}`; } // 如果是固定代理单个站点,这里补充把目标站点的绝对路径也转成代理格式即可 return rawUrl; } catch(e) { return rawUrl; } } // 劫持元素创建逻辑 const originCreateElement = document.createElement.bind(document); document.createElement = function(tagName) { const el = originCreateElement(tagName); const tag = tagName.toLowerCase(); let urlAttrs = []; if (['a','area'].includes(tag)) urlAttrs = ['href']; if (['iframe','img','script','embed','audio','video','source'].includes(tag)) urlAttrs = ['src']; if (tag === 'link') urlAttrs = ['href']; if (tag === 'form') urlAttrs = ['action']; urlAttrs.forEach(attr => { const originDesc = Object.getOwnPropertyDescriptor(el.__proto__, attr) || Object.getOwnPropertyDescriptor(HTMLElement.prototype, attr); if (!originDesc) return; Object.defineProperty(el, attr, { get: () => originDesc.get.call(el), set: (val) => originDesc.set.call(el, proxify(val)) }); }); return el; } // 劫持跳转API const originAssign = window.location.assign.bind(window.location); window.location.assign = (url) => originAssign(proxify(url)); const originReplace = window.location.replace.bind(window.location); window.location.replace = (url) => originReplace(proxify(url)); ['pushState','replaceState'].forEach(method => { const originMethod = window.history[method].bind(window.history); window.history[method] = (state, title, url) => originMethod(state, title, url ? proxify(url) : url); }); // 劫持动态请求API const originFetch = window.fetch.bind(window); window.fetch = (input, init) => { if (typeof input === 'string') input = proxify(input); if (input instanceof Request) input = new Request(proxify(input.url), input); return originFetch(input, init); } const originXhrOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url, ...rest) { return originXhrOpen.call(this, method, proxify(url), ...rest); } // 启动DOM监听,处理直接通过innerHTML/outerHTML批量插入、没经过createElement的节点 const observer = new MutationObserver((mutations) => { mutations.forEach(mut => { mut.addedNodes.forEach(node => { if (node.nodeType !== 1) return; // 检查当前节点 ['href','src','action'].forEach(attr => { if (node.hasAttribute?.(attr)) node.setAttribute(attr, proxify(node.getAttribute(attr))); }); // 检查所有子节点 node.querySelectorAll?.('a[href], area[href], iframe[src], img[src], script[src], link[href], form[action], embed[src], audio[src], video[src], source[src]').forEach(el => { ['href','src','action'].forEach(attr => { if (el.hasAttribute(attr)) el.setAttribute(attr, proxify(el.getAttribute(attr))); }); }); }); }); }); // 立即启动监听,不需要等DOMContentLoaded observer.observe(document.documentElement, { childList: true, subtree: true }); })();- 劫持
第二层:DOM变更全局监听兜底
上述垫片中已经集成了MutationObserver逻辑,直接监听整个文档树的节点插入,针对通过innerHTML、insertAdjacentHTML等方式批量插入、不经过createElement方法的节点,遍历检查其URL属性并替换,避免漏网的动态链接。第三层:Worker侧路由兜底
在Service Worker的fetch事件处理逻辑里加兜底规则:如果收到的请求是访问代理域名下的非根路径、且URL里没有link参数,直接返回302重定向到/?link=编码后的原请求路径,就算前面两层逻辑有遗漏,也不会出现直接访问代理路径的错误跳转。兜底逻辑参考:
addEventListener('fetch', event => { const reqUrl = new URL(event.request.url); // 非根路径且没有代理参数,自动重定向到正确的代理格式 if (reqUrl.pathname !== '/' && !reqUrl.searchParams.get('link')) { const targetPath = reqUrl.pathname + reqUrl.search + reqUrl.hash; return event.respondWith(Response.redirect( `${reqUrl.origin}/?link=${encodeURIComponent(targetPath)}`, 302 )); } // 原有代理处理逻辑 // ... })
- 注入的垫片必须放在所有业务脚本、外部资源引用之前,否则会出现劫持时机晚于业务脚本执行,部分动态链接已经生成的问题。
- 如果原始站点返回的响应有CSP(内容安全策略)限制,需要在Worker侧修改响应头,放开内联脚本的执行权限,避免垫片被浏览器拦截。
- 如果代理的是固定站点,只需要修改
proxify函数里的判断逻辑,把目标站点的绝对路径也纳入代理转换范围即可。
内容的提问来源于stack exchange,提问作者Chris Jones

