From d1eca674f47f832e25c120caec14f099b52cc00e Mon Sep 17 00:00:00 2001 From: William Cai Date: Wed, 24 Jun 2026 15:14:24 +0800 Subject: [PATCH] fix: validate custom function URL at registration to prevent stored SSRF ValidateFunc only checked that name and funcURL were non-empty. The SSRF URL check (block private/internal/metadata addresses) was applied only at evaluation time. A caller with PMS write access could register a function with FuncURL=http://169.254.169.254/... and have it stored; every later evaluation became an internal-network probe. Added utils.ValidateFunctionURL (http/https only, rejects private, loopback, link-local, and unspecified IPs; allows localhost for dev) and call it from ValidateFunc, which all stores invoke on CreateFunction. The evaluation-time check remains as defense-in-depth. Added tests. Co-Authored-By: Claude --- pkg/store/utils/utils.go | 31 +++++++++++++++++ pkg/store/utils/validation_test.go | 54 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 pkg/store/utils/validation_test.go diff --git a/pkg/store/utils/utils.go b/pkg/store/utils/utils.go index 6c6b54d1..8f35d9c1 100644 --- a/pkg/store/utils/utils.go +++ b/pkg/store/utils/utils.go @@ -6,6 +6,8 @@ package utils import ( "encoding/json" "io" + "net" + "net/url" "os" "strings" @@ -38,6 +40,35 @@ func ValidateFunc(function *pms.Function) error { if function.Name == "" || function.FuncURL == "" { return errors.New(errors.InvalidRequest, "\"name\" and \"funcURL\" in function definition can not be empty") } + // Validate the function URL at registration time so that a malicious + // FuncURL pointing at an internal/private address (SSRF) is rejected + // before it is ever stored and evaluated. + return ValidateFunctionURL(function.FuncURL) +} + +// ValidateFunctionURL checks that a custom function URL is safe to call: it must +// use http/https and must not resolve to a private, loopback, link-local, or +// unspecified address (SSRF protection). localhost is permitted for local +// development. +func ValidateFunctionURL(urlStr string) error { + parsed, err := url.Parse(urlStr) + if err != nil { + return errors.Wrapf(err, errors.InvalidRequest, "invalid function URL %q", urlStr) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.Errorf(errors.InvalidRequest, "unsupported URL scheme %q in function URL %q", parsed.Scheme, urlStr) + } + host := parsed.Hostname() // strips brackets from IPv6 and the port + // Allow localhost for local development/testing. + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return nil + } + if ip := net.ParseIP(host); ip != nil { + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return errors.Errorf(errors.InvalidRequest, "function URL %q resolves to a private/internal address, which is not allowed", urlStr) + } + } return nil } diff --git a/pkg/store/utils/validation_test.go b/pkg/store/utils/validation_test.go new file mode 100644 index 00000000..1e683237 --- /dev/null +++ b/pkg/store/utils/validation_test.go @@ -0,0 +1,54 @@ +//Copyright (c) 2018, Oracle and/or its affiliates. All rights reserved. +//Licensed under the Universal Permissive License (UPL) Version 1.0 as shown at http://oss.oracle.com/licenses/upl. + +package utils + +import ( + "testing" + + "github.com/teramoby/speedle-plus/api/pms" +) + +func TestValidateFunctionURL(t *testing.T) { + cases := []struct { + url string + wantErr bool + }{ + // Allowed + {"https://example.com/func", false}, + {"http://example.com:8080/func", false}, + {"http://localhost:9000/func", false}, + {"http://127.0.0.1/func", false}, + {"http://[::1]/func", false}, + // Rejected: private / internal / metadata addresses (SSRF) + {"http://169.254.169.254/latest/meta-data/", true}, // AWS metadata (link-local) + {"http://10.0.0.5/func", true}, // private + {"http://192.168.1.1/func", true}, // private + {"http://172.16.0.1/func", true}, // private + {"http://[fe80::1]/func", true}, // IPv6 link-local + {"http://0.0.0.0/func", true}, // unspecified + // Rejected: bad scheme / unparseable + {"ftp://example.com/func", true}, + {"file:///etc/passwd", true}, + {"://nonsense", true}, + } + for _, c := range cases { + err := ValidateFunctionURL(c.url) + if (err != nil) != c.wantErr { + t.Errorf("ValidateFunctionURL(%q) error = %v, wantErr = %v", c.url, err, c.wantErr) + } + } +} + +func TestValidateFuncRejectsSSRF(t *testing.T) { + // A function registered with an internal metadata URL must be rejected. + fn := &pms.Function{Name: "evil", FuncURL: "http://169.254.169.254/latest/meta-data/"} + if err := ValidateFunc(fn); err == nil { + t.Errorf("expected ValidateFunc to reject SSRF metadata URL, got nil") + } + // A legitimate function must pass. + ok := &pms.Function{Name: "good", FuncURL: "https://func.example.com/run"} + if err := ValidateFunc(ok); err != nil { + t.Errorf("expected ValidateFunc to accept public URL, got %v", err) + } +}