Skip to content

Commit e55226e

Browse files
committed
fix(browser): isolate proxied tab requests
1 parent 605bc1e commit e55226e

7 files changed

Lines changed: 108 additions & 24 deletions

File tree

internal/browser/manager.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (m *Manager) CreateTab(rawURL string) (Tab, error) {
101101
ID: id,
102102
URL: target.String(),
103103
Title: target.Host,
104-
ProxyPath: "/api/browser/proxy/" + id + "/",
104+
ProxyPath: "/browser/" + id + "/",
105105
CreatedAt: now,
106106
UpdatedAt: now,
107107
}
@@ -174,7 +174,11 @@ func (m *Manager) ProxyTarget(id string, requestPath string, rawQuery string) (*
174174
return nil, err
175175
}
176176
target := *base
177-
target.Path = joinURLPath(base.Path, requestPath)
177+
if requestPath == "" || requestPath == "/" {
178+
target.Path = joinURLPath(base.Path, requestPath)
179+
} else {
180+
target.Path = requestPath
181+
}
178182
target.RawPath = ""
179183
if rawQuery != "" {
180184
target.RawQuery = rawQuery

internal/browser/manager_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,27 @@ func TestManagerTabsAndProxyTarget(t *testing.T) {
5959
t.Fatalf("DeleteTab() error = %v", err)
6060
}
6161
}
62+
63+
func TestProxyTargetMapsInitialDocumentAndAbsoluteResources(t *testing.T) {
64+
manager := NewManager()
65+
tab, err := manager.CreateTab("https://example.com/docs/page")
66+
if err != nil {
67+
t.Fatalf("CreateTab() error = %v", err)
68+
}
69+
70+
document, err := manager.ProxyTarget(tab.ID, "/", "")
71+
if err != nil {
72+
t.Fatalf("ProxyTarget(document) error = %v", err)
73+
}
74+
if document.String() != "https://example.com/docs/page" {
75+
t.Fatalf("expected initial proxy request to load tab URL, got %q", document.String())
76+
}
77+
78+
asset, err := manager.ProxyTarget(tab.ID, "/assets/app.js", "v=1")
79+
if err != nil {
80+
t.Fatalf("ProxyTarget(asset) error = %v", err)
81+
}
82+
if asset.String() != "https://example.com/assets/app.js?v=1" {
83+
t.Fatalf("expected absolute asset path to stay root-relative, got %q", asset.String())
84+
}
85+
}

internal/httpapi/browserapi.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func (a *API) handleBrowserProxy(w http.ResponseWriter, r *http.Request) {
102102
return
103103
}
104104

105-
rest := strings.TrimPrefix(r.URL.Path, "/api/browser/proxy/")
105+
rest := browserProxyRest(r.URL.Path)
106106
tabID, requestPath := splitBrowserProxyPath(rest)
107107
if tabID == "" {
108108
http.NotFound(w, r)
@@ -134,6 +134,8 @@ func (a *API) handleBrowserProxy(w http.ResponseWriter, r *http.Request) {
134134
req.Header.Del("Accept-Encoding")
135135
}
136136
proxy.ModifyResponse = func(resp *http.Response) error {
137+
resp.Header.Set("Cache-Control", "no-store")
138+
resp.Header.Set("Pragma", "no-cache")
137139
resp.Header.Del("X-Frame-Options")
138140
resp.Header.Del("Content-Security-Policy")
139141
resp.Header.Del("Content-Security-Policy-Report-Only")
@@ -315,6 +317,13 @@ func splitBrowserTabPath(rest string) (string, string) {
315317
return id, strings.Trim(action, "/")
316318
}
317319

320+
func browserProxyRest(requestPath string) string {
321+
if strings.HasPrefix(requestPath, "/browser/") {
322+
return strings.TrimPrefix(requestPath, "/browser/")
323+
}
324+
return strings.TrimPrefix(requestPath, "/api/browser/proxy/")
325+
}
326+
318327
func splitBrowserProxyPath(rest string) (string, string) {
319328
rest = strings.TrimLeft(rest, "/")
320329
if rest == "" {

internal/httpapi/browserproxy.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"golang.org/x/net/html"
1313
)
1414

15-
const proxyRuntimePatch = `(function(){if(window.__nineEDProxyPatched)return;window.__nineEDProxyPatched=true;var proxyBase=%q;var remotePath=%q;var tabId=%q;function normalize(input){try{var value=String(input);if(/^(data|blob|javascript|mailto|tel):/i.test(value))return value;if(value.startsWith("//"))return window.location.protocol+value;if(value.startsWith("/"))return proxyBase+value.slice(1);return value}catch{return input}}function notifyParent(type,payload){try{if(window.parent&&window.parent!==window){window.parent.postMessage(Object.assign({__nineBrowser:true,type:type,tabId:tabId},payload||{}),window.location.origin)}}catch{}}try{if(remotePath&&window.location.pathname!==remotePath.split(/[?#]/,1)[0]){window.history.replaceState(window.history.state,"",remotePath)}}catch{}var originalFetch=window.fetch;if(typeof originalFetch==="function"){window.fetch=function(input,init){if(typeof input==="string"){input=normalize(input)}else if(input instanceof Request){input=new Request(normalize(input.url),input)}return originalFetch.call(this,input,init)}}var originalOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){if(arguments.length>1){arguments[1]=normalize(url)}return originalOpen.apply(this,arguments)};if(window.EventSource){var OriginalEventSource=window.EventSource;window.EventSource=function(url,config){return new OriginalEventSource(normalize(url),config)};window.EventSource.prototype=OriginalEventSource.prototype}if(navigator.serviceWorker&&typeof navigator.serviceWorker.register==="function"){var originalRegister=navigator.serviceWorker.register.bind(navigator.serviceWorker);navigator.serviceWorker.register=function(scriptURL,options){var nextOptions=options?Object.assign({},options):{};if(nextOptions.scope){nextOptions.scope=normalize(nextOptions.scope)}else{nextOptions.scope=proxyBase}return originalRegister(normalize(scriptURL),nextOptions)}}var nativeWindowOpen=window.open;window.open=function(url,target,features){var normalized=normalize(url||"about:blank");var targetName=String(target||"_blank").toLowerCase();if(targetName===""||targetName==="_blank"||targetName==="_new"||features){notifyParent("open-tab",{url:normalized,target:target||"_blank",features:features||""});return {closed:false,close:function(){notifyParent("close-tab",{})},focus:function(){notifyParent("focus-tab",{})},postMessage:function(message,origin){notifyParent("post-message",{message:message,origin:origin||"*"})},location:{href:normalized}}}return nativeWindowOpen.call(window,normalized,target,features)};var nativeClose=window.close;window.close=function(){notifyParent("close-tab",{});try{return nativeClose.call(window)}catch{return undefined}};document.addEventListener("click",function(event){var node=event.target;while(node&&node.nodeType===1){if(node.tagName==="A"&&node.href){var targetName=String(node.getAttribute("target")||"").toLowerCase();if(targetName==="_blank"||node.hasAttribute("download")){event.preventDefault();notifyParent("open-tab",{url:normalize(node.href),target:targetName||"_blank"});return}break}node=node.parentElement}},true);document.addEventListener("submit",function(event){var form=event.target;if(form&&form.tagName==="FORM"){var targetName=String(form.getAttribute("target")||"").toLowerCase();if(targetName==="_blank"){event.preventDefault();var action=form.getAttribute("action")||remotePath||"/";notifyParent("open-tab",{url:normalize(action),target:"_blank",method:String(form.getAttribute("method")||"get").toUpperCase()})}}},true)})();`
15+
const proxyRuntimePatch = `(function(){if(window.__nineEDProxyPatched)return;window.__nineEDProxyPatched=true;var proxyBase=%q;var remotePath=%q;var tabId=%q;function normalize(input){try{var value=String(input);if(/^(data|blob|javascript|mailto|tel):/i.test(value))return value;if(value.startsWith("//"))return value;if(value.startsWith(window.location.origin+"/")&&!value.startsWith(window.location.origin+proxyBase)){var parsed=new URL(value);return proxyBase+parsed.pathname.replace(/^\/+/,"")+(parsed.search||"")+(parsed.hash||"")}if(value.startsWith("/"))return proxyBase+value.slice(1);return value}catch{return input}}function notifyParent(type,payload){try{if(window.parent&&window.parent!==window){window.parent.postMessage(Object.assign({__nineBrowser:true,type:type,tabId:tabId},payload||{}),window.location.origin)}}catch{}}var originalFetch=window.fetch;if(typeof originalFetch==="function"){window.fetch=function(input,init){if(typeof input==="string"||input instanceof URL){input=normalize(input)}else if(input instanceof Request){input=new Request(normalize(input.url),input)}return originalFetch.call(this,input,init)}}var originalOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){if(arguments.length>1){arguments[1]=normalize(url)}return originalOpen.apply(this,arguments)};if(window.EventSource){var OriginalEventSource=window.EventSource;window.EventSource=function(url,config){return new OriginalEventSource(normalize(url),config)};window.EventSource.prototype=OriginalEventSource.prototype}if(navigator.serviceWorker&&typeof navigator.serviceWorker.register==="function"){navigator.serviceWorker.register=function(){return Promise.resolve({active:null,installing:null,waiting:null,scope:proxyBase,update:function(){return Promise.resolve()},unregister:function(){return Promise.resolve(true)},addEventListener:function(){},removeEventListener:function(){}})}}var nativeWindowOpen=window.open;window.open=function(url,target,features){var normalized=normalize(url||"about:blank");var targetName=String(target||"_blank").toLowerCase();if(targetName===""||targetName==="_blank"||targetName==="_new"||features){notifyParent("open-tab",{url:normalized,target:target||"_blank",features:features||""});return {closed:false,close:function(){notifyParent("close-tab",{})},focus:function(){notifyParent("focus-tab",{})},postMessage:function(message,origin){notifyParent("post-message",{message:message,origin:origin||"*"})},location:{href:normalized}}}return nativeWindowOpen.call(window,normalized,target,features)};var nativeClose=window.close;window.close=function(){notifyParent("close-tab",{});try{return nativeClose.call(window)}catch{return undefined}};document.addEventListener("click",function(event){var node=event.target;while(node&&node.nodeType===1){if(node.tagName==="A"&&node.href){var targetName=String(node.getAttribute("target")||"").toLowerCase();if(targetName==="_blank"||node.hasAttribute("download")){event.preventDefault();notifyParent("open-tab",{url:normalize(node.href),target:targetName||"_blank"});return}break}node=node.parentElement}},true);document.addEventListener("submit",function(event){var form=event.target;if(form&&form.tagName==="FORM"){var targetName=String(form.getAttribute("target")||"").toLowerCase();if(targetName==="_blank"){event.preventDefault();var action=form.getAttribute("action")||remotePath||"/";notifyParent("open-tab",{url:normalize(action),target:"_blank",method:String(form.getAttribute("method")||"get").toUpperCase()})}}},true)})();`
1616

1717
func rewriteProxyResponseBody(resp *http.Response, prefix string, remotePath string, tabID string) error {
1818
if resp.Body == nil {
@@ -90,7 +90,7 @@ func rewriteProxyHTML(input []byte, prefix string, remotePath string, tabID stri
9090
head := findElement(doc, "head")
9191
body := findElement(doc, "body")
9292
if head != nil {
93-
ensureProxyBase(head, prefix)
93+
ensureProxyBase(head, proxyDocumentBase(prefix, remotePath))
9494
injectProxyRuntime(head, prefix, remotePath, tabID)
9595
}
9696

@@ -204,7 +204,7 @@ func rewriteProxyAttribute(value string, prefix string) string {
204204
return value
205205
}
206206
if strings.HasPrefix(value, "//") {
207-
return prefix + strings.TrimPrefix(value, "//")
207+
return value
208208
}
209209
if strings.HasPrefix(value, "/") {
210210
return prefix + strings.TrimPrefix(value, "/")
@@ -242,6 +242,18 @@ func ensureProxyBase(head *html.Node, prefix string) {
242242
head.InsertBefore(base, head.FirstChild)
243243
}
244244

245+
func proxyDocumentBase(prefix string, remotePath string) string {
246+
remoteURL, err := url.Parse(remotePath)
247+
if err != nil {
248+
return prefix
249+
}
250+
remoteDir := path.Dir(remoteURL.Path)
251+
if remoteDir == "." || remoteDir == "/" {
252+
return prefix
253+
}
254+
return prefix + strings.Trim(remoteDir, "/") + "/"
255+
}
256+
245257
func injectProxyRuntime(head *html.Node, prefix string, remotePath string, tabID string) {
246258
for child := head.FirstChild; child != nil; child = child.NextSibling {
247259
if child.Type == html.ElementNode && child.Data == "script" && attrValue(child, "data-nine-proxy-runtime") == "true" {
@@ -309,7 +321,7 @@ func attrValue(node *html.Node, key string) string {
309321
}
310322

311323
func browserProxyPrefix(tabID string) string {
312-
return path.Join("/api/browser/proxy", tabID) + "/"
324+
return path.Join("/browser", tabID) + "/"
313325
}
314326

315327
func isJavaScriptContentType(contentType string) bool {

internal/httpapi/browserproxy_test.go

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@ import (
1212
func TestRewriteProxyHTMLRewritesRootRelativeAssetsAndInjectsRuntime(t *testing.T) {
1313
input := []byte(`<!doctype html><html><head><script src="/assets/app.js"></script></head><body><img src="/hero.png"><a href="/docs">Docs</a></body></html>`)
1414

15-
output, err := rewriteProxyHTML(input, "/api/browser/proxy/browser-1/", "/", "browser-1")
15+
output, err := rewriteProxyHTML(input, "/browser/browser-1/", "/", "browser-1")
1616
if err != nil {
1717
t.Fatalf("rewriteProxyHTML() error = %v", err)
1818
}
1919
html := string(output)
2020

21-
if !strings.Contains(html, `<base href="/api/browser/proxy/browser-1/"/>`) {
21+
if !strings.Contains(html, `<base href="/browser/browser-1/"/>`) {
2222
t.Fatalf("expected base href to be injected, got %q", html)
2323
}
24-
if !strings.Contains(html, `src="/api/browser/proxy/browser-1/assets/app.js"`) {
24+
if !strings.Contains(html, `src="/browser/browser-1/assets/app.js"`) {
2525
t.Fatalf("expected script src to be rewritten, got %q", html)
2626
}
27-
if !strings.Contains(html, `href="/api/browser/proxy/browser-1/docs"`) {
27+
if !strings.Contains(html, `href="/browser/browser-1/docs"`) {
2828
t.Fatalf("expected anchor href to be rewritten, got %q", html)
2929
}
3030
if !strings.Contains(html, `data-nine-proxy-runtime="true"`) {
@@ -36,15 +36,45 @@ func TestRewriteProxyHTMLRewritesRootRelativeAssetsAndInjectsRuntime(t *testing.
3636
if !strings.Contains(html, `var tabId="browser-1"`) {
3737
t.Fatalf("expected runtime patch to receive tab id, got %q", html)
3838
}
39+
if strings.Contains(html, `history.replaceState`) {
40+
t.Fatalf("proxy runtime must not move the iframe out of the proxy URL, got %q", html)
41+
}
42+
if !strings.Contains(html, `window.location.origin+"/"`) {
43+
t.Fatalf("expected runtime patch to normalize same-origin absolute URLs, got %q", html)
44+
}
45+
}
46+
47+
func TestRewriteProxyHTMLUsesRemoteDocumentDirectoryForRelativeAssets(t *testing.T) {
48+
input := []byte(`<!doctype html><html><head><script src="chunk.js"></script></head><body></body></html>`)
49+
50+
output, err := rewriteProxyHTML(input, "/browser/browser-1/", "/docs/page?x=1", "browser-1")
51+
if err != nil {
52+
t.Fatalf("rewriteProxyHTML() error = %v", err)
53+
}
54+
html := string(output)
55+
56+
if !strings.Contains(html, `<base href="/browser/browser-1/docs/"/>`) {
57+
t.Fatalf("expected base href to use remote directory, got %q", html)
58+
}
59+
if !strings.Contains(html, `src="chunk.js"`) {
60+
t.Fatalf("expected relative asset to remain relative to base, got %q", html)
61+
}
62+
}
63+
64+
func TestRewriteProxyAttributeLeavesSchemeRelativeURLsAlone(t *testing.T) {
65+
got := rewriteProxyAttribute("//www.gstatic.com/assets/app.js", "/browser/browser-1/")
66+
if got != "//www.gstatic.com/assets/app.js" {
67+
t.Fatalf("expected scheme-relative URL to be preserved, got %q", got)
68+
}
3969
}
4070

4171
func TestRewriteProxyLocationRewritesAbsoluteAndRootRelativeLocations(t *testing.T) {
42-
prefix := "/api/browser/proxy/browser-1/"
72+
prefix := "/browser/browser-1/"
4373

44-
if got := rewriteProxyLocation("/login", prefix); got != "/api/browser/proxy/browser-1/login" {
74+
if got := rewriteProxyLocation("/login", prefix); got != "/browser/browser-1/login" {
4575
t.Fatalf("rewriteProxyLocation(root-relative) = %q", got)
4676
}
47-
if got := rewriteProxyLocation("https://example.com/dashboard?x=1", prefix); got != "/api/browser/proxy/browser-1/dashboard?x=1" {
77+
if got := rewriteProxyLocation("https://example.com/dashboard?x=1", prefix); got != "/browser/browser-1/dashboard?x=1" {
4878
t.Fatalf("rewriteProxyLocation(absolute) = %q", got)
4979
}
5080
if got := rewriteProxyLocation("settings", prefix); got != "settings" {
@@ -66,7 +96,7 @@ func TestHandleBrowserProxyRewritesHTMLResponses(t *testing.T) {
6696
}
6797

6898
api := New(Dependencies{Mode: "full", UseBrowser: true, Browser: manager})
69-
req := httptest.NewRequest(http.MethodGet, "/api/browser/proxy/"+tab.ID+"/", nil)
99+
req := httptest.NewRequest(http.MethodGet, "/browser/"+tab.ID+"/", nil)
70100
rec := httptest.NewRecorder()
71101

72102
api.Handler().ServeHTTP(rec, req)
@@ -75,42 +105,45 @@ func TestHandleBrowserProxyRewritesHTMLResponses(t *testing.T) {
75105
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
76106
}
77107
body := rec.Body.String()
78-
if !strings.Contains(body, `src="/api/browser/proxy/`+tab.ID+`/assets/app.js"`) {
108+
if !strings.Contains(body, `src="/browser/`+tab.ID+`/assets/app.js"`) {
79109
t.Fatalf("expected proxied asset path, got %q", body)
80110
}
81111
if got := rec.Header().Get("X-Frame-Options"); got != "" {
82112
t.Fatalf("expected X-Frame-Options to be removed, got %q", got)
83113
}
114+
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
115+
t.Fatalf("expected proxied browser responses to disable cache, got %q", got)
116+
}
84117
}
85118

86119
func TestRewriteProxyCSSRewritesRootRelativeURLs(t *testing.T) {
87120
input := []byte(`@font-face{src:url('/cf-fonts/v/inter/normal.woff2')} .hero{background-image:url(/images/hero.png)}`)
88121

89-
output := string(rewriteProxyCSS(input, "/api/browser/proxy/browser-1/"))
122+
output := string(rewriteProxyCSS(input, "/browser/browser-1/"))
90123

91-
if !strings.Contains(output, `url('/api/browser/proxy/browser-1/cf-fonts/v/inter/normal.woff2')`) {
124+
if !strings.Contains(output, `url('/browser/browser-1/cf-fonts/v/inter/normal.woff2')`) {
92125
t.Fatalf("expected font URL to be rewritten, got %q", output)
93126
}
94-
if !strings.Contains(output, `url(/api/browser/proxy/browser-1/images/hero.png)`) {
127+
if !strings.Contains(output, `url(/browser/browser-1/images/hero.png)`) {
95128
t.Fatalf("expected background URL to be rewritten, got %q", output)
96129
}
97130
}
98131

99132
func TestRewriteProxyJavaScriptRewritesRootRelativeAssetAndServiceWorkerPaths(t *testing.T) {
100133
input := []byte("const asset=\"/assets/chunk.js\"; import(\"/assets/entry.js\"); const sw=`/sw.js`; navigator.serviceWorker.register(\"/sw.js\",{scope:\"/\"});")
101134

102-
output := string(rewriteProxyJavaScript(input, "/api/browser/proxy/browser-1/"))
135+
output := string(rewriteProxyJavaScript(input, "/browser/browser-1/"))
103136

104-
if !strings.Contains(output, `"/api/browser/proxy/browser-1/assets/chunk.js"`) {
137+
if !strings.Contains(output, `"/browser/browser-1/assets/chunk.js"`) {
105138
t.Fatalf("expected asset string rewrite, got %q", output)
106139
}
107-
if !strings.Contains(output, `"/api/browser/proxy/browser-1/sw.js"`) {
140+
if !strings.Contains(output, `"/browser/browser-1/sw.js"`) {
108141
t.Fatalf("expected service worker script rewrite, got %q", output)
109142
}
110-
if !strings.Contains(output, "`/api/browser/proxy/browser-1/sw.js`") {
143+
if !strings.Contains(output, "`/browser/browser-1/sw.js`") {
111144
t.Fatalf("expected template literal service worker rewrite, got %q", output)
112145
}
113-
if !strings.Contains(output, `scope:"/api/browser/proxy/browser-1/"`) {
146+
if !strings.Contains(output, `scope:"/browser/browser-1/"`) {
114147
t.Fatalf("expected service worker scope rewrite, got %q", output)
115148
}
116149
}

internal/httpapi/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ func (a *API) Handler() http.Handler {
115115
mux.HandleFunc("/api/browser/tabs", a.handleBrowserTabs)
116116
mux.HandleFunc("/api/browser/tabs/", a.handleBrowserTabByID)
117117
mux.HandleFunc("/api/browser/proxy/", a.handleBrowserProxy)
118+
mux.HandleFunc("/browser/", a.handleBrowserProxy)
118119
mux.HandleFunc("/api/browser/automation/status", a.handleBrowserAutomationStatus)
119120
mux.HandleFunc("/api/browser/automation/start", a.handleBrowserAutomationStart)
120121
mux.HandleFunc("/api/browser/automation/navigate", a.handleBrowserAutomationNavigate)

internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func New(cfg config.Config) *Server {
8181
func (s *Server) Handler() http.Handler {
8282
mux := http.NewServeMux()
8383
mux.Handle("/api/", s.api.Handler())
84+
mux.Handle("/browser/", s.api.Handler())
8485
mux.Handle("/ws/", s.api.Handler())
8586
mux.Handle("/", spaHandler(distDir(), s.Config.Mode))
8687

0 commit comments

Comments
 (0)