diff --git a/server/internal/adminui/pages.go b/server/internal/adminui/pages.go index d15136a..d2f8902 100644 --- a/server/internal/adminui/pages.go +++ b/server/internal/adminui/pages.go @@ -5,10 +5,12 @@ package adminui import ( "encoding/json" + "html/template" "log/slog" "net/http" "net/url" "sort" + "strings" "time" "echo-lot.app/server/internal/adminauth" @@ -122,9 +124,21 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth } rows = append(rows, row{Device: d, Runs: n}) } + // html/template rewrites an href whose scheme it does not recognise to "#ZgotmplZ", so the + // enrollment link rendered as a dead anchor that did nothing when tapped — silently, since the + // markup looks fine and only the sanitised attribute gives it away. + // + // Marking it template.URL opts out of that sanitising, which is only safe because the shape is + // checked first: this value arrives in a query parameter, so without the check a crafted + // /devices?link=javascript:… would put a script URL straight into the page. + link := r.URL.Query().Get("link") + var href template.URL + if strings.HasPrefix(link, "echolot://enroll?") { + href = template.URL(link) + } s.render(w, r, "devices", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, - "Link": r.URL.Query().Get("link"), "Admin": sess.Admin, + "Link": link, "LinkHref": href, "Admin": sess.Admin, }) } diff --git a/server/internal/adminui/render.go b/server/internal/adminui/render.go index 8d00c18..d7089fc 100644 --- a/server/internal/adminui/render.go +++ b/server/internal/adminui/render.go @@ -327,7 +327,7 @@ const baseHTML = ` -
+ {{with $.LinkHref}}{{end}}Only works on the phone you are enrolling. From anywhere else, copy the link into the app's enrolment field, or deliver it over adb.
{{.}}