SSRF via Webhook and Integration Plugin Registration (Discord, Slack, Generic Webhook, Dependency-Track)
Summary
Four integration plugins in the Chainloop control plane make outbound HTTP requests to user-supplied URLs without any IP address or network-level validation. An authenticated organization administrator can register a malicious webhook/integration URL pointing at internal services, cloud metadata endpoints (e.g. http://169.254.169.254/latest/meta-data/), or other non-public hosts. The Discord webhook plugin provides full-read SSRF because the response body is decoded and persisted in the registration configuration that is returned to the caller.
Affected Components
| Plugin |
File |
Sink |
Type |
| Discord webhook |
app/controlplane/plugins/core/discord-webhook/v1/discord.go L92 |
http.Get(request.WebhookURL) |
Full-read (response body decoded as JSON, stored in config) |
| Discord webhook |
same file L219 |
http.Post(webhookURL, ...) |
Blind POST |
| Slack webhook |
app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go L142 |
http.Post(webhookURL, ...) |
Blind POST |
| Generic webhook |
app/controlplane/plugins/core/webhook/v1/webhook.go L245-255 |
http.NewRequestWithContext + client.Do(req) |
Blind POST |
| Dependency-Track |
app/controlplane/plugins/core/dependency-track/v1/client/sbom.go L207-214, L268-278, L307-314 |
http.NewRequest + http.DefaultClient.Do(req) |
Full-read GET + Blind POST |
Root Cause
None of the four plugins validate the resolved IP address of the target URL before connecting. There is no SSRF-safe HTTP client, no DNS-pinning connector, and no private/reserved/loopback address check anywhere in the plugin SDK or the individual plugins.
The validateURL helper in the generic webhook plugin checks only the URL scheme (http/https) and syntactic validity; the Discord and Slack plugins perform no validation at all.
Impact
-
Internal network scanning: the server can be directed to probe arbitrary internal hosts and ports. HTTP status codes and error messages reveal reachability.
-
Cloud metadata exfiltration: on cloud-hosted deployments the server can reach http://169.254.169.254/ (AWS IMDS), http://metadata.google.internal/, or http://169.254.169.254/metadata/instance (Azure IMDS) to retrieve instance credentials, IAM role tokens, and other secrets.
-
Full-read data theft (Discord + Dependency-Track): the Discord plugin decodes the HTTP response body as JSON and stores the name and user.username fields in the registration config object, which is returned to the caller in the Register response and via the DescribeRegistration endpoint. Internal services that return JSON with these field names leak their response content to the attacker. The Dependency-Track plugin similarly reads and parses response bodies from the configured host.
-
Blind request forgery (all four): the server can be used as a proxy to send crafted POST payloads to internal services, potentially triggering state-changing operations on services that trust the server's network address.
Reproduction
- Deploy a Chainloop control plane instance with default settings.
- Authenticate as an organization admin/owner.
- Register a Discord webhook integration with
webhook set to http://169.254.169.254/latest/meta-data/:
chainloop integration registered add \
--name ssrf-test \
--driver discord-webhook \
--opt webhook=http://169.254.169.254/latest/meta-data/
-
On a cloud instance (AWS/GCP/Azure), the server makes http.Get("http://169.254.169.254/latest/meta-data/") and attempts to decode the response as JSON. The HTTP request reaches the metadata service.
-
For the generic webhook plugin, the server sends a POST with an attestation-shaped JSON body to the target URL:
chainloop integration registered add \
--name ssrf-webhook \
--driver webhook \
--opt url=http://internal-service:8080/admin/shutdown
Suggested Fix
Introduce a shared SSRF-safe HTTP transport for all plugins that:
- Resolves the target hostname via DNS.
- Checks the resolved IP address against a deny-list of non-public ranges (loopback, RFC 1918, link-local, IPv6 transition forms including
::ffff:, 2002::/16, 64:ff9b::/96, Teredo 2001::/32).
- Connects to the validated IP (DNS pinning) to prevent TOCTOU/DNS-rebinding bypasses.
- Re-validates the resolved address on every redirect hop.
Go example using a custom net.Dialer with address validation in DialContext:
func ssrfSafeTransport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, _ := net.SplitHostPort(addr)
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !ip.IP.IsGlobalUnicast() || ip.IP.IsPrivate() || ip.IP.IsLoopback() || ip.IP.IsLinkLocalUnicast() {
return nil, fmt.Errorf("SSRF blocked: %s resolves to non-public address %s", host, ip.IP)
}
}
dialer := &net.Dialer{}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
},
}
}
Credit
Discovered by tonghuaroot.
SSRF via Webhook and Integration Plugin Registration (Discord, Slack, Generic Webhook, Dependency-Track)
Summary
Four integration plugins in the Chainloop control plane make outbound HTTP requests to user-supplied URLs without any IP address or network-level validation. An authenticated organization administrator can register a malicious webhook/integration URL pointing at internal services, cloud metadata endpoints (e.g.
http://169.254.169.254/latest/meta-data/), or other non-public hosts. The Discord webhook plugin provides full-read SSRF because the response body is decoded and persisted in the registration configuration that is returned to the caller.Affected Components
app/controlplane/plugins/core/discord-webhook/v1/discord.goL92http.Get(request.WebhookURL)http.Post(webhookURL, ...)app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.goL142http.Post(webhookURL, ...)app/controlplane/plugins/core/webhook/v1/webhook.goL245-255http.NewRequestWithContext+client.Do(req)app/controlplane/plugins/core/dependency-track/v1/client/sbom.goL207-214, L268-278, L307-314http.NewRequest+http.DefaultClient.Do(req)Root Cause
None of the four plugins validate the resolved IP address of the target URL before connecting. There is no SSRF-safe HTTP client, no DNS-pinning connector, and no private/reserved/loopback address check anywhere in the plugin SDK or the individual plugins.
The
validateURLhelper in the generic webhook plugin checks only the URL scheme (http/https) and syntactic validity; the Discord and Slack plugins perform no validation at all.Impact
Internal network scanning: the server can be directed to probe arbitrary internal hosts and ports. HTTP status codes and error messages reveal reachability.
Cloud metadata exfiltration: on cloud-hosted deployments the server can reach
http://169.254.169.254/(AWS IMDS),http://metadata.google.internal/, orhttp://169.254.169.254/metadata/instance(Azure IMDS) to retrieve instance credentials, IAM role tokens, and other secrets.Full-read data theft (Discord + Dependency-Track): the Discord plugin decodes the HTTP response body as JSON and stores the
nameanduser.usernamefields in the registration config object, which is returned to the caller in theRegisterresponse and via theDescribeRegistrationendpoint. Internal services that return JSON with these field names leak their response content to the attacker. The Dependency-Track plugin similarly reads and parses response bodies from the configured host.Blind request forgery (all four): the server can be used as a proxy to send crafted POST payloads to internal services, potentially triggering state-changing operations on services that trust the server's network address.
Reproduction
webhookset tohttp://169.254.169.254/latest/meta-data/:On a cloud instance (AWS/GCP/Azure), the server makes
http.Get("http://169.254.169.254/latest/meta-data/")and attempts to decode the response as JSON. The HTTP request reaches the metadata service.For the generic webhook plugin, the server sends a POST with an attestation-shaped JSON body to the target URL:
Suggested Fix
Introduce a shared SSRF-safe HTTP transport for all plugins that:
::ffff:,2002::/16,64:ff9b::/96, Teredo2001::/32).Go example using a custom
net.Dialerwith address validation inDialContext:Credit
Discovered by tonghuaroot.