Files
kandev/apps/backend/internal/gateway/websocket/proxy_rewriter.go
T

318 lines
14 KiB
Go

package websocket
import (
"bytes"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"golang.org/x/net/html"
)
// URL attributes that may carry a path-absolute reference and therefore need
// the proxy path prefix when the document is served through the port proxy.
// Source: https://html.spec.whatwg.org/multipage/indices.html#attributes-3
var rewritableURLAttrs = map[string]bool{
"href": true,
"src": true,
"action": true,
"formaction": true,
"cite": true,
"data": true,
"poster": true,
"background": true,
"manifest": true,
}
// styleTagName is the HTML element name we treat as a raw-text CSS body so we
// can pipe its contents through `rewriteCSSFragment`.
const styleTagName = "style"
// scriptTagName is a raw-text element per the HTML spec — its contents are
// not HTML-escaped. The golang.org/x/net/html tokenizer correctly returns the
// raw bytes in `Token.Data`, but `Token.String()` blindly HTML-escapes any
// text token's `Data`, which corrupts inline JS containing characters like
// `&` (e.g. `x & 1`, `a && b`, JSON with `&` in strings). We therefore emit
// `<script>` bodies via the raw `Data` directly. We don't rewrite anything
// inside a script body — the runtime shim handles network-facing APIs at
// runtime, which is more reliable than trying to parse JS statically.
const scriptTagName = "script"
// headTagName is the HTML element after whose opening tag we inject the proxy
// runtime shim, so it installs `fetch`/`XHR`/`WebSocket` overrides before any
// user JS can run.
const headTagName = "head"
// runtimeShimTemplate is the JS bootstrap injected at the top of `<head>` for
// every HTML response we proxy. It overrides the network-facing browser APIs
// (fetch, XMLHttpRequest, WebSocket) plus the URL-mutating navigation APIs
// (history.pushState/replaceState, location.assign/replace) so root-absolute
// URLs requested at runtime — e.g. Next.js dynamic chunk imports, fetch calls,
// HMR WebSocket, and SPA-router pushState transitions — stay on the same proxy
// chain instead of escaping to the host origin.
//
// %q is replaced per-response with the proxy path (no trailing slash) using
// `fmt.Sprintf`, which emits a JS-safe double-quoted string literal.
//
// Concatenated across multiple Go strings only for readability; the resulting
// JS is still a single self-invoking expression with no internal whitespace.
const runtimeShimTemplate = `<script>(function(){` +
`var P=%q;` +
`window.__kandevProxyPrefix=P;` +
// Path rewriter: prefix path-absolute URLs that aren't already prefixed.
`function r(u){if(typeof u!=='string')return u;if(!u||u.charAt(0)!=='/'||(u.length>1&&u.charAt(1)==='/'))return u;if(u.indexOf(P)===0)return u;return P+u;}` +
// fetch — string and Request-object input forms.
`var of=window.fetch;if(of){window.fetch=function(i,n){if(typeof i==='string')i=r(i);else if(i&&typeof i==='object'&&typeof i.url==='string'){var nu=r(i.url);if(nu!==i.url){try{i=new Request(nu,i)}catch(e){}}}return of.call(this,i,n)}}` +
// XMLHttpRequest.open — 2nd arg is the URL.
`var oo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){arguments[1]=r(u);return oo.apply(this,arguments)};` +
// WebSocket — path-absolute ws/wss URLs need an explicit ws:// scheme + host since the constructor doesn't accept bare paths.
`var OW=window.WebSocket;if(OW){function W(u,p){if(typeof u==='string'&&u.charAt(0)==='/'&&(u.length<2||u.charAt(1)!=='/')){var l=window.location;u=(l.protocol==='https:'?'wss:':'ws:')+'//'+l.host+P+u}return p?new OW(u,p):new OW(u)}W.prototype=OW.prototype;Object.getOwnPropertyNames(OW).forEach(function(k){try{W[k]=OW[k]}catch(e){}});window.WebSocket=W}` +
// history.pushState / replaceState — SPA routers (Next.js, React Router, etc.) call these to change the URL on client-side navigation. Without rewriting, the URL bar drops the proxy prefix and a reload 404s.
`['pushState','replaceState'].forEach(function(op){var orig=history[op];if(!orig)return;history[op]=function(s,t,u){if(typeof u==='string')u=r(u);return orig.call(this,s,t,u)}});` +
// location.assign / location.replace — direct navigation APIs. (Assigning to location.href cannot be intercepted on a same-origin window without redefining a non-configurable property, so we don't try; pushState covers the common SPA-router path.)
`['assign','replace'].forEach(function(op){var orig=location[op];if(!orig)return;try{location[op]=function(u){if(typeof u==='string')u=r(u);return orig.call(location,u)}}catch(e){}});` +
// MutationObserver: rewrite root-absolute URL attributes on every element that is inserted or has its URL attribute mutated. Covers the cases the network-API patches miss, notably `ReactDOM.preload()` and any framework that builds DOM nodes with absolute paths after the initial HTML has been parsed.
`var ATTRS=['href','src','action','formaction','cite','data','poster','background','manifest','srcset'];` +
`function rwa(el,a){if(!el.getAttribute||!el.hasAttribute(a))return;var v=el.getAttribute(a);if(typeof v!=='string')return;var nv;if(a==='srcset'){nv=v.split(',').map(function(p){var f=p.trim().split(/\s+/);if(f[0])f[0]=r(f[0]);return f.join(' ')}).join(', ')}else{nv=r(v)}if(nv!==v)el.setAttribute(a,nv)}` +
`function rwe(el){if(!el||el.nodeType!==1)return;for(var i=0;i<ATTRS.length;i++)rwa(el,ATTRS[i])}` +
`var MO=window.MutationObserver;if(MO&&document.documentElement){try{new MO(function(rs){for(var i=0;i<rs.length;i++){var rec=rs[i];if(rec.type==='attributes'){rwe(rec.target)}else{for(var j=0;j<rec.addedNodes.length;j++){var n=rec.addedNodes[j];rwe(n);if(n.querySelectorAll){var nl=n.querySelectorAll('[href],[src],[action],[srcset],[poster]');for(var k=0;k<nl.length;k++)rwe(nl[k])}}}}}).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:ATTRS})}catch(e){}}` +
// Console forwarding: pipe iframe console output back to the parent frame via postMessage so it surfaces in the kandev UI alongside other preview events. Errors and stacks are coerced to strings; objects are JSON-cloned where possible. We continue calling the original method so the iframe's own DevTools still shows everything.
`var LV=['log','warn','error','info','debug'];LV.forEach(function(lv){var orig=console[lv];if(!orig)return;console[lv]=function(){try{var out=[];for(var i=0;i<arguments.length;i++){var a=arguments[i];if(a instanceof Error){out.push('Error: '+a.message+(a.stack?'\n'+a.stack:''))}else if(typeof a==='object'&&a!==null){try{out.push(JSON.parse(JSON.stringify(a)))}catch(e){out.push(String(a))}}else{out.push(a)}}window.parent.postMessage({source:'kandev-inspector',type:'console',payload:{level:lv,args:out}},'*')}catch(e){}return orig.apply(console,arguments)}});` +
`})();</script>`
// runtimeShim returns the runtime shim script tag with the given proxy prefix
// baked in. %q produces a JS-safe double-quoted string literal (slashes and
// alphanumerics need no escaping, which matches every prefix we emit).
func runtimeShim(prefix string) string {
return fmt.Sprintf(runtimeShimTemplate, prefix)
}
// urlInCSSPattern matches `url(...)` invocations in CSS where the argument is
// a path-absolute URL we want to rewrite. The argument can be optionally
// wrapped in single or double quotes and may be surrounded by whitespace.
//
// url(/foo)
// url('/foo')
// url("/foo")
//
// Network-relative (`//host/foo`) and absolute (`http://...`) are left alone.
var urlInCSSPattern = regexp.MustCompile(`url\(\s*(['"]?)(/[^/'"][^'")]*)`)
// importInCSSPattern matches `@import "/foo";` style root-absolute imports.
var importInCSSPattern = regexp.MustCompile(`@import\s+(['"])(/[^/'"][^'"]*)['"]`)
// rewriteProxyResponse mutates an `http.Response` from agentctl in place,
// rewriting root-absolute URLs to be prefixed by `proxyPrefix` so the iframe's
// asset/XHR/import requests come back through the same port proxy instead of
// hitting the host page's origin. Returns nil and leaves the response untouched
// for content types we don't rewrite (everything except HTML and CSS today).
//
// `proxyPrefix` is the public URL path that fronts this proxy on the gateway,
// e.g. "/port-proxy/<sessionId>/<port>" (no trailing slash). It is prepended to
// matched URLs that start with a single "/" — see `rewriteAbsolutePath`.
func rewriteProxyResponse(resp *http.Response, proxyPrefix string) error {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
var rewrite func([]byte, string) []byte
switch {
case strings.Contains(ct, "text/html"):
rewrite = rewriteHTMLURLs
case strings.Contains(ct, "text/css"):
rewrite = rewriteCSSURLs
default:
return nil
}
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
modified := rewrite(body, proxyPrefix)
resp.Body = io.NopCloser(bytes.NewReader(modified))
resp.Header.Del("Content-Encoding")
resp.Header.Set("Content-Length", strconv.Itoa(len(modified)))
resp.ContentLength = int64(len(modified))
return nil
}
// rewriteAbsolutePath turns a path-absolute URL ("/foo") into a proxied path
// ("<prefix>/foo"). Returns the input unchanged for non-rewritable cases:
// empty strings, network-relative URLs (`//host`), schemes (`http:`, `data:`,
// `mailto:`, etc.), or relative paths (`foo`, `./foo`, `../foo`).
func rewriteAbsolutePath(rawURL, prefix string) string {
if len(rawURL) < 1 || rawURL[0] != '/' {
return rawURL
}
if len(rawURL) >= 2 && rawURL[1] == '/' {
return rawURL // network-relative
}
return prefix + rawURL
}
// htmlRewriteState tracks per-document position during a single
// `rewriteHTMLURLs` pass. Pulled out to its own type so each token-handling
// case stays short and the top-level loop's cyclomatic complexity stays under
// the lint budget.
type htmlRewriteState struct {
out *bytes.Buffer
prefix string
shim string
inStyle bool
inScript bool
shimInjected bool
}
// onStartTag emits the (URL-rewritten) start tag and updates raw-text
// element tracking. Also injects the runtime shim immediately after `<head>`.
func (s *htmlRewriteState) onStartTag(token html.Token) {
rewriteTokenURLs(&token, s.prefix)
s.out.WriteString(token.String())
if !s.shimInjected && token.Data == headTagName {
s.out.WriteString(s.shim)
s.shimInjected = true
}
switch token.Data {
case styleTagName:
s.inStyle = true
case scriptTagName:
s.inScript = true
}
}
// onEndTag clears raw-text element tracking, then writes the end tag.
func (s *htmlRewriteState) onEndTag(token html.Token) {
switch token.Data {
case styleTagName:
s.inStyle = false
case scriptTagName:
s.inScript = false
}
s.out.WriteString(token.String())
}
// onTextToken writes text content with the right escaping for the current
// element context: rewrite CSS URLs inside `<style>`, emit raw bytes inside
// `<script>` (Token.String would HTML-escape and corrupt JS), and otherwise
// fall back to the default Token.String() entity-encoding.
func (s *htmlRewriteState) onTextToken(token html.Token) {
switch {
case s.inStyle:
s.out.WriteString(rewriteCSSFragment(token.Data, s.prefix))
case s.inScript:
s.out.WriteString(token.Data)
default:
s.out.WriteString(token.String())
}
}
// rewriteHTMLURLs walks the HTML document and rewrites every rewritable URL
// attribute (`href`, `src`, …) plus `srcset` values and inline `style="…"`
// `url(...)` references. `<style>` content is run through the CSS URL
// rewriter; `<script>` content is emitted unchanged (the runtime shim
// handles network-facing APIs at runtime).
//
// Falls back to returning the input unchanged if tokenization fails midway, so
// a malformed page never blocks the response.
func rewriteHTMLURLs(body []byte, prefix string) []byte {
tok := html.NewTokenizer(bytes.NewReader(body))
var out bytes.Buffer
out.Grow(len(body) + 256 + len(runtimeShimTemplate))
s := &htmlRewriteState{out: &out, prefix: prefix, shim: runtimeShim(prefix)}
for {
tt := tok.Next()
if tt == html.ErrorToken {
if tok.Err() == io.EOF {
return out.Bytes()
}
return body
}
token := tok.Token()
switch token.Type {
case html.StartTagToken:
s.onStartTag(token)
case html.SelfClosingTagToken:
rewriteTokenURLs(&token, prefix)
out.WriteString(token.String())
case html.EndTagToken:
s.onEndTag(token)
case html.TextToken:
s.onTextToken(token)
default:
out.WriteString(token.String())
}
}
}
// rewriteTokenURLs walks a single token's attributes and rewrites any URL-
// shaped attribute value in place.
func rewriteTokenURLs(token *html.Token, prefix string) {
if token.Type != html.StartTagToken && token.Type != html.SelfClosingTagToken {
return
}
for i, attr := range token.Attr {
key := strings.ToLower(attr.Key)
switch {
case rewritableURLAttrs[key]:
token.Attr[i].Val = rewriteAbsolutePath(attr.Val, prefix)
case key == "srcset":
token.Attr[i].Val = rewriteSrcSet(attr.Val, prefix)
case key == "style":
token.Attr[i].Val = rewriteCSSFragment(attr.Val, prefix)
}
}
}
// rewriteSrcSet rewrites each candidate URL in a `srcset` attribute. The
// value format is `url [descriptor], url [descriptor], …` per the HTML spec.
func rewriteSrcSet(value, prefix string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
// Split into URL and optional descriptor (separated by whitespace).
fields := strings.Fields(trimmed)
if len(fields) == 0 {
continue
}
fields[0] = rewriteAbsolutePath(fields[0], prefix)
parts[i] = strings.Join(fields, " ")
}
return strings.Join(parts, ", ")
}
// rewriteCSSURLs rewrites url(/...) and @import "/..." occurrences inside a
// standalone CSS document.
func rewriteCSSURLs(body []byte, prefix string) []byte {
return []byte(rewriteCSSFragment(string(body), prefix))
}
// rewriteCSSFragment rewrites CSS URL references inside an arbitrary string
// (either a full CSS file or the contents of an inline style attribute).
func rewriteCSSFragment(css, prefix string) string {
css = urlInCSSPattern.ReplaceAllStringFunc(css, func(match string) string {
sub := urlInCSSPattern.FindStringSubmatch(match)
// sub: full match, quote, url
if len(sub) != 3 {
return match
}
return "url(" + sub[1] + rewriteAbsolutePath(sub[2], prefix)
})
css = importInCSSPattern.ReplaceAllStringFunc(css, func(match string) string {
sub := importInCSSPattern.FindStringSubmatch(match)
if len(sub) != 3 {
return match
}
return "@import " + sub[1] + rewriteAbsolutePath(sub[2], prefix) + sub[1]
})
return css
}