Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd551b275 | |||
| d7794a06b3 | |||
| d0e74d6ef4 | |||
| 1831af9c48 | |||
| dca0e35e24 | |||
| 4b5b682d0c | |||
| 078dca33b8 |
@@ -61,6 +61,7 @@ func CLIFlags() *portainer.CLIFlags {
|
|||||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||||
|
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import (
|
|||||||
"github.com/portainer/portainer/pkg/libhelm"
|
"github.com/portainer/portainer/pkg/libhelm"
|
||||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||||
|
"github.com/portainer/portainer/pkg/validate"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -330,6 +331,18 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trustedOrigins := []string{}
|
||||||
|
if *flags.TrustedOrigins != "" {
|
||||||
|
// validate if the trusted origins are valid urls
|
||||||
|
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
|
||||||
|
if !validate.IsTrustedOrigin(origin) {
|
||||||
|
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedOrigins = append(trustedOrigins, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileService := initFileService(*flags.Data)
|
fileService := initFileService(*flags.Data)
|
||||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||||
if encryptionKey == nil {
|
if encryptionKey == nil {
|
||||||
@@ -578,6 +591,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||||||
PendingActionsService: pendingActionsService,
|
PendingActionsService: pendingActionsService,
|
||||||
PlatformService: platformService,
|
PlatformService: platformService,
|
||||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||||
|
TrustedOrigins: trustedOrigins,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -611,7 +611,7 @@
|
|||||||
"RequiredPasswordLength": 12
|
"RequiredPasswordLength": 12
|
||||||
},
|
},
|
||||||
"KubeconfigExpiry": "0",
|
"KubeconfigExpiry": "0",
|
||||||
"KubectlShellImage": "portainer/kubectl-shell:2.31.0",
|
"KubectlShellImage": "portainer/kubectl-shell:2.31.3",
|
||||||
"LDAPSettings": {
|
"LDAPSettings": {
|
||||||
"AnonymousMode": true,
|
"AnonymousMode": true,
|
||||||
"AutoCreateUsers": true,
|
"AutoCreateUsers": true,
|
||||||
@@ -939,7 +939,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": {
|
"version": {
|
||||||
"VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
"VERSION": "{\"SchemaVersion\":\"2.31.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||||
},
|
},
|
||||||
"webhooks": null
|
"webhooks": null
|
||||||
}
|
}
|
||||||
+35
-7
@@ -2,6 +2,7 @@ package csrf
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,7 +10,8 @@ import (
|
|||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
|
||||||
gorillacsrf "github.com/gorilla/csrf"
|
gcsrf "github.com/gorilla/csrf"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/urfave/negroni"
|
"github.com/urfave/negroni"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
|
|||||||
w.Header().Set(csrfSkipHeader, "1")
|
w.Header().Set(csrfSkipHeader, "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
|
||||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||||
isDockerDesktopExtension := false
|
isDockerDesktopExtension := false
|
||||||
@@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
|||||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = gorillacsrf.Protect(
|
handler = gcsrf.Protect(
|
||||||
token,
|
token,
|
||||||
gorillacsrf.Path("/"),
|
gcsrf.Path("/"),
|
||||||
gorillacsrf.Secure(false),
|
gcsrf.Secure(false),
|
||||||
|
gcsrf.TrustedOrigins(trustedOrigins),
|
||||||
|
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
|
||||||
)(handler)
|
)(handler)
|
||||||
|
|
||||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||||
@@ -55,7 +59,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||||
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
|
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if skip {
|
if skip {
|
||||||
r = gorillacsrf.UnsafeSkipCheck(r)
|
r = gcsrf.UnsafeSkipCheck(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withErrorHandler(trustedOrigins []string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := gcsrf.FailureReason(r)
|
||||||
|
|
||||||
|
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
|
||||||
|
log.Error().Err(err).
|
||||||
|
Str("request_url", r.URL.String()).
|
||||||
|
Str("host", r.Host).
|
||||||
|
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
|
||||||
|
Str("forwarded", r.Header.Get("Forwarded")).
|
||||||
|
Str("origin", r.Header.Get("Origin")).
|
||||||
|
Str("referer", r.Header.Get("Referer")).
|
||||||
|
Strs("trusted_origins", trustedOrigins).
|
||||||
|
Msg("Failed to validate Origin or Referer")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
|
||||||
|
http.StatusForbidden,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @title PortainerCE API
|
||||||
// @version 2.31.0
|
// @version 2.31.3
|
||||||
// @description.markdown api-description.md
|
// @description.markdown api-description.md
|
||||||
// @termsOfService
|
// @termsOfService
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// @param id path int true "Environment identifier"
|
// @param id path int true "Environment identifier"
|
||||||
// @param namespace path string true "The namespace name the events are associated to"
|
// @param namespace path string true "The namespace name the events are associated to"
|
||||||
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
||||||
// @success 200 {object} models.Event[] "Success"
|
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
@@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r
|
|||||||
// @produce json
|
// @produce json
|
||||||
// @param id path int true "Environment identifier"
|
// @param id path int true "Environment identifier"
|
||||||
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
|
||||||
// @success 200 {object} models.Event[] "Success"
|
// @success 200 {object} []kubernetes.K8sEvent "Success"
|
||||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middlewares
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,45 @@ type plainTextHTTPRequestHandler struct {
|
|||||||
next http.Handler
|
next http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
|
||||||
|
// The Forwarded header format supports:
|
||||||
|
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
|
||||||
|
// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17
|
||||||
|
// We take the first (leftmost) entry as it represents the original client
|
||||||
|
func parseForwardedHeaderProto(forwarded string) string {
|
||||||
|
if forwarded == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the first part (leftmost proxy, closest to original client)
|
||||||
|
firstPart, _, _ := strings.Cut(forwarded, ",")
|
||||||
|
firstPart = strings.TrimSpace(firstPart)
|
||||||
|
|
||||||
|
// Split by semicolon to get key-value pairs within this proxy entry
|
||||||
|
// Format: key=value;key=value;key=value
|
||||||
|
pairs := strings.Split(firstPart, ";")
|
||||||
|
for _, pair := range pairs {
|
||||||
|
// Split by equals sign to separate key and value
|
||||||
|
key, value, found := strings.Cut(pair, "=")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(strings.TrimSpace(key), "proto") {
|
||||||
|
return strings.Trim(strings.TrimSpace(value), `"'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPSRequest checks if the original request was made over HTTPS
|
||||||
|
// by examining both X-Forwarded-Proto and Forwarded headers
|
||||||
|
func isHTTPSRequest(r *http.Request) bool {
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") ||
|
||||||
|
strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if slices.Contains(safeMethods, r.Method) {
|
if slices.Contains(safeMethods, r.Method) {
|
||||||
h.next.ServeHTTP(w, r)
|
h.next.ServeHTTP(w, r)
|
||||||
@@ -24,7 +64,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R
|
|||||||
|
|
||||||
req := r
|
req := r
|
||||||
// If original request was HTTPS (via proxy), keep CSRF checks.
|
// If original request was HTTPS (via proxy), keep CSRF checks.
|
||||||
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
if !isHTTPSRequest(r) {
|
||||||
req = csrf.PlaintextHTTPRequest(r)
|
req = csrf.PlaintextHTTPRequest(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tests = []struct {
|
||||||
|
name string
|
||||||
|
forwarded string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty header",
|
||||||
|
forwarded: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single proxy with proto=https",
|
||||||
|
forwarded: "proto=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single proxy with proto=http",
|
||||||
|
forwarded: "proto=http",
|
||||||
|
expected: "http",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single proxy with multiple directives",
|
||||||
|
forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single proxy with proto in middle",
|
||||||
|
forwarded: "for=192.0.2.60;proto=https;host=example.com",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single proxy with proto at end",
|
||||||
|
forwarded: "for=192.0.2.60;host=example.com;proto=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple proxies - takes first",
|
||||||
|
forwarded: "proto=https, proto=http",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple proxies with complex format",
|
||||||
|
forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple proxies with for directive only",
|
||||||
|
forwarded: "for=192.0.2.43, for=198.51.100.17",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple proxies with proto only in second",
|
||||||
|
forwarded: "for=192.0.2.43, proto=https",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple proxies with proto only in first",
|
||||||
|
forwarded: "proto=https, for=198.51.100.17",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted protocol value",
|
||||||
|
forwarded: "proto=\"https\"",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single quoted protocol value",
|
||||||
|
forwarded: "proto='https'",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case protocol",
|
||||||
|
forwarded: "proto=HTTPS",
|
||||||
|
expected: "HTTPS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no proto directive",
|
||||||
|
forwarded: "for=192.0.2.60;by=203.0.113.43",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty proto value",
|
||||||
|
forwarded: "proto=",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace around values",
|
||||||
|
forwarded: " proto = https ",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace around semicolons",
|
||||||
|
forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace around commas",
|
||||||
|
forwarded: "proto=https , proto=http",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 address in for directive",
|
||||||
|
forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex multiple proxies with IPv6",
|
||||||
|
forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "obfuscated identifiers",
|
||||||
|
forwarded: "for=_mdn;proto=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown identifier",
|
||||||
|
forwarded: "for=unknown;proto=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed key-value pair",
|
||||||
|
forwarded: "proto",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed key-value pair with equals",
|
||||||
|
forwarded: "proto=",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple equals signs",
|
||||||
|
forwarded: "proto=https=extra",
|
||||||
|
expected: "https=extra",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case directive name",
|
||||||
|
forwarded: "PROTO=https",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case directive name with spaces",
|
||||||
|
forwarded: " Proto = https ",
|
||||||
|
expected: "https",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForwardedHeaderProto(t *testing.T) {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseForwardedHeaderProto(tt.forwarded)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzParseForwardedHeaderProto(f *testing.F) {
|
||||||
|
for _, t := range tests {
|
||||||
|
f.Add(t.forwarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, forwarded string) {
|
||||||
|
parseForwardedHeaderProto(forwarded)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -38,14 +38,30 @@ type K8sApplication struct {
|
|||||||
Labels map[string]string `json:"Labels,omitempty"`
|
Labels map[string]string `json:"Labels,omitempty"`
|
||||||
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
||||||
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
|
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
|
||||||
|
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomResourceMetadata struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
APIVersion string `json:"apiVersion"`
|
||||||
|
Plural string `json:"plural"`
|
||||||
|
}
|
||||||
|
|
||||||
type Pod struct {
|
type Pod struct {
|
||||||
Status string `json:"Status"`
|
Name string `json:"Name"`
|
||||||
|
ContainerName string `json:"ContainerName"`
|
||||||
|
Image string `json:"Image"`
|
||||||
|
ImagePullPolicy string `json:"ImagePullPolicy"`
|
||||||
|
Status string `json:"Status"`
|
||||||
|
NodeName string `json:"NodeName"`
|
||||||
|
PodIP string `json:"PodIP"`
|
||||||
|
UID string `json:"Uid"`
|
||||||
|
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
||||||
|
CreationDate time.Time `json:"CreationDate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
@@ -72,8 +88,8 @@ type TLSInfo struct {
|
|||||||
|
|
||||||
// Existing types
|
// Existing types
|
||||||
type K8sApplicationResource struct {
|
type K8sApplicationResource struct {
|
||||||
CPURequest float64 `json:"CpuRequest"`
|
CPURequest float64 `json:"CpuRequest,omitempty"`
|
||||||
CPULimit float64 `json:"CpuLimit"`
|
CPULimit float64 `json:"CpuLimit,omitempty"`
|
||||||
MemoryRequest int64 `json:"MemoryRequest"`
|
MemoryRequest int64 `json:"MemoryRequest,omitempty"`
|
||||||
MemoryLimit int64 `json:"MemoryLimit"`
|
MemoryLimit int64 `json:"MemoryLimit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -113,6 +113,7 @@ type Server struct {
|
|||||||
PendingActionsService *pendingactions.PendingActionsService
|
PendingActionsService *pendingactions.PendingActionsService
|
||||||
PlatformService platform.Service
|
PlatformService platform.Service
|
||||||
PullLimitCheckDisabled bool
|
PullLimitCheckDisabled bool
|
||||||
|
TrustedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
@@ -336,7 +337,7 @@ func (server *Server) Start() error {
|
|||||||
|
|
||||||
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
||||||
|
|
||||||
handler, err := csrf.WithProtect(handler)
|
handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to create CSRF middleware")
|
return errors.Wrap(err, "failed to create CSRF middleware")
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -139,6 +139,7 @@ type (
|
|||||||
LogMode *string
|
LogMode *string
|
||||||
KubectlShellImage *string
|
KubectlShellImage *string
|
||||||
PullLimitCheckDisabled *bool
|
PullLimitCheckDisabled *bool
|
||||||
|
TrustedOrigins *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CustomTemplateVariableDefinition
|
// CustomTemplateVariableDefinition
|
||||||
@@ -1728,7 +1729,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API
|
// APIVersion is the version number of the Portainer API
|
||||||
APIVersion = "2.31.0"
|
APIVersion = "2.31.3"
|
||||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||||
APIVersionSupport = "STS"
|
APIVersionSupport = "STS"
|
||||||
// Edition is what this edition of Portainer is called
|
// Edition is what this edition of Portainer is called
|
||||||
@@ -1787,6 +1788,8 @@ const (
|
|||||||
LicenseServerBaseURL = "https://api.portainer.io"
|
LicenseServerBaseURL = "https://api.portainer.io"
|
||||||
// URL to validate licenses along with system metadata.
|
// URL to validate licenses along with system metadata.
|
||||||
LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin"
|
LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin"
|
||||||
|
// TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection
|
||||||
|
TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// List of supported features
|
// List of supported features
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
|
||||||
|
|
||||||
import styles from './ViewLoading.module.css';
|
import styles from './ViewLoading.module.css';
|
||||||
|
|
||||||
@@ -18,12 +15,7 @@ export function ViewLoading({ message }: Props) {
|
|||||||
<div className="sk-fold-cube" />
|
<div className="sk-fold-cube" />
|
||||||
<div className="sk-fold-cube" />
|
<div className="sk-fold-cube" />
|
||||||
</div>
|
</div>
|
||||||
{message && (
|
{message && <span className={styles.message}>{message}</span>}
|
||||||
<span className={styles.message}>
|
|
||||||
{message}
|
|
||||||
<Icon icon={Settings} className="!ml-1 animate-spin-slow" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function UpgradeButton({
|
|||||||
useCache
|
useCache
|
||||||
);
|
);
|
||||||
const versions = helmRepoVersionsQuery.data;
|
const versions = helmRepoVersionsQuery.data;
|
||||||
const repo = versions?.[0]?.Repo;
|
|
||||||
|
|
||||||
// Combined loading state
|
// Combined loading state
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -63,17 +62,28 @@ export function UpgradeButton({
|
|||||||
latestVersionQuery?.data &&
|
latestVersionQuery?.data &&
|
||||||
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
|
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
|
||||||
);
|
);
|
||||||
const currentVersion = release?.chart.metadata?.version;
|
|
||||||
|
const currentRepo = versions?.find(
|
||||||
|
(v) =>
|
||||||
|
v.Chart === release?.chart.metadata?.name &&
|
||||||
|
v.AppVersion === release?.chart.metadata?.appVersion &&
|
||||||
|
v.Version === release?.chart.metadata?.version
|
||||||
|
)?.Repo;
|
||||||
|
|
||||||
const editableHelmRelease: UpdateHelmReleasePayload = {
|
const editableHelmRelease: UpdateHelmReleasePayload = {
|
||||||
name: releaseName,
|
name: releaseName,
|
||||||
namespace: namespace || '',
|
namespace: namespace || '',
|
||||||
values: release?.values?.userSuppliedValues,
|
values: release?.values?.userSuppliedValues,
|
||||||
chart: release?.chart.metadata?.name || '',
|
chart: release?.chart.metadata?.name || '',
|
||||||
version: currentVersion,
|
appVersion: release?.chart.metadata?.appVersion,
|
||||||
repo,
|
version: release?.chart.metadata?.version,
|
||||||
|
repo: currentRepo ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredVersions = currentRepo
|
||||||
|
? versions?.filter((v) => v.Repo === currentRepo) || []
|
||||||
|
: versions || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
@@ -151,7 +161,7 @@ export function UpgradeButton({
|
|||||||
async function handleUpgrade() {
|
async function handleUpgrade() {
|
||||||
const submittedUpgradeValues = await openUpgradeHelmModal(
|
const submittedUpgradeValues = await openUpgradeHelmModal(
|
||||||
editableHelmRelease,
|
editableHelmRelease,
|
||||||
versions
|
filteredVersions
|
||||||
);
|
);
|
||||||
|
|
||||||
if (submittedUpgradeValues) {
|
if (submittedUpgradeValues) {
|
||||||
|
|||||||
@@ -19,39 +19,48 @@ import { useHelmChartValues } from '../../queries/useHelmChartValues';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
|
||||||
values: UpdateHelmReleasePayload;
|
payload: UpdateHelmReleasePayload;
|
||||||
versions: ChartVersion[];
|
versions: ChartVersion[];
|
||||||
chartName: string;
|
chartName: string;
|
||||||
repo: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpgradeHelmModal({
|
export function UpgradeHelmModal({
|
||||||
values,
|
payload,
|
||||||
versions,
|
versions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
chartName,
|
chartName,
|
||||||
repo,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
|
||||||
const isCurrentVersion = version.Version === values.version;
|
const repo = payload.repo === version.Repo ? version.Repo : '';
|
||||||
const label = `${version.Repo}@${version.Version}${
|
const isCurrentVersion =
|
||||||
|
version.AppVersion === payload.appVersion &&
|
||||||
|
version.Version === payload.version;
|
||||||
|
|
||||||
|
const label = `${repo}@${version.Version}${
|
||||||
isCurrentVersion ? ' (current)' : ''
|
isCurrentVersion ? ' (current)' : ''
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
repo,
|
||||||
label,
|
label,
|
||||||
value: version,
|
value: version,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultVersion =
|
const defaultVersion =
|
||||||
versionOptions.find((v) => v.value.Version === values.version)?.value ||
|
versionOptions.find(
|
||||||
versionOptions[0]?.value;
|
(v) =>
|
||||||
|
v.value.AppVersion === payload.appVersion &&
|
||||||
|
v.value.Version === payload.version &&
|
||||||
|
v.value.Repo === payload.repo
|
||||||
|
)?.value || versionOptions[0]?.value;
|
||||||
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
|
||||||
const [userValues, setUserValues] = useState<string>(values.values || '');
|
const [userValues, setUserValues] = useState<string>(payload.values || '');
|
||||||
const [atomic, setAtomic] = useState<boolean>(true);
|
const [atomic, setAtomic] = useState<boolean>(true);
|
||||||
|
|
||||||
const chartValuesRefQuery = useHelmChartValues({
|
const chartValuesRefQuery = useHelmChartValues({
|
||||||
chart: chartName,
|
chart: chartName,
|
||||||
repo,
|
repo: version.Repo,
|
||||||
version: version.Version,
|
version: version.Version,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +84,7 @@ export function UpgradeHelmModal({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="release-name-input"
|
id="release-name-input"
|
||||||
value={values.name}
|
value={payload.name}
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
data-cy="helm-release-name-input"
|
data-cy="helm-release-name-input"
|
||||||
@@ -88,7 +97,7 @@ export function UpgradeHelmModal({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="namespace-input"
|
id="namespace-input"
|
||||||
value={values.namespace}
|
value={payload.namespace}
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
data-cy="helm-namespace-input"
|
data-cy="helm-namespace-input"
|
||||||
@@ -142,10 +151,10 @@ export function UpgradeHelmModal({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name: values.name,
|
name: payload.name,
|
||||||
values: userValues,
|
values: userValues,
|
||||||
namespace: values.namespace,
|
namespace: payload.namespace,
|
||||||
chart: values.chart,
|
chart: payload.chart,
|
||||||
repo: version.Repo,
|
repo: version.Repo,
|
||||||
version: version.Version,
|
version: version.Version,
|
||||||
atomic,
|
atomic,
|
||||||
@@ -165,13 +174,12 @@ export function UpgradeHelmModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openUpgradeHelmModal(
|
export async function openUpgradeHelmModal(
|
||||||
values: UpdateHelmReleasePayload,
|
payload: UpdateHelmReleasePayload,
|
||||||
versions: ChartVersion[]
|
versions: ChartVersion[]
|
||||||
) {
|
) {
|
||||||
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
|
||||||
values,
|
payload,
|
||||||
versions,
|
versions,
|
||||||
chartName: values.chart,
|
chartName: payload.chart,
|
||||||
repo: values.repo ?? '',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ interface HelmSearch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Entries {
|
interface Entries {
|
||||||
[key: string]: { version: string }[];
|
[key: string]: { version: string; appVersion: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartVersion {
|
export interface ChartVersion {
|
||||||
|
Chart?: string;
|
||||||
Repo: string;
|
Repo: string;
|
||||||
|
Label?: string;
|
||||||
Version: string;
|
Version: string;
|
||||||
|
AppVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,8 +80,10 @@ async function getSearchHelmRepo(
|
|||||||
const versions = data.entries[chart];
|
const versions = data.entries[chart];
|
||||||
return (
|
return (
|
||||||
versions?.map((v) => ({
|
versions?.map((v) => ({
|
||||||
|
Chart: chart,
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
Version: v.version,
|
Version: v.version,
|
||||||
|
AppVersion: v.appVersion,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -120,9 +120,10 @@ export interface InstallChartPayload {
|
|||||||
export interface UpdateHelmReleasePayload {
|
export interface UpdateHelmReleasePayload {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
values?: string;
|
values?: string;
|
||||||
repo?: string;
|
repo: string;
|
||||||
name: string;
|
name: string;
|
||||||
chart: string;
|
chart: string;
|
||||||
|
appVersion?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
atomic?: boolean;
|
atomic?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"author": "Portainer.io",
|
"author": "Portainer.io",
|
||||||
"name": "portainer",
|
"name": "portainer",
|
||||||
"homepage": "http://portainer.io",
|
"homepage": "http://portainer.io",
|
||||||
"version": "2.31.0",
|
"version": "2.31.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git@github.com:portainer/portainer.git"
|
"url": "git@github.com:portainer/portainer.git"
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type Release struct {
|
|||||||
Manifest string `json:"manifest,omitempty"`
|
Manifest string `json:"manifest,omitempty"`
|
||||||
// Hooks are all of the hooks declared for this release.
|
// Hooks are all of the hooks declared for this release.
|
||||||
Hooks []*Hook `json:"hooks,omitempty"`
|
Hooks []*Hook `json:"hooks,omitempty"`
|
||||||
|
// AppVersion is the app version of the release.
|
||||||
|
AppVersion string `json:"appVersion,omitempty"`
|
||||||
// Version is an int which represents the revision of the release.
|
// Version is an int which represents the revision of the release.
|
||||||
Version int `json:"version,omitempty"`
|
Version int `json:"version,omitempty"`
|
||||||
// Namespace is the kubernetes namespace of the release.
|
// Namespace is the kubernetes namespace of the release.
|
||||||
|
|||||||
@@ -90,8 +90,17 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
|
|||||||
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
|
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repoName, err := getRepoNameFromURL(repoURL.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Str("context", "HelmClient").
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to get hostname from URL")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Download the index file and update repository configuration
|
// Download the index file and update repository configuration
|
||||||
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo)
|
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("context", "HelmClient").
|
Str("context", "HelmClient").
|
||||||
@@ -163,7 +172,8 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
|
|||||||
// Create chart repository object
|
// Create chart repository object
|
||||||
rep, err := repo.NewChartRepository(
|
rep, err := repo.NewChartRepository(
|
||||||
&repo.Entry{
|
&repo.Entry{
|
||||||
URL: repoURLString,
|
Name: repoName,
|
||||||
|
URL: repoURLString,
|
||||||
},
|
},
|
||||||
getter.All(repoSettings),
|
getter.All(repoSettings),
|
||||||
)
|
)
|
||||||
|
|||||||
+47
-17
@@ -2,7 +2,8 @@ package sdk
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||||
@@ -32,24 +33,32 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||||||
Str("output_format", string(showOpts.OutputFormat)).
|
Str("output_format", string(showOpts.OutputFormat)).
|
||||||
Msg("Showing chart information")
|
Msg("Showing chart information")
|
||||||
|
|
||||||
// Initialize action configuration (no namespace or cluster access needed)
|
repoURL, err := parseRepoURL(showOpts.Repo)
|
||||||
actionConfig := new(action.Configuration)
|
|
||||||
err := hspm.initActionConfig(actionConfig, "", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// error is already logged in initActionConfig
|
log.Error().
|
||||||
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
Str("context", "HelmClient").
|
||||||
|
Str("repo", showOpts.Repo).
|
||||||
|
Err(err).
|
||||||
|
Msg("Invalid repository URL")
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary directory for chart download
|
repoName, err := getRepoNameFromURL(repoURL.String())
|
||||||
tempDir, err := os.MkdirTemp("", "helm-show-*")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("context", "HelmClient").
|
Str("context", "HelmClient").
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Failed to create temp directory")
|
Msg("Failed to get hostname from URL")
|
||||||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize action configuration (no namespace or cluster access needed)
|
||||||
|
actionConfig := new(action.Configuration)
|
||||||
|
err = hspm.initActionConfig(actionConfig, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
// error is already logged in initActionConfig
|
||||||
|
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Create showClient action
|
// Create showClient action
|
||||||
showClient, err := initShowClient(actionConfig, showOpts)
|
showClient, err := initShowClient(actionConfig, showOpts)
|
||||||
@@ -68,11 +77,12 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||||||
Str("repo", showOpts.Repo).
|
Str("repo", showOpts.Repo).
|
||||||
Msg("Locating chart")
|
Msg("Locating chart")
|
||||||
|
|
||||||
chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings)
|
fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart)
|
||||||
|
chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Str("context", "HelmClient").
|
Str("context", "HelmClient").
|
||||||
Str("chart", showOpts.Chart).
|
Str("chart", fullChartPath).
|
||||||
Str("repo", showOpts.Repo).
|
Str("repo", showOpts.Repo).
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("Failed to locate chart")
|
Msg("Failed to locate chart")
|
||||||
@@ -104,13 +114,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||||||
// and return the show client.
|
// and return the show client.
|
||||||
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
|
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
|
||||||
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
|
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
|
||||||
showClient.ChartPathOptions.RepoURL = showOpts.Repo
|
showClient.ChartPathOptions.Version = showOpts.Version
|
||||||
showClient.ChartPathOptions.Version = showOpts.Version // If version is "", it will use the latest version
|
|
||||||
|
|
||||||
// Set output type based on ShowOptions
|
// Set output type based on ShowOptions
|
||||||
switch showOpts.OutputFormat {
|
switch showOpts.OutputFormat {
|
||||||
case options.ShowAll:
|
|
||||||
showClient.OutputFormat = action.ShowAll
|
|
||||||
case options.ShowChart:
|
case options.ShowChart:
|
||||||
showClient.OutputFormat = action.ShowChart
|
showClient.OutputFormat = action.ShowChart
|
||||||
case options.ShowValues:
|
case options.ShowValues:
|
||||||
@@ -127,3 +134,26 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt
|
|||||||
|
|
||||||
return showClient, nil
|
return showClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRepoNameFromURL extracts a unique repository identifier from a URL string.
|
||||||
|
// It combines hostname and path to ensure uniqueness across different repositories on the same host.
|
||||||
|
// Examples:
|
||||||
|
// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo
|
||||||
|
// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo
|
||||||
|
// - https://charts.helm.sh/stable -> charts.helm.sh-stable
|
||||||
|
func getRepoNameFromURL(urlStr string) (string, error) {
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := parsedURL.Hostname()
|
||||||
|
path := parsedURL.Path
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
path = strings.ReplaceAll(path, "/", "-")
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return hostname, nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s", hostname, path), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,32 @@ func IsDNSName(s string) bool {
|
|||||||
|
|
||||||
return !IsIP(s) && dnsNameRegex.MatchString(s)
|
return !IsIP(s) && dnsNameRegex.MatchString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsTrustedOrigin(s string) bool {
|
||||||
|
// Reject if a scheme is present
|
||||||
|
if strings.Contains(s, "://") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepend http:// for parsing
|
||||||
|
strTemp := "http://" + s
|
||||||
|
parsedOrigin, err := url.Parse(strTemp)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate host, and ensure no user, path, query, fragment, port, etc.
|
||||||
|
if parsedOrigin.Host == "" ||
|
||||||
|
parsedOrigin.User != nil ||
|
||||||
|
parsedOrigin.Path != "" ||
|
||||||
|
parsedOrigin.RawQuery != "" ||
|
||||||
|
parsedOrigin.Fragment != "" ||
|
||||||
|
parsedOrigin.Opaque != "" ||
|
||||||
|
parsedOrigin.RawFragment != "" ||
|
||||||
|
parsedOrigin.RawPath != "" ||
|
||||||
|
parsedOrigin.Port() != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -437,3 +437,64 @@ func Test_IsDNSName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_IsTrustedOrigin(t *testing.T) {
|
||||||
|
f := func(s string, expected bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result := IsTrustedOrigin(s)
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf("unexpected result for %q; got %t; want %t", s, result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid trusted origins - host only
|
||||||
|
f("localhost", true)
|
||||||
|
f("example.com", true)
|
||||||
|
f("192.168.1.1", true)
|
||||||
|
f("api.example.com", true)
|
||||||
|
f("subdomain.example.org", true)
|
||||||
|
|
||||||
|
// Invalid trusted origins - host with port (no longer allowed)
|
||||||
|
f("localhost:8080", false)
|
||||||
|
f("example.com:3000", false)
|
||||||
|
f("192.168.1.1:443", false)
|
||||||
|
f("api.example.com:9000", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - empty or malformed
|
||||||
|
f("", false)
|
||||||
|
f("invalid url", false)
|
||||||
|
f("://example.com", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with scheme
|
||||||
|
f("http://example.com", false)
|
||||||
|
f("https://localhost", false)
|
||||||
|
f("ftp://192.168.1.1", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with user info
|
||||||
|
f("user@example.com", false)
|
||||||
|
f("user:pass@localhost", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with path
|
||||||
|
f("example.com/path", false)
|
||||||
|
f("localhost/api", false)
|
||||||
|
f("192.168.1.1/static", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with query parameters
|
||||||
|
f("example.com?param=value", false)
|
||||||
|
f("localhost:8080?query=test", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with fragment
|
||||||
|
f("example.com#fragment", false)
|
||||||
|
f("localhost:3000#section", false)
|
||||||
|
|
||||||
|
// Invalid trusted origins - with multiple invalid components
|
||||||
|
f("https://user@example.com/path?query=value#fragment", false)
|
||||||
|
f("http://localhost:8080/api/v1?param=test", false)
|
||||||
|
|
||||||
|
// Edge cases - ports are no longer allowed
|
||||||
|
f("example.com:0", false) // port 0 is no longer valid
|
||||||
|
f("example.com:65535", false) // max port number is no longer valid
|
||||||
|
f("example.com:99999", false) // invalid port number
|
||||||
|
f("example.com:-1", false) // negative port
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user