Skip to content

Unrestricted SSRF in plugin tool execution allows any authenticated user to reach internal-only network services #2711

Description

@geo-chen

version: commit 22275b1

Summary

Any authenticated user can create a custom plugin whose tool definition points at an arbitrary host, including hosts that are only reachable from inside the backend's own network (internal services, cloud metadata endpoints in a cloud deployment). Invoking the tool, including through the plugin editor's own "Test Run" / debug feature, which requires no admin approval and no bot binding, causes the coze-server backend to issue the HTTP request and return the full raw response body to the caller. There is no host allowlist, no private-IP/metadata-IP blocking, and no scheme restriction anywhere on this code path.

Details

A plugin's tool definition (server URL, path, HTTP method, headers, body) is entirely attacker-controlled at plugin-creation time - any workspace member can create a plugin (POST /api/plugin_api/register_plugin_meta, create_api) with any "Plugin URL" and any tool "Tool path" / "Request method".

When a tool is executed, buildHTTPRequest (backend/domain/plugin/service/tool/invocation_http.go:127) builds the request directly from that stored configuration:

func (h *httpCallImpl) buildHTTPRequest(ctx context.Context, args *InvocationArgs) (httpReq *http.Request, err error) {
    tool := args.Tool
    rawURL := args.ServerURL + tool.GetSubURL()
    reqURL, err := h.buildHTTPRequestURL(ctx, rawURL, args)
    ...
    httpReq, err = http.NewRequestWithContext(ctx, tool.GetMethod(), reqURL.String(), bytes.NewBuffer(bodyBytes))
    ...
}

and Do() sends it with a plain, unconfigured resty client (invocation_http.go:51,112):

var defaultHttpCli *resty.Client = resty.New()
...
httpResp, err := restyReq.Send()

There is no validation anywhere in this path (or in exec_tool.go, which calls into it) that args.ServerURL resolves to a public, non-internal address. No DNS-rebinding protection, no blocklist for RFC1918 / loopback / link-local ranges (which would include the cloud metadata address 169.254.169.254 on AWS/GCP/Azure), and no scheme restriction. The only network-allowlist concept anywhere in the codebase (CODE_RUNNER_ALLOW_NET) belongs to a completely different feature (the sandboxed code-runner) and is not consulted here.

The tool can be invoked without publishing the plugin or binding it to any bot, via the plugin editor's own debug endpoint:

POST /api/plugin_api/debug_api

which reaches getToolDebugPluginInfo (backend/domain/plugin/service/exec_tool.go:445) and then the same execute() / httpCallImpl.Do() path used by a live bot.

PoC

(available upon request)

Impact

Any authenticated user, with no admin role and no approval workflow, can use the plugin/tool feature to make the coze-server backend issue HTTP requests to arbitrary hosts, including internal-only services that are not reachable from outside the deployment's own network, and read the full raw response body. In a cloud deployment this includes the instance metadata service (169.254.169.254), which commonly exposes IAM/service-account credentials. Within this deployment it already allows reconnaissance and data disclosure from every other backing service (etcd, MinIO, Elasticsearch, MySQL admin ports, NSQ admin) that the coze-server container can reach but the end user should not be able to.

The request method is taken directly from the attacker-authored tool definition with no allowlist, so non-idempotent methods (POST/PUT/DELETE) against internal services are equally reachable in principle; this report only demonstrates and scores the confirmed read/response-reflection primitive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions