package proxy import ( "encoding/base64" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" ) // hopHeaders are headers that should not be forwarded by proxies. var hopHeaders = map[string]bool{ "Connection": true, "Keep-Alive": true, "Proxy-Authenticate": true, "Proxy-Authorization": true, "Te": true, "Trailers": true, "Transfer-Encoding": true, "Upgrade": true, } // antiFrameHeaders are headers we strip to allow iframe embedding. var antiFrameHeaders = []string{ "X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "X-Content-Type-Options", } // forwardHeaders are request headers we copy from the client to the upstream. // NOTE: Accept-Encoding is intentionally omitted so Go's Transport handles // compression transparently (adds gzip, auto-decompresses response body). // This ensures we can do text replacements on HTML/CSS bodies. var forwardHeaders = []string{ "User-Agent", "Accept", "Accept-Language", "Cookie", "Referer", "Range", "If-None-Match", "If-Modified-Since", "Cache-Control", } // jsShimTemplate is injected into HTML responses to intercept JS-initiated requests. // It patches fetch, XMLHttpRequest, WebSocket, and EventSource to route through the proxy. const jsShimTemplate = `` // NewHandler returns an http.Handler that serves the reverse proxy at /proxy/. // URL structure: /proxy/{base64_origin}/{path...} func NewHandler() http.Handler { client := &http.Client{ Timeout: 30 * 1000000000, // 30s CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse // don't follow redirects }, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Parse: /proxy/{base64_origin}/{path...} trimmed := strings.TrimPrefix(r.URL.Path, "/proxy/") if trimmed == "" || trimmed == r.URL.Path { http.Error(w, "bad proxy URL", http.StatusBadRequest) return } // Split into base64 segment and remaining path slashIdx := strings.Index(trimmed, "/") var b64Origin, pathAndQuery string if slashIdx == -1 { b64Origin = trimmed pathAndQuery = "/" } else { b64Origin = trimmed[:slashIdx] pathAndQuery = trimmed[slashIdx:] } originBytes, err := base64.RawURLEncoding.DecodeString(b64Origin) if err != nil { // Try standard encoding with padding originBytes, err = base64.StdEncoding.DecodeString(b64Origin) if err != nil { http.Error(w, "invalid base64 origin", http.StatusBadRequest) return } } origin := string(originBytes) // Validate origin is a valid URL originURL, err := url.Parse(origin) if err != nil || (originURL.Scheme != "http" && originURL.Scheme != "https") { http.Error(w, "invalid origin URL", http.StatusBadRequest) return } // Build upstream URL targetURL := origin + pathAndQuery if r.URL.RawQuery != "" { targetURL += "?" + r.URL.RawQuery } log.Printf("proxy: %s %s -> %s", r.Method, r.URL.Path, targetURL) // Create upstream request upReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) if err != nil { http.Error(w, "failed to create request", http.StatusInternalServerError) return } // Copy selected headers for _, h := range forwardHeaders { if v := r.Header.Get(h); v != "" { upReq.Header.Set(h, v) } } // Reconstruct the original Referer from the client's proxy-rewritten Referer. // The client sends e.g. "https://f1.viktorbarzin.me/proxy/{b64origin}/path" // and we need to decode that back to "https://original.com/path". upReq.Header.Set("Referer", decodeProxyReferer(r.Header.Get("Referer"), origin)) // Fetch upstream resp, err := client.Do(upReq) if err != nil { log.Printf("proxy: upstream fetch failed: %v", err) http.Error(w, "upstream fetch failed", http.StatusBadGateway) return } defer resp.Body.Close() log.Printf("proxy: %s %s <- %d (%s)", r.Method, r.URL.Path, resp.StatusCode, resp.Header.Get("Content-Type")) // Handle redirects: rewrite Location header through proxy if resp.StatusCode >= 300 && resp.StatusCode < 400 { loc := resp.Header.Get("Location") if loc != "" { rewritten := rewriteRedirect(loc, origin, b64Origin) w.Header().Set("Location", rewritten) log.Printf("proxy: redirect %s -> %s", loc, rewritten) } w.WriteHeader(resp.StatusCode) return } // Copy response headers, stripping anti-frame, hop-by-hop, and encoding headers. // Content-Encoding is stripped because Go's Transport already decompressed the body. // Content-Length is stripped because we may rewrite the body (changing its length). for key, vals := range resp.Header { if hopHeaders[key] { continue } if strings.EqualFold(key, "Content-Encoding") || strings.EqualFold(key, "Content-Length") { continue } skip := false for _, ah := range antiFrameHeaders { if strings.EqualFold(key, ah) { skip = true break } } if skip { continue } for _, v := range vals { w.Header().Add(key, v) } } // Add permissive CORS headers so cross-origin XHR/fetch from the iframe works w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") w.WriteHeader(resp.StatusCode) // For HTML responses, rewrite URLs and inject JS shim ct := resp.Header.Get("Content-Type") if strings.Contains(ct, "text/html") { body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("proxy: failed to read HTML body: %v", err) return } rewritten := rewriteHTML(string(body), origin, b64Origin) w.Write([]byte(rewritten)) return } // For CSS responses, rewrite url() references if strings.Contains(ct, "text/css") { body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("proxy: failed to read CSS body: %v", err) return } rewritten := rewriteCSS(string(body), origin, b64Origin) w.Write([]byte(rewritten)) return } // For JavaScript responses, strip debugger statements if strings.Contains(ct, "javascript") || strings.Contains(ct, "ecmascript") { body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("proxy: failed to read JS body: %v", err) return } cleaned := debuggerStmtRe.ReplaceAllString(string(body), "/* */") w.Write([]byte(cleaned)) return } // Stream other responses directly io.Copy(w, resp.Body) }) } // rewriteRedirect rewrites a Location header value to route through the proxy. func rewriteRedirect(loc, origin, b64Origin string) string { // Absolute URL on the same origin if strings.HasPrefix(loc, origin) { path := strings.TrimPrefix(loc, origin) return "/proxy/" + b64Origin + path } // Absolute URL on a different origin — proxy it too parsed, err := url.Parse(loc) if err != nil { return loc } if parsed.IsAbs() { newOrigin := parsed.Scheme + "://" + parsed.Host newB64 := base64.RawURLEncoding.EncodeToString([]byte(newOrigin)) return "/proxy/" + newB64 + parsed.RequestURI() } // Relative URL — it will resolve naturally return loc } // Precompiled regexes for root-relative URL rewriting in HTML attributes. // Matches src="/...", href="/...", action="/...", poster="/..." but NOT "//..." (protocol-relative). var rootRelativeAttrRe = regexp.MustCompile(`((?:src|href|action|poster|data)\s*=\s*["'])/([^/"'][^"']*)`) // Matches url("/...") or url('/...') or url(/...) in inline styles — but NOT url("//...") var rootRelativeCSSRe = regexp.MustCompile(`(url\(\s*["']?)/([^/"')[^"')]*)(["']?\s*\))`) // disableDevtoolRe matches )?`) // adScriptRe matches )?`) // adInlineRe matches inline `) // contextMenuBlockRe matches inline scripts that block right-click and dev tools shortcuts. var contextMenuBlockRe = regexp.MustCompile(`(?i)]*>\s*document\.addEventListener\(\s*'contextmenu'[\s\S]{0,500}?`) // debuggerStmtRe matches debugger statements in JavaScript. var debuggerStmtRe = regexp.MustCompile(`\bdebugger\b\s*;?`) // rewriteHTML replaces URLs and injects the JS shim to intercept runtime requests. func rewriteHTML(body, origin, b64Origin string) string { proxyPrefix := "/proxy/" + b64Origin // 1. Rewrite absolute URLs matching the target origin escaped := regexp.QuoteMeta(origin) absRe := regexp.MustCompile(escaped + `(/[^"'\s>)]*)?`) body = absRe.ReplaceAllStringFunc(body, func(match string) string { path := strings.TrimPrefix(match, origin) if path == "" { path = "/" } return proxyPrefix + path }) // 2. Rewrite root-relative URLs in HTML attributes (src="/...", href="/...", etc.) // Skip URLs already rewritten by step 1 (starting with /proxy/) body = rootRelativeAttrRe.ReplaceAllStringFunc(body, func(match string) string { m := rootRelativeAttrRe.FindStringSubmatch(match) if len(m) < 3 { return match } // m[2] is the path after the leading "/", skip if already proxied if strings.HasPrefix(m[2], "proxy/") { return match } return m[1] + proxyPrefix + "/" + m[2] }) // 3. Rewrite root-relative URLs in inline CSS url() references // Skip URLs already rewritten by step 1 (starting with /proxy/) body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string { m := rootRelativeCSSRe.FindStringSubmatch(match) if len(m) < 4 { return match } if strings.HasPrefix(m[2], "proxy/") { return match } return m[1] + proxyPrefix + "/" + m[2] + m[3] }) // 4. Strip anti-debugging scripts (disable-devtool, devtools-detect) body = disableDevtoolRe.ReplaceAllString(body, "") // 4b. Strip ad/popup scripts and context menu blockers body = adScriptRe.ReplaceAllString(body, "") body = adInlineRe.ReplaceAllString(body, "") body = contextMenuBlockRe.ReplaceAllString(body, "") // 4c. Strip debugger statements from inline scripts body = debuggerStmtRe.ReplaceAllString(body, "/* */") // 5. Inject JS shim right after to intercept fetch/XHR/WebSocket shim := fmt.Sprintf(jsShimTemplate, b64Origin, origin) headIdx := strings.Index(strings.ToLower(body), "") if headIdx != -1 { insertPos := headIdx + len("") body = body[:insertPos] + shim + body[insertPos:] } else { // No tag — prepend to body body = shim + body } return body } // rewriteCSS replaces root-relative url() references in CSS to route through the proxy. func rewriteCSS(body, origin, b64Origin string) string { proxyPrefix := "/proxy/" + b64Origin // Rewrite absolute URLs matching origin escaped := regexp.QuoteMeta(origin) absRe := regexp.MustCompile(escaped + `(/[^"'\s)]*)?`) body = absRe.ReplaceAllStringFunc(body, func(match string) string { path := strings.TrimPrefix(match, origin) if path == "" { path = "/" } return proxyPrefix + path }) // Rewrite root-relative url() references, skip already-proxied body = rootRelativeCSSRe.ReplaceAllStringFunc(body, func(match string) string { m := rootRelativeCSSRe.FindStringSubmatch(match) if len(m) < 4 { return match } if strings.HasPrefix(m[2], "proxy/") { return match } return m[1] + proxyPrefix + "/" + m[2] + m[3] }) return body } // decodeProxyReferer takes the client's Referer (which points to a proxy URL) // and decodes it back to the original upstream URL. This is critical for // cross-origin requests where the upstream checks the Referer (e.g. HLS servers). // Falls back to origin+"/" if decoding fails. func decodeProxyReferer(clientReferer, fallbackOrigin string) string { if clientReferer == "" { return fallbackOrigin + "/" } // Find /proxy/ in the Referer URL path idx := strings.Index(clientReferer, "/proxy/") if idx == -1 { return fallbackOrigin + "/" } // Extract everything after /proxy/ rest := clientReferer[idx+len("/proxy/"):] if rest == "" { return fallbackOrigin + "/" } // Split into base64 segment and remaining path slashIdx := strings.Index(rest, "/") var b64Seg, pathPart string if slashIdx == -1 { b64Seg = rest pathPart = "/" } else { b64Seg = rest[:slashIdx] pathPart = rest[slashIdx:] } // Decode the base64 origin originBytes, err := base64.RawURLEncoding.DecodeString(b64Seg) if err != nil { originBytes, err = base64.StdEncoding.DecodeString(b64Seg) if err != nil { return fallbackOrigin + "/" } } return string(originBytes) + pathPart }