Rewrite root-relative href, src, action and fx-action attributes on start tags only, leaving scripts and text alone. The signer bridge already scopes its own requests through localPath.
- commit
- 3408716c6332a8604afde0285761b34e0dd16e7c
- parent
- 204be272d9f5a6b51737cd14ae23ac22d1320c06
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 39 | +++++++++++++++++++++++++++++ |
| A | internal/ | 41 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 14 | ---------- |
| M | internal/ | 2 | +- |
@@ -0,0 +1,39 @@
1+package webui2+3+import (4+ "regexp"5+ "strings"6+7+ "golang.org/x/net/html"8+)9+10+var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action)="(/[^\"]*)"`)11+12+// injectBase scopes root-relative template URLs without changing scripts or13+// literal content. The signer bridge scopes its requests with localPath.14+func injectBase(source, base string) string {15+ if base == "" {16+ return source17+ }18+ var out strings.Builder19+ out.Grow(len(source))20+ tokens := html.NewTokenizer(strings.NewReader(source))21+ for {22+ kind := tokens.Next()23+ raw := string(tokens.Raw())24+ if kind == html.StartTagToken || kind == html.SelfClosingTagToken {25+ raw = rootURLAttribute.ReplaceAllStringFunc(raw, func(attribute string) string {26+ start := strings.IndexByte(attribute, '"') + 127+ path := attribute[start : len(attribute)-1]28+ if strings.HasPrefix(path, "//") || path == base || strings.HasPrefix(path, base+"/") {29+ return attribute30+ }31+ return attribute[:start] + base + path + `"`32+ })33+ }34+ out.WriteString(raw)35+ if kind == html.ErrorToken {36+ return out.String()37+ }38+ }39+}@@ -0,0 +1,41 @@
1+package webui2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestInjectBasePreservesExternalURLsAndContent(t *testing.T) {9+ for _, tc := range []struct {10+ name, input, want string11+ }{12+ {"local link", `<a href="/repo?path=a%26b">code</a>`, `<a href="/r/alice/repo?path=a%26b">code</a>`},13+ {"external link", `<a href="//example.org/file">external</a>`, `<a href="//example.org/file">external</a>`},14+ {"external image", `<img src="//example.org/icon.png">`, `<img src="//example.org/icon.png">`},15+ {"already scoped", `<a href="/r/alice/files">files</a>`, `<a href="/r/alice/files">files</a>`},16+ {"script source", `<script src="/fixi.js"></script>`, `<script src="/r/alice/fixi.js"></script>`},17+ {"script text", `<script>const example = ' href="/example"';</script>`, `<script>const example = ' href="/example"';</script>`},18+ {"textarea", `<textarea> href="/example"</textarea>`, `<textarea> href="/example"</textarea>`},19+ {"local action", `<button fx-action="/connect/fragment">preview</button>`, `<button fx-action="/r/alice/connect/fragment">preview</button>`},20+ } {21+ t.Run(tc.name, func(t *testing.T) {22+ if got := injectBase(tc.input, "/r/alice"); got != tc.want {23+ t.Fatalf("got %q, want %q", got, tc.want)24+ }25+ })26+ }27+}28+29+func FuzzInjectBasePreservesText(f *testing.F) {30+ for _, seed := range []string{"hello", ` href="/file"`, `fetch("/example")`, `new URL("/file")`, "emoji 🧪", "a\x00b"} {31+ f.Add(seed)32+ }33+ f.Fuzz(func(t *testing.T, content string) {34+ // Textarea content can contain quotes and URL examples literally.35+ content = strings.ReplaceAll(content, "<", "<")36+ input := "<textarea>" + content + "</textarea>"37+ if got := injectBase(input, "/r/alice"); got != input {38+ t.Fatalf("content changed: got %q, want %q", got, input)39+ }40+ })41+}@@ -843,20 +843,6 @@ func requestPrefix(request *http.Request) string {
843843 return pathPrefix(request.URL.Path)844844 }845845 846-func injectBase(html, base string) string {847- if base == "" {848- return html849- }850- for _, attr := range []string{"href", "src", "fx-action", "action"} {851- html = strings.ReplaceAll(html, " "+attr+"=\"/", " "+attr+"=\""+base+"/")852- }853- html = strings.ReplaceAll(html, "fetch(\"/", "fetch(\""+base+"/")854- html = strings.ReplaceAll(html, "new URL(\"/", "new URL(\""+base+"/")855- html = strings.ReplaceAll(html, "signedSession(\"/", "signedSession(\""+base+"/")856- html = strings.ReplaceAll(html, "qr.src=\"/", "qr.src=\""+base+"/")857- return html858-}859-860846 func tabForPath(path string) string {861847 path = strings.TrimPrefix(path, pathPrefix(path))862848 if path == "/" || path == "" {@@ -335,7 +335,7 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
335335 recorder := httptest.NewRecorder()336336 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/connect", nil))337337 body := recorder.Body.String()338- 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`} {338+ 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`} {339339 if !strings.Contains(body, want) {340340 t.Errorf("tenant prefix missing %q", want)341341 }