diff --git a/internal/htmlsanitize/htmlsanitize.go b/internal/htmlsanitize/htmlsanitize.go
new file mode 100644
--- /dev/null
+++ b/internal/htmlsanitize/htmlsanitize.go
@@ -0,0 +1,100 @@
+package htmlsanitize
+
+import (
+ "bytes"
+ "encoding/json"
+ "html"
+ "io"
+ "mime"
+ "strings"
+)
+
+// String escapes text before it is returned to browser-facing management clients.
+func String(value string) string {
+ return html.EscapeString(value)
+}
+
+// Strings escapes each string in values while preserving order.
+func Strings(values []string) []string {
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ out = append(out, String(value))
+ }
+ return out
+}
+
+// JSONBody escapes all string values in a JSON document.
+func JSONBody(body []byte) ([]byte, bool) {
+ trimmed := bytes.TrimSpace(body)
+ if len(trimmed) == 0 {
+ return body, false
+ }
+
+ decoder := json.NewDecoder(bytes.NewReader(trimmed))
+ decoder.UseNumber()
+ var value any
+ if errDecode := decoder.Decode(&value); errDecode != nil {
+ return body, false
+ }
+ var extra any
+ if errExtra := decoder.Decode(&extra); errExtra != io.EOF {
+ return body, false
+ }
+
+ var buffer bytes.Buffer
+ encoder := json.NewEncoder(&buffer)
+ encoder.SetEscapeHTML(false)
+ if errEncode := encoder.Encode(JSONValue(value)); errEncode != nil {
+ return body, false
+ }
+ return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), true
+}
+
+// JSONBodyIfLikely escapes JSON bodies when the content type or body shape indicates JSON.
+func JSONBodyIfLikely(body []byte, contentType string) ([]byte, bool) {
+ if IsJSONContentType(contentType) || LooksLikeJSON(body) {
+ return JSONBody(body)
+ }
+ return body, false
+}
+
+// JSONValue recursively escapes string values in JSON-compatible data.
+func JSONValue(value any) any {
+ switch typed := value.(type) {
+ case string:
+ return String(typed)
+ case []any:
+ out := make([]any, len(typed))
+ for index, item := range typed {
+ out[index] = JSONValue(item)
+ }
+ return out
+ case map[string]any:
+ out := make(map[string]any, len(typed))
+ for key, item := range typed {
+ out[key] = JSONValue(item)
+ }
+ return out
+ default:
+ return value
+ }
+}
+
+// IsJSONContentType reports whether contentType is application/json or a +json type.
+func IsJSONContentType(contentType string) bool {
+ mediaType, _, errParse := mime.ParseMediaType(strings.TrimSpace(contentType))
+ if errParse != nil {
+ mediaType = strings.TrimSpace(contentType)
+ }
+ mediaType = strings.ToLower(mediaType)
+ return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")
+}
+
+// LooksLikeJSON reports whether body starts with an object or array JSON marker.
+func LooksLikeJSON(body []byte) bool {
+ trimmed := bytes.TrimSpace(body)
+ if len(trimmed) == 0 {
+ return false
+ }
+ return trimmed[0] == '{' || trimmed[0] == '['
+}
diff --git a/internal/htmlsanitize/htmlsanitize_test.go b/internal/htmlsanitize/htmlsanitize_test.go
new file mode 100644
--- /dev/null
+++ b/internal/htmlsanitize/htmlsanitize_test.go
@@ -0,0 +1,55 @@
+package htmlsanitize
+
+import (
+ "bytes"
+ "encoding/json"
+ "html"
+ "testing"
+)
+
+func TestJSONBodyEscapesStringValues(t *testing.T) {
+ t.Parallel()
+
+ got, ok := JSONBody([]byte(`{"title":"","items":["safe & sound",{"description":"mode"}],"count":1}`))
+ if !ok {
+ t.Fatal("JSONBody() ok = false, want true")
+ }
+
+ var body map[string]any
+ if errUnmarshal := json.Unmarshal(got, &body); errUnmarshal != nil {
+ t.Fatalf("Unmarshal() error = %v; body=%s", errUnmarshal, string(got))
+ }
+ if body["title"] != html.EscapeString("") {
+ t.Fatalf("title = %q, want escaped", body["title"])
+ }
+ items, okItems := body["items"].([]any)
+ if !okItems || len(items) != 2 {
+ t.Fatalf("items = %#v, want two items", body["items"])
+ }
+ if items[0] != html.EscapeString("safe & sound") {
+ t.Fatalf("items[0] = %q, want escaped", items[0])
+ }
+ nested, okNested := items[1].(map[string]any)
+ if !okNested {
+ t.Fatalf("items[1] = %#v, want object", items[1])
+ }
+ if nested["description"] != html.EscapeString("mode") {
+ t.Fatalf("description = %q, want escaped", nested["description"])
+ }
+ if body["count"] != float64(1) {
+ t.Fatalf("count = %#v, want unchanged number", body["count"])
+ }
+}
+
+func TestJSONBodyIfLikelySkipsNonJSONHTML(t *testing.T) {
+ t.Parallel()
+
+ body := []byte("
plugin")
+ got, ok := JSONBodyIfLikely(body, "text/html; charset=utf-8")
+ if ok {
+ t.Fatal("JSONBodyIfLikely() ok = true, want false")
+ }
+ if !bytes.Equal(got, body) {
+ t.Fatalf("body = %q, want unchanged %q", string(got), string(body))
+ }
+}
diff --git a/internal/pluginhost/management.go b/internal/pluginhost/management.go
--- a/internal/pluginhost/management.go
+++ b/internal/pluginhost/management.go
@@ -8,6 +8,7 @@
"net/http"
"strings"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/htmlsanitize"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
@@ -255,6 +256,7 @@
http.Error(w, "plugin management handler failed", http.StatusBadGateway)
return true
}
+ resp.Body = escapeManagementResponseBody(resp)
for keyHeader, values := range resp.Headers {
for _, value := range values {
@@ -328,6 +330,14 @@
}
}()
return record.route.Handler.HandleManagement(ctx, req)
+}
+
+func escapeManagementResponseBody(resp pluginapi.ManagementResponse) []byte {
+ body, okEscaped := htmlsanitize.JSONBodyIfLikely(resp.Body, resp.Headers.Get("Content-Type"))
+ if !okEscaped {
+ return resp.Body
+ }
+ return body
}
func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
diff --git a/internal/pluginhost/management_test.go b/internal/pluginhost/management_test.go
--- a/internal/pluginhost/management_test.go
+++ b/internal/pluginhost/management_test.go
@@ -2,6 +2,8 @@
import (
"context"
+ "encoding/json"
+ "html"
"net/http"
"net/http/httptest"
"testing"
@@ -60,6 +62,63 @@
rec = httptest.NewRecorder()
if host.ServeManagementHTTP(rec, req) {
t.Fatal("reserved route was served by plugin")
+ }
+}
+
+func TestServeManagementHTMLEscapesJSONResponseStrings(t *testing.T) {
+ host := newHostWithRecords(capabilityRecord{
+ id: "json",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
+ Method: http.MethodGet,
+ Path: "/plugins/json/status",
+ Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
+ return pluginapi.ManagementResponse{
+ Headers: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
+ Body: []byte(`{
+ "title": "",
+ "items": ["first", {"description": "safe & sound"}],
+ "count": 1
+ }`),
+ }, nil
+ }),
+ }}},
+ }},
+ })
+ host.RegisterManagementRoutes(context.Background(), nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/json/status", nil)
+ rec := httptest.NewRecorder()
+ if !host.ServeManagementHTTP(rec, req) {
+ t.Fatal("ServeManagementHTTP() = false, want true")
+ }
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+
+ var body map[string]any
+ if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
+ t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
+ }
+ if body["title"] != html.EscapeString("") {
+ t.Fatalf("title = %q, want escaped", body["title"])
+ }
+ items, okItems := body["items"].([]any)
+ if !okItems || len(items) != 2 {
+ t.Fatalf("items = %#v, want two items", body["items"])
+ }
+ if items[0] != html.EscapeString("first") {
+ t.Fatalf("items[0] = %q, want escaped", items[0])
+ }
+ nested, okNested := items[1].(map[string]any)
+ if !okNested {
+ t.Fatalf("items[1] = %#v, want object", items[1])
+ }
+ if nested["description"] != html.EscapeString("safe & sound") {
+ t.Fatalf("nested description = %q, want escaped", nested["description"])
+ }
+ if body["count"] != float64(1) {
+ t.Fatalf("count = %#v, want unchanged number", body["count"])
}
}
diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go
--- a/internal/api/handlers/management/plugin_store.go
+++ b/internal/api/handlers/management/plugin_store.go
@@ -9,6 +9,7 @@
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/htmlsanitize"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
@@ -81,19 +82,19 @@
status := statuses[plugin.ID]
installedVersion := status.InstalledVersion
entries = append(entries, pluginStoreListEntry{
- ID: plugin.ID,
- Name: plugin.Name,
- Description: plugin.Description,
- Author: plugin.Author,
- Version: plugin.Version,
- Repository: plugin.Repository,
- Logo: plugin.Logo,
- Homepage: plugin.Homepage,
- License: plugin.License,
- Tags: append([]string{}, plugin.Tags...),
+ ID: htmlsanitize.String(plugin.ID),
+ Name: htmlsanitize.String(plugin.Name),
+ Description: htmlsanitize.String(plugin.Description),
+ Author: htmlsanitize.String(plugin.Author),
+ Version: htmlsanitize.String(plugin.Version),
+ Repository: htmlsanitize.String(plugin.Repository),
+ Logo: htmlsanitize.String(plugin.Logo),
+ Homepage: htmlsanitize.String(plugin.Homepage),
+ License: htmlsanitize.String(plugin.License),
+ Tags: htmlsanitize.Strings(plugin.Tags),
Installed: status.Installed,
- InstalledVersion: installedVersion,
- Path: status.Path,
+ InstalledVersion: htmlsanitize.String(installedVersion),
+ Path: htmlsanitize.String(status.Path),
Configured: status.Configured,
Registered: status.Registered,
Enabled: status.Enabled,
@@ -104,7 +105,7 @@
c.JSON(http.StatusOK, pluginStoreListResponse{
PluginsEnabled: pluginsEnabled,
- PluginsDir: pluginsDir,
+ PluginsDir: htmlsanitize.String(pluginsDir),
Plugins: entries,
})
}
@@ -217,9 +218,9 @@
c.JSON(http.StatusOK, pluginInstallResponse{
Status: "installed",
- ID: result.ID,
- Version: result.Version,
- Path: result.Path,
+ ID: htmlsanitize.String(result.ID),
+ Version: htmlsanitize.String(result.Version),
+ Path: htmlsanitize.String(result.Path),
PluginsEnabled: pluginsEnabled,
RestartRequired: restartRequired,
})
diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go
--- a/internal/api/handlers/management/plugin_store_test.go
+++ b/internal/api/handlers/management/plugin_store_test.go
@@ -7,6 +7,7 @@
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "html"
"io"
"net/http"
"net/http/httptest"
@@ -76,6 +77,72 @@
}
if entry.Path == "" {
t.Fatal("path is empty")
+ }
+}
+
+func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
+ t.Parallel()
+ gin.SetMode(gin.TestMode)
+
+ h := &Handler{
+ cfg: &config.Config{
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: t.TempDir(),
+ },
+ },
+ configFilePath: writeTestConfigFile(t),
+ pluginStoreRegistryURL: "https://registry.example/registry.json",
+ pluginStoreHTTPClient: fakePluginStoreHTTPClient{
+ "https://registry.example/registry.json": []byte(`{
+ "schema_version": 1,
+ "plugins": [{
+ "id": "sample-provider",
+ "name": "",
+ "description": "
",
+ "author": "\"attacker\"",
+ "version": "0.1.0",
+ "repository": "https://github.com/author-name/cliproxy-sample-provider-plugin",
+ "logo": "