diff --git a/internal/server/grpc_status.go b/internal/server/grpc_status.go index 7399e6b..6e46ad5 100644 --- a/internal/server/grpc_status.go +++ b/internal/server/grpc_status.go @@ -34,14 +34,14 @@ func (s *HTTPServer) handleGRPC(w http.ResponseWriter, r *http.Request) { members, err := s.store.GetMembers() if err != nil { - data.ErrorMsg = template.HTML("
Failed to retrieve members
") + data.ErrorMsg = "Failed to retrieve members" s.renderGRPCStatus(w, data) return } selfInfo, ok := members[agentID] if !ok || selfInfo.GRPCAddr == "" { - data.ErrorMsg = template.HTML("
gRPC address not found for self
") + data.ErrorMsg = "gRPC address not found for self" s.renderGRPCStatus(w, data) return } @@ -51,7 +51,7 @@ func (s *HTTPServer) handleGRPC(w http.ResponseWriter, r *http.Request) { conn, err := grpc.NewClient(selfInfo.GRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - data.ErrorMsg = template.HTML(fmt.Sprintf("
Failed to connect to local gRPC channelz: %v
", html.EscapeString(err.Error()))) + data.ErrorMsg = fmt.Sprintf("Failed to connect to local gRPC channelz: %v", err) s.renderGRPCStatus(w, data) return } @@ -62,18 +62,18 @@ func (s *HTTPServer) handleGRPC(w http.ResponseWriter, r *http.Request) { // Get Servers servers, err := s.fetchGRPCServers(ctx, client) if err != nil { - data.ErrorMsg = template.HTML(fmt.Sprintf("
Failed to get servers: %v
", html.EscapeString(err.Error()))) + data.ErrorMsg = fmt.Sprintf("Failed to get servers: %v", err) } data.Servers = servers // Get Top Channels channels, err := s.fetchGRPCChannels(ctx, client) if err != nil { - errMsg := fmt.Sprintf("
Failed to get top channels: %v
", html.EscapeString(err.Error())) + errMsg := fmt.Sprintf("Failed to get top channels: %v", err) if data.ErrorMsg != "" { - data.ErrorMsg = template.HTML(string(data.ErrorMsg) + errMsg) + data.ErrorMsg = data.ErrorMsg + "; " + errMsg } else { - data.ErrorMsg = template.HTML(errMsg) + data.ErrorMsg = errMsg } } data.Channels = channels diff --git a/internal/server/http_server.go b/internal/server/http_server.go index 8860abf..767e4f1 100644 --- a/internal/server/http_server.go +++ b/internal/server/http_server.go @@ -51,7 +51,10 @@ type OngoingRequest struct { //go:embed templates/*.html var templateFS embed.FS -var templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) +var templates = template.Must(template.New("").Funcs(template.FuncMap{ + "sanitizeURL": sanitizeURL, + "wrapString": wrapString, +}).ParseFS(templateFS, "templates/*.html")) // HTTPServer provides a web dashboard for inspecting agent state and issuing commands. type BaseData struct { @@ -61,24 +64,27 @@ type BaseData struct { } type Participant struct { - IDLabel template.HTML + ID string + IsSelf bool ShortName string GRPCAddr string - HTTPLink template.HTML + HTTPURL string } type IndexData struct { BaseData - AgentID template.HTML + AgentID string AgentCert string KeysCount int Participants []Participant } type Peer struct { - Label template.HTML + Name string + IsSelf bool AgentID string - Endpoints template.HTML + GRPCAddr string + HTTPURL string } type Message struct { @@ -98,12 +104,12 @@ type MessagesData struct { } type KnownPeer struct { - ID string - Name string - GRPCURL string - HTTPLink template.HTML - HasCert bool - CertPEM string + ID string + Name string + GRPCURL string + HTTPURL string + HasCert bool + CertPEM string } type PeersData struct { @@ -112,7 +118,7 @@ type PeersData struct { } type KV struct { - Key template.HTML + Key string Value template.HTML Type string Version uint64 @@ -169,7 +175,7 @@ type GRPCChannelData struct { type GRPCStatusData struct { BaseData - ErrorMsg template.HTML + ErrorMsg string Servers []GRPCServerData Channels []GRPCChannelData } @@ -230,7 +236,15 @@ const ( {{.Time}} {{.Type}} {{.Key}} - {{.StatusHTML}} + + {{if not .Finished}} + Ongoing... + {{else if .Success}} + Success + {{else}} + Failed + {{end}} + {{.Result}} {{end}} @@ -395,28 +409,18 @@ func (s *HTTPServer) prepareParticipants(members map[string]state.PeerInfo, self var participants []Participant for _, id := range ids { - idWrapped := fmt.Sprintf(`
%s
`, html.EscapeString(wrapString(id, 40))) - label := idWrapped - if id == selfID { - label = fmt.Sprintf("%s self", idWrapped) - } info := members[id] - httpLink := "" - if info.HTTPURL != "" { - httpLink = fmt.Sprintf("%s", html.EscapeString(sanitizeURL(info.HTTPURL)), html.EscapeString(info.HTTPURL)) - } else { - httpLink = "N/A" - } grpcAddr := info.GRPCAddr if grpcAddr == "" { grpcAddr = "unknown" } participants = append(participants, Participant{ - IDLabel: template.HTML(label), + ID: id, + IsSelf: id == selfID, ShortName: info.ShortName, GRPCAddr: grpcAddr, - HTTPLink: template.HTML(httpLink), + HTTPURL: info.HTTPURL, }) } return participants @@ -449,7 +453,7 @@ func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) { data := IndexData{ BaseData: base, - AgentID: template.HTML(fmt.Sprintf(`
%s
`, html.EscapeString(wrapString(agentID, 40)))), + AgentID: agentID, KeysCount: len(kvs), Participants: s.prepareParticipants(members, agentID), } @@ -502,24 +506,17 @@ func (s *HTTPServer) handleMessages(w http.ResponseWriter, r *http.Request) { for _, id := range ids { info := members[id] - label := html.EscapeString(info.ShortName) - if id == agentID { - label = fmt.Sprintf("%s self", label) - } - grpcAddr := info.GRPCAddr if grpcAddr == "" { grpcAddr = "unknown" } - endpoints := fmt.Sprintf("%s", html.EscapeString(grpcAddr)) - if info.HTTPURL != "" { - endpoints += fmt.Sprintf(" | %s", html.EscapeString(sanitizeURL(info.HTTPURL)), html.EscapeString(info.HTTPURL)) - } data.Peers = append(data.Peers, Peer{ - Label: template.HTML(label), - AgentID: id, - Endpoints: template.HTML(endpoints), + Name: info.ShortName, + IsSelf: id == agentID, + AgentID: id, + GRPCAddr: grpcAddr, + HTTPURL: info.HTTPURL, }) } @@ -591,12 +588,6 @@ func (s *HTTPServer) handlePeers(w http.ResponseWriter, r *http.Request) { } grpcURL := fmt.Sprintf("grpc://%s", grpcAddr) - httpLink := "" - if info.HTTPURL != "" { - httpLink = fmt.Sprintf("%s", html.EscapeString(sanitizeURL(info.HTTPURL)), html.EscapeString(info.HTTPURL)) - } else { - httpLink = "N/A" - } certPEM := "" if len(info.Certificate) > 0 { if cert, err := identity.UnmarshalCertificate(info.Certificate); err == nil { @@ -605,12 +596,12 @@ func (s *HTTPServer) handlePeers(w http.ResponseWriter, r *http.Request) { } data.Peers = append(data.Peers, KnownPeer{ - ID: id, - Name: info.ShortName, - GRPCURL: grpcURL, - HTTPLink: template.HTML(httpLink), - HasCert: len(info.Certificate) > 0, - CertPEM: certPEM, + ID: id, + Name: info.ShortName, + GRPCURL: grpcURL, + HTTPURL: info.HTTPURL, + HasCert: len(info.Certificate) > 0, + CertPEM: certPEM, }) } @@ -640,7 +631,7 @@ func (s *HTTPServer) handleStore(w http.ResponseWriter, r *http.Request) { for _, kv := range kvs { data.KVs = append(data.KVs, KV{ - Key: template.HTML(fmt.Sprintf(`
%s
`, html.EscapeString(wrapString(kv.Key, 40)))), + Key: kv.Key, Value: template.HTML(maybeJSONToTable(kv.Value)), Type: kv.Type, Version: kv.Version, @@ -722,32 +713,26 @@ func (s *HTTPServer) handleUserAPI(w http.ResponseWriter, r *http.Request) { ongoingHTML := "" if len(ongoing) > 0 { type renderEntry struct { - Time string - Type string - Key string - StatusHTML template.HTML - Result string + Time string + Type string + Key string + Finished bool + Success bool + Result string } var entries []renderEntry for _, r := range ongoing { - status := "Ongoing..." - if r.Finished { - if r.Success { - status = "Success" - } else { - status = "Failed" - } - } resultText := r.Result if len(resultText) > 100 { resultText = resultText[:97] + "..." } entries = append(entries, renderEntry{ - Time: r.StartTime.Format("15:04:05"), - Type: r.Type, - Key: r.Key, - StatusHTML: template.HTML(status), - Result: resultText, + Time: r.StartTime.Format("15:04:05"), + Type: r.Type, + Key: r.Key, + Finished: r.Finished, + Success: r.Success, + Result: resultText, }) } var sb strings.Builder diff --git a/internal/server/http_server_test.go b/internal/server/http_server_test.go index ffc6ceb..c04cbeb 100644 --- a/internal/server/http_server_test.go +++ b/internal/server/http_server_test.go @@ -189,3 +189,61 @@ func TestHTTPServer_handleSystem(t *testing.T) { t.Errorf("expected 'Go Runtime Statistics' in body") } } + +func TestHTTPServer_XSSProtection(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Inject malicious peer info + maliciousID := "attacker-id" + maliciousName := "Attacker" + maliciousURL := "javascript:alert('xss')" + + server.store.UpdatePeerInfo(maliciousID, state.PeerInfo{ + ShortName: maliciousName, + HTTPURL: maliciousURL, + GRPCAddr: "127.0.0.1:50051", + }) + + t.Run("IndexPage", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + server.handleIndex(w, req) + body := w.Body.String() + + if strings.Contains(body, maliciousID) { + t.Errorf("Body contains raw malicious ID, should be escaped") + } + if strings.Contains(body, maliciousName) { + t.Errorf("Body contains raw malicious Name, should be escaped") + } + if strings.Contains(body, maliciousURL) && !strings.Contains(body, "#") { + t.Errorf("Body contains raw malicious URL, should be sanitized to #") + } + + escapedID := html.EscapeString(maliciousID) + if !strings.Contains(body, escapedID) { + // Note: wrapString might break the escaped ID if not careful, but let's check for pieces + t.Logf("Checking for escaped ID: %s", escapedID) + } + escapedName := html.EscapeString(maliciousName) + if !strings.Contains(body, escapedName) { + t.Errorf("Body does not contain escaped Name: %s", escapedName) + } + }) + + t.Run("PeersPage", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/peers", nil) + w := httptest.NewRecorder() + server.handlePeers(w, req) + body := w.Body.String() + + escapedName := html.EscapeString(maliciousName) + if !strings.Contains(body, escapedName) { + t.Errorf("Body does not contain escaped Name: %s", escapedName) + } + if strings.Contains(body, maliciousURL) && !strings.Contains(body, "href=\"#\"") { + t.Errorf("Malicious URL not sanitized in Peers page") + } + }) +} diff --git a/internal/server/templates/grpc.html b/internal/server/templates/grpc.html index 8148015..86c0a07 100644 --- a/internal/server/templates/grpc.html +++ b/internal/server/templates/grpc.html @@ -46,7 +46,7 @@
{{if .ErrorMsg}} - {{.ErrorMsg}} +
{{.ErrorMsg}}
{{end}}
diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index ddba692..b6782e8 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -50,7 +50,7 @@
Agent Info
-

ID: {{.AgentID}}

+

ID:

{{wrapString .AgentID 40}}

Name: {{.AgentName}}

Keys Count: {{.KeysCount}}


@@ -75,10 +75,21 @@ {{range .Participants}} - {{.IDLabel}} + + +
{{wrapString .ID 40}}
+ {{if .IsSelf}}self{{end}} +
+ {{.ShortName}} {{.GRPCAddr}} - {{.HTTPLink}} + + {{if .HTTPURL}} + {{.HTTPURL}} + {{else}} + N/A + {{end}} + {{end}} diff --git a/internal/server/templates/messages.html b/internal/server/templates/messages.html index 4c4f10d..7c5d575 100644 --- a/internal/server/templates/messages.html +++ b/internal/server/templates/messages.html @@ -58,9 +58,17 @@ {{range .Peers}} - {{.Label}} + + {{.Name}} + {{if .IsSelf}}self{{end}} + {{.AgentID}} - {{.Endpoints}} + + {{.GRPCAddr}} + {{if .HTTPURL}} + | {{.HTTPURL}} + {{end}} + {{end}} diff --git a/internal/server/templates/peers.html b/internal/server/templates/peers.html index b5e0945..5077045 100644 --- a/internal/server/templates/peers.html +++ b/internal/server/templates/peers.html @@ -65,7 +65,13 @@ {{.ID}} {{.Name}} {{.GRPCURL}} - {{.HTTPLink}} + + {{if .HTTPURL}} + {{.HTTPURL}} + {{else}} + N/A + {{end}} + {{if .HasCert}} Certificate diff --git a/internal/server/templates/store.html b/internal/server/templates/store.html index 41e32e4..d1db516 100644 --- a/internal/server/templates/store.html +++ b/internal/server/templates/store.html @@ -61,7 +61,7 @@ {{range $index, $kv := .KVs}} - {{$kv.Key}} +
{{wrapString $kv.Key 40}}