tinyrelaycommit

Commit 3408716c6332

browse files at this commit | history

1diff --git a/internal/webui/prefix.go b/internal/webui/prefix.go
2new file mode 100644
3index 0000000..bf86faf
4--- /dev/null
5+++ b/internal/webui/prefix.go
6@@ -0,0 +1,39 @@
7+package webui
8+
9+import (
10+ "regexp"
11+ "strings"
12+
13+ "golang.org/x/net/html"
14+)
15+
16+var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action)="(/[^\"]*)"`)
17+
18+// injectBase scopes root-relative template URLs without changing scripts or
19+// literal content. The signer bridge scopes its requests with localPath.
20+func injectBase(source, base string) string {
21+ if base == "" {
22+ return source
23+ }
24+ var out strings.Builder
25+ out.Grow(len(source))
26+ tokens := html.NewTokenizer(strings.NewReader(source))
27+ for {
28+ kind := tokens.Next()
29+ raw := string(tokens.Raw())
30+ if kind == html.StartTagToken || kind == html.SelfClosingTagToken {
31+ raw = rootURLAttribute.ReplaceAllStringFunc(raw, func(attribute string) string {
32+ start := strings.IndexByte(attribute, '"') + 1
33+ path := attribute[start : len(attribute)-1]
34+ if strings.HasPrefix(path, "//") || path == base || strings.HasPrefix(path, base+"/") {
35+ return attribute
36+ }
37+ return attribute[:start] + base + path + `"`
38+ })
39+ }
40+ out.WriteString(raw)
41+ if kind == html.ErrorToken {
42+ return out.String()
43+ }
44+ }
45+}
46diff --git a/internal/webui/prefix_test.go b/internal/webui/prefix_test.go
47new file mode 100644
48index 0000000..09bff56
49--- /dev/null
50+++ b/internal/webui/prefix_test.go
51@@ -0,0 +1,41 @@
52+package webui
53+
54+import (
55+ "strings"
56+ "testing"
57+)
58+
59+func TestInjectBasePreservesExternalURLsAndContent(t *testing.T) {
60+ for _, tc := range []struct {
61+ name, input, want string
62+ }{
63+ {"local link", `<a href="/repo?path=a%26b">code</a>`, `<a href="/r/alice/repo?path=a%26b">code</a>`},
64+ {"external link", `<a href="//example.org/file">external</a>`, `<a href="//example.org/file">external</a>`},
65+ {"external image", `<img src="//example.org/icon.png">`, `<img src="//example.org/icon.png">`},
66+ {"already scoped", `<a href="/r/alice/files">files</a>`, `<a href="/r/alice/files">files</a>`},
67+ {"script source", `<script src="/fixi.js"></script>`, `<script src="/r/alice/fixi.js"></script>`},
68+ {"script text", `<script>const example = ' href="/example"';</script>`, `<script>const example = ' href="/example"';</script>`},
69+ {"textarea", `<textarea> href="/example"</textarea>`, `<textarea> href="/example"</textarea>`},
70+ {"local action", `<button fx-action="/connect/fragment">preview</button>`, `<button fx-action="/r/alice/connect/fragment">preview</button>`},
71+ } {
72+ t.Run(tc.name, func(t *testing.T) {
73+ if got := injectBase(tc.input, "/r/alice"); got != tc.want {
74+ t.Fatalf("got %q, want %q", got, tc.want)
75+ }
76+ })
77+ }
78+}
79+
80+func FuzzInjectBasePreservesText(f *testing.F) {
81+ for _, seed := range []string{"hello", ` href="/file"`, `fetch("/example")`, `new URL("/file")`, "emoji 🧪", "a\x00b"} {
82+ f.Add(seed)
83+ }
84+ f.Fuzz(func(t *testing.T, content string) {
85+ // Textarea content can contain quotes and URL examples literally.
86+ content = strings.ReplaceAll(content, "<", "&lt;")
87+ input := "<textarea>" + content + "</textarea>"
88+ if got := injectBase(input, "/r/alice"); got != input {
89+ t.Fatalf("content changed: got %q, want %q", got, input)
90+ }
91+ })
92+}
93diff --git a/internal/webui/webui.go b/internal/webui/webui.go
94index 03f900e..1b8711a 100644
95--- a/internal/webui/webui.go
96+++ b/internal/webui/webui.go
97@@ -843,20 +843,6 @@ func requestPrefix(request *http.Request) string {
98 return pathPrefix(request.URL.Path)
99 }
100
101-func injectBase(html, base string) string {
102- if base == "" {
103- return html
104- }
105- for _, attr := range []string{"href", "src", "fx-action", "action"} {
106- html = strings.ReplaceAll(html, " "+attr+"=\"/", " "+attr+"=\""+base+"/")
107- }
108- html = strings.ReplaceAll(html, "fetch(\"/", "fetch(\""+base+"/")
109- html = strings.ReplaceAll(html, "new URL(\"/", "new URL(\""+base+"/")
110- html = strings.ReplaceAll(html, "signedSession(\"/", "signedSession(\""+base+"/")
111- html = strings.ReplaceAll(html, "qr.src=\"/", "qr.src=\""+base+"/")
112- return html
113-}
114-
115 func tabForPath(path string) string {
116 path = strings.TrimPrefix(path, pathPrefix(path))
117 if path == "/" || path == "" {
118diff --git a/internal/webui/webui_test.go b/internal/webui/webui_test.go
119index 837df47..e58cea7 100644
120--- a/internal/webui/webui_test.go
121+++ b/internal/webui/webui_test.go
122@@ -335,7 +335,7 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
123 recorder := httptest.NewRecorder()
124 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))
125 body := recorder.Body.String()
126- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/r/alice/session")`, `replace(/^http/,"ws")+root`} {
127+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/connect/fragment"`, `localPath(this.getAttribute("action")||"/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {
128 if !strings.Contains(body, want) {
129 t.Errorf("tenant prefix missing %q", want)
130 }

About

ownerc01503e8035f
visibilitypublic
head3408716c6332

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs