fix: server-side HTML URL rewriting to bypass CSP blocking
Build and Push to GHCR / build-and-push (push) Has been cancelled

JS injection fails on sites with strict Content-Security-Policy
(e.g. GitHub). Add golang.org/x/net/html-based server-side rewriting
of URL attributes (href/src/action/etc.) and CSS url() values before
response is sent. Keep JS injection as fallback for dynamic content.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <[email protected]>
This commit is contained in:
Agent
2026-05-24 17:44:35 +08:00
co-authored by HAPI
parent f03eacaf20
commit f13f294d04
5 changed files with 144 additions and 3 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
module mirror-proxy module mirror-proxy
go 1.23 go 1.25.0
require golang.org/x/net v0.55.0
+2
View File
@@ -0,0 +1,2 @@
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+5 -1
View File
@@ -53,7 +53,7 @@ func DynamicProxy(targetURL string) http.Handler {
resp.Header.Set("Location", rewriteURL(loc, tokenPrefix)) resp.Header.Set("Location", rewriteURL(loc, tokenPrefix))
} }
// 对 HTML 响应注入 JS 脚本,重写链接 // 对 HTML 响应做服务端 URL 重写 + 注入 JS 兜底
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") && resp.Body != nil { if strings.Contains(contentType, "text/html") && resp.Body != nil {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@@ -62,7 +62,11 @@ func DynamicProxy(targetURL string) http.Handler {
} }
resp.Body.Close() resp.Body.Close()
// 1. 服务端重写所有已知 URL 属性(应对 CSP 禁止内联脚本的情况)
body = rewriteHTMLBody(body, tokenPrefix)
// 2. 注入 JS 处理动态添加的内容(无 CSP 时生效)
body = injectTokenPrefixScript(body, tokenPrefix) body = injectTokenPrefixScript(body, tokenPrefix)
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body)) resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
+5 -1
View File
@@ -49,7 +49,7 @@ func NewGitHubProxy() http.Handler {
resp.Header.Set("Location", rewriteURL(loc, tokenPrefix)) resp.Header.Set("Location", rewriteURL(loc, tokenPrefix))
} }
// 对 HTML 响应注入 JS 脚本,拦截链接点击 // 对 HTML 响应做服务端 URL 重写 + 注入 JS 兜底
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "text/html") && resp.Body != nil { if strings.Contains(contentType, "text/html") && resp.Body != nil {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@@ -58,7 +58,11 @@ func NewGitHubProxy() http.Handler {
} }
resp.Body.Close() resp.Body.Close()
// 1. 服务端重写所有已知 URL 属性(应对 CSP 禁止内联脚本的情况)
body = rewriteHTMLBody(body, tokenPrefix)
// 2. 注入 JS 处理动态添加的内容(无 CSP 时生效)
body = injectTokenPrefixScript(body, tokenPrefix) body = injectTokenPrefixScript(body, tokenPrefix)
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body)) resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
+129
View File
@@ -0,0 +1,129 @@
package proxy
import (
"bytes"
"regexp"
"strings"
"golang.org/x/net/html"
)
// rewriteHTMLBody parses HTML and rewrites absolute URLs in known attributes
// so they include the token prefix. This is needed because many sites (e.g.
// GitHub) use Content-Security-Policy that blocks inline scripts, making the
// JS-injection approach unreliable.
func rewriteHTMLBody(body []byte, prefix string) []byte {
if prefix == "" {
return body
}
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return body
}
rewriteNode(doc, prefix)
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
return body
}
return buf.Bytes()
}
// urlAttrs lists element attributes that contain URLs which should be rewritten.
var urlAttrs = []string{
"href",
"src",
"action",
"poster",
"formaction",
"cite",
"longdesc",
"profile",
"background",
"data-url",
"data-href",
}
func rewriteNode(n *html.Node, prefix string) {
if n.Type == html.ElementNode {
for i := range n.Attr {
attr := &n.Attr[i]
if isURLAttr(attr.Key) {
attr.Val = rewriteURL(attr.Val, prefix)
continue
}
// <meta http-equiv="refresh" content="0;url=/path">
if strings.EqualFold(n.Data, "meta") &&
strings.EqualFold(attr.Key, "content") &&
isRefreshMeta(n) {
attr.Val = rewriteRefreshContent(attr.Val, prefix)
}
}
// Rewrite url(...) inside <style> tags.
if strings.EqualFold(n.Data, "style") && n.FirstChild != nil &&
n.FirstChild.Type == html.TextNode {
n.FirstChild.Data = rewriteCSSURLs(n.FirstChild.Data, prefix)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
rewriteNode(c, prefix)
}
}
func isURLAttr(key string) bool {
lower := strings.ToLower(key)
for _, attr := range urlAttrs {
if lower == attr {
return true
}
}
return false
}
func isRefreshMeta(n *html.Node) bool {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, "http-equiv") && strings.EqualFold(a.Val, "refresh") {
return true
}
}
return false
}
var refreshURLRe = regexp.MustCompile(`(?i)(url\s*=\s*)(["']?)(/[^"'>\s;]*)\2`)
func rewriteRefreshContent(content, prefix string) string {
return refreshURLRe.ReplaceAllStringFunc(content, func(m string) string {
matches := refreshURLRe.FindStringSubmatch(m)
if len(matches) < 5 {
return m
}
urlPart := matches[3]
rewritten := rewriteURL(urlPart, prefix)
if rewritten == urlPart {
return m
}
return matches[1] + matches[2] + rewritten + matches[2]
})
}
// cssURLRe matches url(/path), url("/path"), url('/path').
var cssURLRe = regexp.MustCompile(`(?i)(url\s*\(\s*)(["']?)(/[^"')\s]*)\2(\s*\))`)
func rewriteCSSURLs(css, prefix string) string {
return cssURLRe.ReplaceAllStringFunc(css, func(m string) string {
matches := cssURLRe.FindStringSubmatch(m)
if len(matches) < 5 {
return m
}
urlPart := matches[3]
rewritten := rewriteURL(urlPart, prefix)
if rewritten == urlPart {
return m
}
return matches[1] + matches[2] + rewritten + matches[2] + matches[4]
})
}