fix: server-side HTML URL rewriting to bypass CSP blocking
Build and Push to GHCR / build-and-push (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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]
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user