feat(htmlsanitize): add HTML and JSON sanitization utilities with integration across plugins and APIs

- Introduced `htmlsanitize` package for escaping HTML and handling JSON body sanitization to prevent XSS vulnerabilities.
- Integrated sanitization functions into plugin store, plugin host, and API management handlers to ensure all user-facing content is escaped.
- Added unit tests to verify proper escaping of HTML strings, JSON bodies, and nested data structures.
- Updated existing management and plugin-related tests to validate sanitization implementations.
This commit is contained in:
Luis Pater
2026-06-13 01:10:27 +08:00
parent 60f6a54282
commit 44d3066a9c
8 changed files with 382 additions and 35 deletions

View File

@@ -9,6 +9,7 @@ import (
"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 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
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 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
c.JSON(http.StatusOK, pluginStoreListResponse{
PluginsEnabled: pluginsEnabled,
PluginsDir: pluginsDir,
PluginsDir: htmlsanitize.String(pluginsDir),
Plugins: entries,
})
}
@@ -217,9 +218,9 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
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,
})

View File

@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"html"
"io"
"net/http"
"net/http/httptest"
@@ -79,6 +80,72 @@ func TestListPluginStoreMergesInstalledStatus(t *testing.T) {
}
}
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": "<script>alert(1)</script>",
"description": "<img src=x onerror=alert(1)>",
"author": "\"attacker\"",
"version": "0.1.0",
"repository": "https://github.com/author-name/cliproxy-sample-provider-plugin",
"logo": "<svg onload=alert(1)>",
"homepage": "https://example.com/?q=<x>",
"license": "<b>MIT</b>",
"tags": ["<provider>", "safe & sound"]
}]
}`),
},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugin-store", nil)
h.ListPluginStore(c)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var body pluginStoreListResponse
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
}
if len(body.Plugins) != 1 {
t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
}
entry := body.Plugins[0]
if entry.Name != html.EscapeString("<script>alert(1)</script>") ||
entry.Description != html.EscapeString("<img src=x onerror=alert(1)>") ||
entry.Author != html.EscapeString(`"attacker"`) ||
entry.Version != "0.1.0" ||
entry.Repository != "https://github.com/author-name/cliproxy-sample-provider-plugin" ||
entry.Logo != html.EscapeString("<svg onload=alert(1)>") ||
entry.Homepage != html.EscapeString("https://example.com/?q=<x>") ||
entry.License != html.EscapeString("<b>MIT</b>") {
t.Fatalf("store entry = %#v, want escaped strings", entry)
}
if len(entry.Tags) != 2 ||
entry.Tags[0] != html.EscapeString("<provider>") ||
entry.Tags[1] != html.EscapeString("safe & sound") {
t.Fatalf("tags = %#v, want escaped strings", entry.Tags)
}
}
func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)

View File

@@ -10,6 +10,7 @@ import (
"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/sdk/pluginapi"
"gopkg.in/yaml.v3"
@@ -85,8 +86,8 @@ func (h *Handler) ListPlugins(c *gin.Context) {
}
for _, file := range files {
entries[file.ID] = pluginListEntry{
ID: file.ID,
Path: file.Path,
ID: htmlsanitize.String(file.ID),
Path: htmlsanitize.String(file.Path),
Enabled: true,
ConfigFields: []pluginConfigFieldInfo{},
Menus: []pluginMenuInfo{},
@@ -94,7 +95,7 @@ func (h *Handler) ListPlugins(c *gin.Context) {
}
for id, item := range configs {
entry := entries[id]
entry.ID = id
entry.ID = htmlsanitize.String(id)
entry.Configured = true
entry.Enabled = pluginInstanceEnabled(item)
if entry.ConfigFields == nil {
@@ -108,10 +109,10 @@ func (h *Handler) ListPlugins(c *gin.Context) {
if host != nil {
for _, info := range host.RegisteredPlugins() {
entry := entries[info.ID]
entry.ID = info.ID
entry.ID = htmlsanitize.String(info.ID)
entry.Registered = true
entry.SupportsOAuth = info.SupportsOAuth
entry.Logo = info.Metadata.Logo
entry.Logo = htmlsanitize.String(info.Metadata.Logo)
entry.ConfigFields = pluginConfigFields(info.Metadata.ConfigFields)
entry.Menus = pluginMenus(info.Menus)
entry.Metadata = pluginMetadata(info.Metadata)
@@ -143,7 +144,7 @@ func (h *Handler) ListPlugins(c *gin.Context) {
c.JSON(http.StatusOK, pluginListResponse{
PluginsEnabled: pluginsEnabled,
PluginsDir: pluginsDir,
PluginsDir: htmlsanitize.String(pluginsDir),
Plugins: out,
})
}
@@ -265,12 +266,11 @@ func pluginInstanceEnabled(item config.PluginInstanceConfig) bool {
func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo {
out := make([]pluginConfigFieldInfo, 0, len(fields))
for _, field := range fields {
enumValues := append([]string{}, field.EnumValues...)
out = append(out, pluginConfigFieldInfo{
Name: field.Name,
Type: string(field.Type),
EnumValues: enumValues,
Description: field.Description,
Name: htmlsanitize.String(field.Name),
Type: htmlsanitize.String(string(field.Type)),
EnumValues: htmlsanitize.Strings(field.EnumValues),
Description: htmlsanitize.String(field.Description),
})
}
return out
@@ -280,9 +280,9 @@ func pluginMenus(menus []pluginhost.RegisteredPluginMenu) []pluginMenuInfo {
out := make([]pluginMenuInfo, 0, len(menus))
for _, menu := range menus {
out = append(out, pluginMenuInfo{
Path: menu.Path,
Menu: menu.Menu,
Description: menu.Description,
Path: htmlsanitize.String(menu.Path),
Menu: htmlsanitize.String(menu.Menu),
Description: htmlsanitize.String(menu.Description),
})
}
return out
@@ -290,11 +290,11 @@ func pluginMenus(menus []pluginhost.RegisteredPluginMenu) []pluginMenuInfo {
func pluginMetadata(meta pluginapi.Metadata) *pluginMetadataInfo {
return &pluginMetadataInfo{
Name: meta.Name,
Version: meta.Version,
Author: meta.Author,
GitHubRepository: meta.GitHubRepository,
Logo: meta.Logo,
Name: htmlsanitize.String(meta.Name),
Version: htmlsanitize.String(meta.Version),
Author: htmlsanitize.String(meta.Author),
GitHubRepository: htmlsanitize.String(meta.GitHubRepository),
Logo: htmlsanitize.String(meta.Logo),
ConfigFields: pluginConfigFields(meta.ConfigFields),
}
}

View File

@@ -3,6 +3,7 @@ package management
import (
"bytes"
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"os"
@@ -13,6 +14,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
"gopkg.in/yaml.v3"
)
@@ -211,6 +214,58 @@ func TestPatchPluginConfigMergesAndDeletesFields(t *testing.T) {
}
}
func TestPluginDisplayFieldsEscapeHTML(t *testing.T) {
t.Parallel()
fields := pluginConfigFields([]pluginapi.ConfigField{{
Name: `<img src=x onerror=alert(1)>`,
Type: pluginapi.ConfigFieldTypeEnum,
EnumValues: []string{`<fast>`, `safe & sound`},
Description: `"quoted" 'single' <b>mode</b>`,
}})
if len(fields) != 1 {
t.Fatalf("fields len = %d, want 1", len(fields))
}
if fields[0].Name != html.EscapeString(`<img src=x onerror=alert(1)>`) {
t.Fatalf("field name = %q, want escaped", fields[0].Name)
}
if fields[0].EnumValues[0] != html.EscapeString(`<fast>`) || fields[0].EnumValues[1] != html.EscapeString(`safe & sound`) {
t.Fatalf("enum values = %#v, want escaped values", fields[0].EnumValues)
}
if fields[0].Description != html.EscapeString(`"quoted" 'single' <b>mode</b>`) {
t.Fatalf("description = %q, want escaped", fields[0].Description)
}
menus := pluginMenus([]pluginhost.RegisteredPluginMenu{{
Path: `/v0/resource/plugins/sample/<status>`,
Menu: `<b>Status</b>`,
Description: `Shows <script>alert(1)</script>.`,
}})
if len(menus) != 1 {
t.Fatalf("menus len = %d, want 1", len(menus))
}
if menus[0].Path != html.EscapeString(`/v0/resource/plugins/sample/<status>`) ||
menus[0].Menu != html.EscapeString(`<b>Status</b>`) ||
menus[0].Description != html.EscapeString(`Shows <script>alert(1)</script>.`) {
t.Fatalf("menu = %#v, want escaped strings", menus[0])
}
meta := pluginMetadata(pluginapi.Metadata{
Name: `<script>alert(1)</script>`,
Version: `1.0.0&evil=true`,
Author: `"attacker"`,
GitHubRepository: `https://example.com/repo?x=<script>`,
Logo: `<svg onload=alert(1)>`,
})
if meta.Name != html.EscapeString(`<script>alert(1)</script>`) ||
meta.Version != html.EscapeString(`1.0.0&evil=true`) ||
meta.Author != html.EscapeString(`"attacker"`) ||
meta.GitHubRepository != html.EscapeString(`https://example.com/repo?x=<script>`) ||
meta.Logo != html.EscapeString(`<svg onload=alert(1)>`) {
t.Fatalf("metadata = %#v, want escaped strings", meta)
}
}
func writeManagementPluginFile(t *testing.T, id string) string {
t.Helper()
root := t.TempDir()

View File

@@ -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] == '['
}

View File

@@ -0,0 +1,55 @@
package htmlsanitize
import (
"bytes"
"encoding/json"
"html"
"testing"
)
func TestJSONBodyEscapesStringValues(t *testing.T) {
t.Parallel()
got, ok := JSONBody([]byte(`{"title":"<script>alert(1)</script>","items":["safe & sound",{"description":"<b>mode</b>"}],"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("<script>alert(1)</script>") {
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("<b>mode</b>") {
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("<!doctype html><title>plugin</title>")
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))
}
}

View File

@@ -8,6 +8,7 @@ import (
"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 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool
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 {
@@ -330,6 +332,14 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute
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) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return pluginapi.ManagementResponse{}, nil

View File

@@ -2,6 +2,8 @@ package pluginhost
import (
"context"
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"testing"
@@ -63,6 +65,63 @@ func TestRegisterManagementRoutesSkipsReservedAndUsesPriority(t *testing.T) {
}
}
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": "<script>alert(1)</script>",
"items": ["<b>first</b>", {"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("<script>alert(1)</script>") {
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("<b>first</b>") {
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"])
}
}
func TestManagementHandlerPanicFusesPlugin(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "panic",