diff --git a/analysis/ssrf.go b/analysis/ssrf.go index 3af95653b..f0bae06b1 100644 --- a/analysis/ssrf.go +++ b/analysis/ssrf.go @@ -16,6 +16,18 @@ func unwrappedHTTPTransport(m dsl.Matcher) { // Variable assigned a bare transport (cannot be tracked to a later WrapTransport call). m.Match(`$_ := &http.Transport{$*_}`). Report(`bare *http.Transport variable; use ssrf.WrapTransport(&http.Transport{...}) inline instead`) + + // Field assignment of a bare transport (e.g. httpClient.Transport = &http.Transport{...}). + m.Match(`$_.Transport = &http.Transport{$*_}`). + Report(`bare *http.Transport field assignment; wrap with ssrf.WrapTransport() to enforce the SSRF protection policy`) +} + +// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport. +// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport +// passed here must be wrapped with ssrf.WrapTransport. +func helmGetterTransport(m dsl.Matcher) { + m.Match(`getter.WithTransport(&http.Transport{$*_})`). + Report(`getter.WithTransport called with a bare *http.Transport; wrap with ssrf.WrapTransport() as Helm v4 bypasses http.DefaultTransport`) } // internalTransportMisuse flags calls to WrapTransportInternal outside the four proxy diff --git a/api/agent/version.go b/api/agent/version.go index ea8a22b9c..f2581c0b4 100644 --- a/api/agent/version.go +++ b/api/agent/version.go @@ -1,6 +1,7 @@ package agent import ( + "context" "crypto/tls" "errors" "fmt" @@ -11,6 +12,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/url" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/rs/zerolog/log" ) @@ -19,10 +21,14 @@ import ( // // it sends a ping to the agent and parses the version and platform from the headers func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo + if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil { + return 0, "", err + } + httpCli := &http.Client{Timeout: 3 * time.Second} if tlsConfig != nil { - httpCli.Transport = &http.Transport{TLSClientConfig: tlsConfig} + httpCli.Transport = ssrf.WrapTransport(&http.Transport{TLSClientConfig: tlsConfig}) } parsedURL, err := url.ParseURL(endpointUrl + "/ping") diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 8b92aee11..abf21d25f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -58,7 +58,10 @@ import ( libswarm "github.com/portainer/portainer/pkg/libstack/swarm" "github.com/portainer/portainer/pkg/validate" + gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client" + gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git" gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/google/uuid" "github.com/rs/zerolog/log" ) @@ -413,6 +416,9 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow } gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport}) + gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient)) + gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient)) + gogitclient.InstallProtocol("file", nil) instanceID, err := dataStore.Version().InstanceID() if err != nil { diff --git a/api/git/ssrf_transport.go b/api/git/ssrf_transport.go new file mode 100644 index 000000000..6ef461488 --- /dev/null +++ b/api/git/ssrf_transport.go @@ -0,0 +1,53 @@ +package git + +import ( + "context" + "fmt" + "net" + "strconv" + + "github.com/portainer/portainer/pkg/libhttp/ssrf" + + gittransport "github.com/go-git/go-git/v5/plumbing/transport" +) + +const gitDefaultPort = 9418 + +// ssrfGitTransport wraps a git:// transport and validates the resolved IP +// against the SSRF policy before establishing connections. +type ssrfGitTransport struct { + inner gittransport.Transport +} + +// NewSSRFGitTransport wraps inner and blocks connections to private IP ranges +// according to the active SSRF policy. +func NewSSRFGitTransport(inner gittransport.Transport) gittransport.Transport { + return &ssrfGitTransport{inner: inner} +} + +func (t *ssrfGitTransport) NewUploadPackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.UploadPackSession, error) { + if err := checkEndpointSSRF(ep); err != nil { + return nil, err + } + + return t.inner.NewUploadPackSession(ep, auth) +} + +func (t *ssrfGitTransport) NewReceivePackSession(ep *gittransport.Endpoint, auth gittransport.AuthMethod) (gittransport.ReceivePackSession, error) { + if err := checkEndpointSSRF(ep); err != nil { + return nil, err + } + + return t.inner.NewReceivePackSession(ep, auth) +} + +func checkEndpointSSRF(ep *gittransport.Endpoint) error { + port := ep.Port + if port <= 0 { + port = gitDefaultPort + } + + rawURL := fmt.Sprintf("git://%s/", net.JoinHostPort(ep.Host, strconv.Itoa(port))) + + return ssrf.CheckURL(context.Background(), rawURL) +} diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index 31c99cd95..2d95ecfaa 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -19,6 +19,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" "github.com/rs/zerolog/log" @@ -315,6 +316,10 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( return nil, httpErr } + if err := ssrf.CheckURL(r.Context(), gitConfig.URL); err != nil { + return nil, err + } + commitHash, err := stackutils.DownloadGitRepository(context.TODO(), gitConfig, handler.GitService, getProjectPath) if err != nil { return nil, err diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index 09f902f0c..83901a298 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -14,6 +14,7 @@ import ( "github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/pkg/edge" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" "github.com/pkg/errors" @@ -69,7 +70,6 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") } - if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled") } @@ -138,6 +138,10 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat return nil, httpErr } + if err := ssrf.CheckURL(r.Context(), repoConfig.URL); err != nil { + return nil, errors.Wrap(err, "repository URL blocked by SSRF policy") + } + stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID) stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username) diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index e4aa40559..93545a80e 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -12,6 +12,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" "github.com/rs/zerolog/log" ) @@ -100,6 +101,10 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht tlsSkipVerify = src.Git.TLSSkipVerify } + if err := ssrf.CheckURL(r.Context(), repoURL); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + projectPath, err := handler.fileService.GetTemporaryPath() if err != nil { return httperror.InternalServerError("Unable to create temporary folder", err) diff --git a/api/http/handler/gitops/sources/create_git.go b/api/http/handler/gitops/sources/create_git.go index fcde7aaa2..6329e377e 100644 --- a/api/http/handler/gitops/sources/create_git.go +++ b/api/http/handler/gitops/sources/create_git.go @@ -12,6 +12,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" ) // GitAuthenticationPayload holds authentication parameters for a git source @@ -30,8 +31,8 @@ type GitSourceCreatePayload struct { // Validate implements the portainer.Validatable interface func (payload *GitSourceCreatePayload) Validate(_ *http.Request) error { - if strings.TrimSpace(payload.URL) == "" { - return errors.New("url is required") + if !validate.IsURL(payload.URL) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") } return nil diff --git a/api/http/handler/gitops/sources/update_git.go b/api/http/handler/gitops/sources/update_git.go index 867baa3b5..4731c9d71 100644 --- a/api/http/handler/gitops/sources/update_git.go +++ b/api/http/handler/gitops/sources/update_git.go @@ -12,6 +12,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" ) var ( @@ -35,6 +36,10 @@ type GitAuthenticationUpdatePayload struct { // Validate implements the portainer.Validatable interface func (payload *GitSourceUpdatePayload) Validate(_ *http.Request) error { + if payload.URL != nil && !validate.IsURL(*payload.URL) { + return errors.New("invalid repository URL. Must correspond to a valid URL format") + } + return nil } diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go index 42600ca4b..7f940b01e 100644 --- a/api/http/handler/helm/helm_repo_search.go +++ b/api/http/handler/helm/helm_repo_search.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer/pkg/libhelm/options" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/rs/zerolog/log" "github.com/pkg/errors" @@ -45,6 +46,10 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) * return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) } + if err := ssrf.CheckURL(r.Context(), repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + searchOpts := options.SearchRepoOptions{ Repo: repo, Chart: chart, @@ -53,7 +58,8 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) * result, err := handler.helmPackageManager.SearchRepo(searchOpts) if err != nil { - return httperror.InternalServerError("Search failed", err) + log.Warn().Err(err).Str("repo", repo).Msg("helm repo search failed") + return httperror.InternalServerError("Search failed", errors.New("failed to search Helm repository")) } w.Header().Set("Content-Type", "text/plain") diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go index 3e706558d..1c5860ba4 100644 --- a/api/http/handler/helm/helm_show.go +++ b/api/http/handler/helm/helm_show.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer/pkg/libhelm/options" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/pkg/errors" "github.com/rs/zerolog/log" @@ -41,6 +42,10 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) } + if err := ssrf.CheckURL(r.Context(), repo); err != nil { + return httperror.BadRequest("Repository URL blocked by SSRF policy", err) + } + chart := r.URL.Query().Get("chart") if chart == "" { return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) @@ -65,7 +70,8 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper } result, err := handler.helmPackageManager.Show(showOptions) if err != nil { - return httperror.InternalServerError("Unable to show chart", err) + log.Warn().Err(err).Str("repo", repo).Str("chart", chart).Msg("helm show failed") + return httperror.InternalServerError("Unable to show chart", errors.New("failed to retrieve Helm chart information")) } w.Header().Set("Content-Type", "text/plain") diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index f0f0064db..4a24bf93f 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -14,6 +14,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" "github.com/pkg/errors" @@ -74,6 +75,12 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format") } + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" { + if err := ssrf.CheckURL(r.Context(), *payload.HelmRepositoryURL); err != nil { + return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format") + } + } + if payload.UserSessionTimeout != nil { if _, err := time.ParseDuration(*payload.UserSessionTimeout); err != nil { return errors.New("Invalid user session timeout") diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index b17964152..d3daada50 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -14,6 +14,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/validate" "github.com/pkg/errors" @@ -125,6 +126,10 @@ func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) return errors.New("Invalid manifest URL") } + if err := ssrf.CheckURL(r.Context(), payload.ManifestURL); err != nil { + return err + } + return nil } diff --git a/api/http/handler/users/user_helm_repos.go b/api/http/handler/users/user_helm_repos.go index e8490b2e0..0bb37d0c2 100644 --- a/api/http/handler/users/user_helm_repos.go +++ b/api/http/handler/users/user_helm_repos.go @@ -11,6 +11,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/pkg/errors" ) @@ -24,7 +25,11 @@ type addHelmRepoUrlPayload struct { URL string `json:"url"` } -func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { +func (p *addHelmRepoUrlPayload) Validate(r *http.Request) error { + if err := ssrf.CheckURL(r.Context(), p.URL); err != nil { + return err + } + return libhelm.ValidateHelmRepositoryURL(p.URL, nil) } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index e1e800413..bef0ac7b0 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -22,6 +22,7 @@ import ( "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/logs" "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/swarm" @@ -506,6 +507,11 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error } repositoryURL := remote[:len(remote)-4] + + if err := ssrf.CheckURL(request.Context(), repositoryURL); err != nil { + return err + } + latestCommitID, err := transport.gitService.LatestCommitID( request.Context(), repositoryURL, diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go index 66b9ddd77..f5c9721cd 100644 --- a/api/stacks/stackbuilders/stack_git_builder.go +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api/scheduler" "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/portainer/portainer/pkg/libhttp/ssrf" ) type GitMethodStackBuilder struct { @@ -78,6 +79,10 @@ func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPaylo return b.fileService.GetStackProjectPath(stackFolder) } + if err := ssrf.CheckURL(ctx, repoConfig.URL); err != nil { + return fmt.Errorf("repository URL blocked by SSRF policy: %w", err) + } + commitHash, err := stackutils.DownloadGitRepository(ctx, repoConfig, b.gitService, getProjectPath) if err != nil { return fmt.Errorf("failed to download git repository: %w", err) diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go index dee1443d2..46b9bbb6d 100644 --- a/pkg/libhelm/sdk/search_repo.go +++ b/pkg/libhelm/sdk/search_repo.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "path/filepath" "strings" "sync" @@ -14,6 +15,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/logs" "github.com/portainer/portainer/pkg/libhelm/options" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "github.com/portainer/portainer/pkg/liboras" "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" @@ -216,13 +218,15 @@ func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSe Str("repo_name", repoName). Msg("Creating chart repository object") + ssrfTransport := ssrf.WrapTransport(http.DefaultTransport.(*http.Transport).Clone()) + // Create chart repository object rep, err := repo.NewChartRepository( &repo.Entry{ Name: repoName, URL: repoURLString, }, - getter.All(repoSettings), + getter.All(repoSettings, getter.WithTransport(ssrfTransport)), ) if err != nil { log.Error(). diff --git a/pkg/libhelm/validate_repo.go b/pkg/libhelm/validate_repo.go index a9d3a810a..ba46f1cfd 100644 --- a/pkg/libhelm/validate_repo.go +++ b/pkg/libhelm/validate_repo.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/portainer/portainer/pkg/libhelm/sdk" + "github.com/portainer/portainer/pkg/libhttp/ssrf" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" repo "helm.sh/helm/v4/pkg/repo/v1" @@ -40,12 +41,14 @@ func ValidateHelmRepositoryURL(repoUrl string, _ *http.Client) error { return fmt.Errorf("failed to derive repo name: %w", err) } + ssrfTransport := ssrf.WrapTransport(http.DefaultTransport.(*http.Transport).Clone()) + r, err := repo.NewChartRepository( &repo.Entry{ Name: repoName, URL: repoUrl, }, - getter.All(settings), + getter.All(settings, getter.WithTransport(ssrfTransport)), ) if err != nil { return fmt.Errorf("%s is not a valid chart repository or cannot be reached: %w", repoUrl, err) @@ -53,7 +56,7 @@ func ValidateHelmRepositoryURL(repoUrl string, _ *http.Client) error { indexPath, err := r.DownloadIndexFile() if err != nil { - return fmt.Errorf("%s is not a valid chart repository or cannot be reached: %w", repoUrl, err) + return fmt.Errorf("%s is not a valid chart repository or cannot be reached", repoUrl) } // Best-effort: load and seed in-memory cache for future SearchRepo calls