Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@
mux.HandlePath(http.MethodGet, "/healthz", frontEndHandlerWithLocation(o.consolePath))
mux.HandlePath(http.MethodGet, "/favicon.ico", frontEndHandlerWithLocation(o.consolePath))
mux.HandlePath(http.MethodGet, "/swagger.json", frontEndHandlerWithLocation(o.consolePath))
mux.HandlePath(http.MethodGet, "/data/{data}", o.dataFromExtension(remoteServer.(server.UIExtensionServer)))
mux.HandlePath(http.MethodGet, "/get", o.getAtestBinary)
mux.HandlePath(http.MethodPost, "/runner/{suite}/{case}", service.WebRunnerHandler)
mux.HandlePath(http.MethodGet, "/api/v1/sbom", service.SBomHandler)
Expand Down Expand Up @@ -531,6 +532,27 @@
})
}

func (o *serverOption) dataFromExtension(extServer server.UIExtensionServer) func(w http.ResponseWriter,
r *http.Request, pathParams map[string]string) {
return func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
ctx := r.Context()
for k, v := range r.Header {
if !strings.HasPrefix(k, "X-Extension-") {
continue
}
ctx = context.WithValue(ctx, k, v)
}
result, err := extServer.GetPageOfStatic(ctx, &server.SimpleName{
Name: pathParams["data"],
})
if err == nil {
w.Write([]byte(result.GetMessage()))

Check warning

Code scanning / CodeQL

Reflected cross-site scripting Medium

Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 2 months ago

To address the XSS vulnerability, we should escape any untrusted data before embedding it in an HTTP response likely to be rendered as HTML by browsers. In this case, the output from result.GetMessage() should be HTML-escaped, especially since extension code receives arbitrary header values from the user and could include them in the message.

Best fix:

  • In the handler function, change the line that writes the message to the response to write the HTML-escaped version of the message.
  • Use Go's standard html.EscapeString() function (import "html" package) for HTML escaping.
  • Only the relevant region of cmd/server.go needs to be changed: around line 549, and a new import added at the top.

No changes are required to the proto or server files; only the handler that produces the HTTP response needs adjusting.

Suggested changeset 1
cmd/server.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/cmd/server.go b/cmd/server.go
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -32,7 +32,7 @@
 	"strings"
 	"syscall"
 	"time"
-
+	"html"
 	"github.com/linuxsuren/api-testing/pkg/apispec"
 
 	"github.com/linuxsuren/api-testing/pkg/runner"
@@ -546,9 +546,9 @@
 			Name: pathParams["data"],
 		})
 		if err == nil {
-			w.Write([]byte(result.GetMessage()))
+			w.Write([]byte(html.EscapeString(result.GetMessage())))
 		} else {
-			w.Write([]byte(err.Error()))
+			w.Write([]byte(html.EscapeString(err.Error())))
 		}
 	}
 }
EOF
@@ -32,7 +32,7 @@
"strings"
"syscall"
"time"

"html"
"github.com/linuxsuren/api-testing/pkg/apispec"

"github.com/linuxsuren/api-testing/pkg/runner"
@@ -546,9 +546,9 @@
Name: pathParams["data"],
})
if err == nil {
w.Write([]byte(result.GetMessage()))
w.Write([]byte(html.EscapeString(result.GetMessage())))
} else {
w.Write([]byte(err.Error()))
w.Write([]byte(html.EscapeString(err.Error())))
}
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
} else {
w.Write([]byte(err.Error()))
}
}
}

func (o *serverOption) getAtestBinary(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
name := util.EmptyThenDefault(r.URL.Query().Get("name"), "atest")

Expand Down
29 changes: 28 additions & 1 deletion cmd/server_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023-2024 API Testing Authors.
Copyright 2023-2025 API Testing Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -145,6 +145,15 @@ func TestFrontEndHandlerWithLocation(t *testing.T) {
assert.Equal(t, "ok", resp.GetBody().String())
})

t.Run("swagger", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/swagger.json", nil)
assert.NoError(t, err)

resp := newFakeResponseWriter()
handler(resp, req, map[string]string{})
assert.Equal(t, string(server.SwaggerJSON), resp.GetBody().String())
})

t.Run("pprof", func(t *testing.T) {
apis := []string{"", "cmdline", "symbol",
"trace", "profile",
Expand Down Expand Up @@ -339,6 +348,24 @@ func TestStartPlugins(t *testing.T) {
})
}

func TestDataFromExtension(t *testing.T) {
opt := &serverOption{}

handler := opt.dataFromExtension(server.UnimplementedUIExtensionServer{})
t.Run("not found", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/data/fake", nil)
req.Header.Set("X-Extension-Fake", "fake")
req.Header.Set("Content-Type", "application/json")
assert.NoError(t, err)

resp := newFakeResponseWriter()
handler(resp, req, map[string]string{
"data": "fake",
})
assert.Contains(t, resp.GetBody().String(), "not implemented")
})
}

type fakeResponseWriter struct {
buf *bytes.Buffer
header http.Header
Expand Down
4 changes: 4 additions & 0 deletions console/atest-ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export default defineConfig({
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/data': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})
53 changes: 46 additions & 7 deletions pkg/server/remote_server.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2023-2024 API Testing Authors.
Copyright 2023-2025 API Testing Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -1405,9 +1405,12 @@ func (s *server) GetTheme(ctx context.Context, in *SimpleName) (result *CommonRe
loader := s.getLoader(ctx)
defer loader.Close()

result = &CommonResult{}
result = &CommonResult{
Success: true,
}
result.Message, err = loader.GetTheme(in.Name)
if err != nil {
result.Success = false
result.Message = fmt.Sprintf("failed to get theme: %v", err)
}
return
Expand All @@ -1420,9 +1423,9 @@ func (s *server) GetBindings(ctx context.Context, _ *Empty) (result *SimpleList,
result = &SimpleList{}
var bindings []string
if bindings, err = loader.GetBindings(); err == nil {
for _, theme := range bindings {
for _, binding := range bindings {
result.Data = append(result.Data, &Pair{
Key: theme,
Key: binding,
Value: "",
})
}
Expand All @@ -1434,9 +1437,12 @@ func (s *server) GetBinding(ctx context.Context, in *SimpleName) (result *Common
loader := s.getLoader(ctx)
defer loader.Close()

result = &CommonResult{}
result = &CommonResult{
Success: true,
}
result.Message, err = loader.GetBinding(in.Name)
if err != nil {
result.Success = false
result.Message = fmt.Sprintf("failed to get binding: %v", err)
}
return
Expand Down Expand Up @@ -1531,6 +1537,36 @@ func (s *server) GetPageOfCSS(ctx context.Context, in *SimpleName) (result *Comm
return
}

func (s *server) GetPageOfStatic(ctx context.Context, in *SimpleName) (result *CommonResult, err error) {
result = &CommonResult{}
extNameInter := ctx.Value("X-Extension-Name")
if extNameInter == nil {
result.Message = "X-Extension-Name is required"
result.Success = false
return
}

var extName string
switch v := extNameInter.(type) {
case []string:
extName = v[0]
case string:
extName = v
}

if loader, ok := uiExtensionLoaders[extName]; ok {
if js, err := loader.GetPageOfStatic(in.Name); err == nil {
result.Message = js
result.Success = true
} else {
result.Message = err.Error()
}
} else {
result.Message = fmt.Sprintf("not found loader for %s", extName)
}
return
}

// implement the mock server

// Start starts the mock server
Expand Down Expand Up @@ -1561,9 +1597,12 @@ func (s *mockServerController) Reload(ctx context.Context, in *MockConfig) (repl
case "memory":
s.mockWriter = mock.NewInMemoryReader(in.Config)
case "localFile":
if in.StoreLocalFile == "" {
return nil, errors.New("StoreLocalFile is required")
}
s.mockWriter = mock.NewLocalFileReader(in.StoreLocalFile)
case "remote":
case "url":
default:
return nil, fmt.Errorf("unsupported store kind: %s", in.StoreKind)
}
s.config = in

Expand Down
117 changes: 117 additions & 0 deletions pkg/server/remote_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
_ "embed"

"github.com/h2non/gock"
"github.com/linuxsuren/api-testing/pkg/mock"
atest "github.com/linuxsuren/api-testing/pkg/testing"
"github.com/linuxsuren/api-testing/pkg/util"
"github.com/linuxsuren/api-testing/sample"
Expand Down Expand Up @@ -966,6 +967,10 @@ func getRemoteServerInTempDir() (server RunnerServer, call func()) {
os.MkdirAll(themePath, 0755)
os.WriteFile(filepath.Join(themePath, "simple.json"), []byte(simplePostman), 0755)

bindinPath := filepath.Join(dir, "data", "key-binding")
os.MkdirAll(bindinPath, 0755)
os.WriteFile(filepath.Join(bindinPath, "default.json"), binding, 0755)

writer := atest.NewFileWriter(dir)
server = NewRemoteServer(writer, newLocalloaderFromStore(), nil, nil, dir, 1024*1024*4)
return
Expand All @@ -992,6 +997,9 @@ var simpleTestCase string
//go:embed testdata/postman.json
var simplePostman string

//go:embed testdata/keybinding.json
var binding []byte

const urlFoo = "http://foo"

type fakeServerStream struct {
Expand Down Expand Up @@ -1079,4 +1087,113 @@ func TestGetThemes(t *testing.T) {
})
assert.NoError(t, err)
assert.NotNil(t, theme)

theme, err = themeServer.GetTheme(context.Background(), &SimpleName{
Name: "not-exist",
})
assert.Error(t, err)
assert.False(t, theme.Success)
}

func TestKeybinding(t *testing.T) {
server, clean := getRemoteServerInTempDir()
defer clean()

themeServer, ok := server.(ThemeExtensionServer)
assert.True(t, ok)

reply, err := themeServer.GetBindings(context.Background(), &Empty{})
assert.NoError(t, err)
assert.Equal(t, 1, len(reply.Data))

var theme *CommonResult
theme, err = themeServer.GetBinding(context.Background(), &SimpleName{
Name: "default",
})
assert.NoError(t, err)
assert.NotNil(t, theme)

theme, err = themeServer.GetBinding(context.Background(), &SimpleName{
Name: "not-exist",
})
assert.Error(t, err)
assert.False(t, theme.Success)
}

func TestMockServer(t *testing.T) {
loader := mock.NewInMemoryServer(context.Background(), 0)
loader.SetupHandler(mock.NewInMemoryReader(""), "/")
mockServer := NewMockServerController(nil, loader, 0)

t.Run("reload as unsupported kind", func(t *testing.T) {
_, err := mockServer.Reload(context.Background(), &MockConfig{})
assert.Error(t, err)
})

t.Run("reload as memory kind", func(t *testing.T) {
_, err := mockServer.Reload(context.Background(), &MockConfig{
StoreKind: "memory",
})
assert.NoError(t, err)
})

t.Run("reload as localFile kind, but file is empty", func(t *testing.T) {
_, err := mockServer.Reload(context.Background(), &MockConfig{
StoreKind: "localFile",
})
assert.Error(t, err)
})

t.Run("reload as localFile kind, invalid file", func(t *testing.T) {
_, err := mockServer.Reload(context.Background(), &MockConfig{
StoreKind: "localFile",
StoreLocalFile: "testdata/simple.yaml",
})
assert.Error(t, err)
})

t.Run("reload as localFile kind", func(t *testing.T) {
_, err := mockServer.Reload(context.Background(), &MockConfig{
StoreKind: "localFile",
StoreLocalFile: "testdata/simple_mock.yaml",
})
assert.NoError(t, err)

config, err := mockServer.GetConfig(context.Background(), &Empty{})
assert.NoError(t, err)
assert.Equal(t, "localFile", config.StoreKind)
})
}

func TestUIExtension(t *testing.T) {
server, clean := getRemoteServerInTempDir()
defer clean()

uiServer, ok := server.(UIExtensionServer)
assert.True(t, ok)
assert.NotNil(t, uiServer)

ctx := context.Background()
menuList, err := uiServer.GetMenus(ctx, &Empty{})
assert.NoError(t, err)
assert.Equal(t, 0, len(menuList.Data))

result, _ := uiServer.GetPageOfJS(ctx, &SimpleName{Name: "name"})
assert.False(t, result.Success)

result, _ = uiServer.GetPageOfCSS(ctx, &SimpleName{Name: "name"})
assert.False(t, result.Success)

result, _ = uiServer.GetPageOfStatic(ctx, &SimpleName{Name: "name"})
assert.False(t, result.Success)

result, _ = uiServer.GetPageOfStatic(
context.WithValue(ctx, "X-Extension-Name", "fake"),
&SimpleName{Name: "name"})
assert.False(t, result.Success)

result, _ = uiServer.GetPageOfStatic(
context.WithValue(ctx, "X-Extension-Name", []string{"fake"}),
&SimpleName{Name: "name"})
assert.False(t, result.Success)
}
Loading
Loading