mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
chore(csrf): remove gorilla/csrf BE-12948 (#2618)
This commit is contained in:
@@ -108,7 +108,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
|
||||
##@ Docs
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
go mod download -x
|
||||
go mod download
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
|
||||
+1
-125
@@ -1,30 +1,13 @@
|
||||
package csrf
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
gcsrf "github.com/gorilla/csrf"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
const csrfSkipHeader = "X-CSRF-Token-Skip"
|
||||
|
||||
// SkipCSRFToken signals that the X-CSRF-Token header should not be sent in the response.
|
||||
// Deprecated: only meaningful when the "legacy-csrf" feature flag is enabled.
|
||||
func SkipCSRFToken(w http.ResponseWriter) {
|
||||
w.Header().Set(csrfSkipHeader, "1")
|
||||
}
|
||||
|
||||
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
|
||||
// DOCKER_EXTENSION=1 is set in build/docker-extension/docker-compose.yml
|
||||
isDockerDesktopExtension := false
|
||||
@@ -32,10 +15,6 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
|
||||
isDockerDesktopExtension = true
|
||||
}
|
||||
|
||||
if featureflags.IsEnabled("legacy-csrf") {
|
||||
return withLegacyProtect(handler, trustedOrigins, isDockerDesktopExtension)
|
||||
}
|
||||
|
||||
cop := http.NewCrossOriginProtection()
|
||||
for _, origin := range trustedOrigins {
|
||||
if err := cop.AddTrustedOrigin(origin); err != nil {
|
||||
@@ -58,14 +37,7 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
|
||||
protected := cop.Handler(handler)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if skip {
|
||||
if isDockerDesktopExtension {
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
@@ -74,99 +46,3 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
|
||||
protected.ServeHTTP(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacyProtect(handler http.Handler, trustedOrigins []string, isDockerDesktopExtension bool) (http.Handler, error) {
|
||||
handler = withLegacySendCSRFToken(handler)
|
||||
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
// gorilla/csrf compares referer.Host against trusted origin entries, so it
|
||||
// needs bare host[:port] values rather than full scheme://host[:port] origins.
|
||||
legacyOrigins := make([]string, len(trustedOrigins))
|
||||
for i, origin := range trustedOrigins {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trusted origin %q: %w", origin, err)
|
||||
}
|
||||
|
||||
legacyOrigins[i] = parsed.Host
|
||||
}
|
||||
|
||||
handler = gcsrf.Protect(
|
||||
token,
|
||||
gcsrf.Path("/"),
|
||||
gcsrf.Secure(false),
|
||||
gcsrf.TrustedOrigins(legacyOrigins),
|
||||
gcsrf.ErrorHandler(withLegacyErrorHandler(trustedOrigins)),
|
||||
)(handler)
|
||||
|
||||
return withLegacySkipCSRF(handler, isDockerDesktopExtension), nil
|
||||
}
|
||||
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacySendCSRFToken(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := negroni.NewResponseWriter(w)
|
||||
|
||||
sw.Before(func(sw negroni.ResponseWriter) {
|
||||
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
|
||||
sw.Header().Del(csrfSkipHeader)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
||||
}
|
||||
})
|
||||
|
||||
handler.ServeHTTP(sw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacySkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
skip, err := security.ShouldSkipCSRFCheck(r, isDockerDesktopExtension)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if skip {
|
||||
r = gcsrf.UnsafeSkipCheck(r)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Deprecated: use WithProtect without the "legacy-csrf" feature flag instead.
|
||||
func withLegacyErrorHandler(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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,51 +154,6 @@ func TestWithProtect_allowsPostFromTrustedOrigin(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithProtect_skipsCsrfForApiKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := WithProtect(okHandler, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
req.Header.Set("X-API-KEY", "my-api-key")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithProtect_skipsCsrfForBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := WithProtect(okHandler, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
req.Header.Set("Authorization", "Bearer some-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithProtect_forbidsBothApiKeyAndBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := WithProtect(okHandler, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-API-KEY", "my-api-key")
|
||||
req.Header.Set("Authorization", "Bearer some-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -213,88 +168,3 @@ func TestWithProtect_enforcesCsrfForCookieAuth(t *testing.T) {
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_noError_noOrigins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withLegacyProtect(okHandler, nil, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_noError_schemeHostOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withLegacyProtect(okHandler, []string{"https://example.com"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_noError_schemeHostPortOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withLegacyProtect(okHandler, []string{"https://example.com:3000"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_noError_multipleOrigins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withLegacyProtect(okHandler, []string{"https://example.com", "http://internal.example.com:8080"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_safeMethodsAlwaysAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := withLegacyProtect(okHandler, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||
req := httptest.NewRequest(method, "/", nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code, "method %s should be allowed", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_blocksPostWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := withLegacyProtect(okHandler, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: "some-token"})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_skipsCsrfForApiKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := withLegacyProtect(okHandler, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-API-KEY", "my-api-key")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWithLegacyProtect_skipsCsrfForBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := withLegacyProtect(okHandler, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer some-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
var (
|
||||
// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
|
||||
safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
)
|
||||
|
||||
type plainTextHTTPRequestHandler struct {
|
||||
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
|
||||
for pair := range strings.SplitSeq(firstPart, ";") {
|
||||
// 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) {
|
||||
if slices.Contains(safeMethods, r.Method) {
|
||||
h.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := r
|
||||
// If original request was HTTPS (via proxy), keep CSRF checks.
|
||||
if !isHTTPSRequest(r) {
|
||||
req = csrf.PlaintextHTTPRequest(r)
|
||||
}
|
||||
|
||||
h.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func PlaintextHTTPRequest(next http.Handler) http.Handler {
|
||||
return &plainTextHTTPRequestHandler{next: next}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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) {
|
||||
t.Parallel()
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -307,6 +307,14 @@ func (bouncer *RequestBouncer) mwIsTeamLeader(next http.Handler) http.Handler {
|
||||
// A result of a first succeeded token lookup would be used for the authentication.
|
||||
func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, hasAPIKey := extractAPIKey(r)
|
||||
_, hasBearerToken := extractBearerToken(r)
|
||||
if hasAPIKey && hasBearerToken {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "API key and auth header are not allowed at the same time", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var token *portainer.TokenData
|
||||
|
||||
for _, lookup := range tokenLookups {
|
||||
@@ -570,40 +578,3 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ShouldSkipCSRFCheck checks if the CSRF check should be skipped
|
||||
//
|
||||
// It returns true if the request has no cookie token and has either (but not both):
|
||||
// - an api key header
|
||||
// - an auth header
|
||||
// if it has both headers, an error is returned
|
||||
//
|
||||
// we allow CSRF check to be skipped for the following reasons:
|
||||
// - public routes
|
||||
// - kubectl - a bearer token is needed, and no csrf token can be sent
|
||||
// - api token
|
||||
// - docker desktop extension
|
||||
func ShouldSkipCSRFCheck(r *http.Request, isDockerDesktopExtension bool) (bool, error) {
|
||||
if isDockerDesktopExtension {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
cookie, _ := r.Cookie(portainer.AuthCookieKey)
|
||||
hasCookie := cookie != nil && cookie.Value != ""
|
||||
|
||||
if hasCookie {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
apiKey := r.Header.Get(apiKeyHeader)
|
||||
hasApiKey := apiKey != ""
|
||||
|
||||
authHeader := r.Header.Get(jwtTokenHeader)
|
||||
hasAuthHeader := authHeader != ""
|
||||
|
||||
if hasApiKey && hasAuthHeader {
|
||||
return false, errors.New("api key and auth header are not allowed at the same time")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -399,81 +399,25 @@ func Test_apiKeyLookup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ShouldSkipCSRFCheck(t *testing.T) {
|
||||
func Test_mwAuthenticateFirst_rejectsBothAPIKeyAndBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
tt := []struct {
|
||||
name string
|
||||
cookieValue string
|
||||
apiKey string
|
||||
authHeader string
|
||||
isDockerDesktopExtension bool
|
||||
expectedResult bool
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "Should return false (not skip) when cookie is present",
|
||||
cookieValue: "test-cookie",
|
||||
isDockerDesktopExtension: false,
|
||||
},
|
||||
{
|
||||
name: "Should return true (skip) when cookie is present and docker desktop extension is true",
|
||||
cookieValue: "test-cookie",
|
||||
isDockerDesktopExtension: true,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return true (skip) when cookie is not present",
|
||||
cookieValue: "",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return true (skip) when api key is present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return true (skip) when auth header is present",
|
||||
cookieValue: "",
|
||||
authHeader: "test-auth-header",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "Should return false (not skip) and error when both api key and auth header are present",
|
||||
cookieValue: "",
|
||||
apiKey: "test-api-key",
|
||||
authHeader: "test-auth-header",
|
||||
isDockerDesktopExtension: false,
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
for _, test := range tt {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if test.cookieValue != "" {
|
||||
req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: test.cookieValue})
|
||||
}
|
||||
if test.apiKey != "" {
|
||||
req.Header.Set(apiKeyHeader, test.apiKey)
|
||||
}
|
||||
if test.authHeader != "" {
|
||||
req.Header.Set(jwtTokenHeader, test.authHeader)
|
||||
}
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ShouldSkipCSRFCheck(req, test.isDockerDesktopExtension)
|
||||
is.Equal(test.expectedResult, result)
|
||||
if test.expectedError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
apiKeyService := apikey.NewAPIKeyService(nil, nil)
|
||||
bouncer := NewRequestBouncer(t.Context(), store, jwtService, apiKeyService)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set(apiKeyHeader, "test-api-key")
|
||||
req.Header.Set(jwtTokenHeader, "Bearer test-token")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h := bouncer.mwAuthenticateFirst(nil, testHandler200)
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestJWTRevocation(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ func (server *Server) Start(ctx context.Context) error {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: middlewares.PlaintextHTTPRequest(handler),
|
||||
Handler: handler,
|
||||
ErrorLog: errorLogger,
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1977,7 +1977,7 @@ const (
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
var SupportedFeatureFlags = []featureflags.Feature{"hsts", "csp", "legacy-csrf"}
|
||||
var SupportedFeatureFlags = []featureflags.Feature{"hsts", "csp"}
|
||||
|
||||
const (
|
||||
_ AuthenticationMethod = iota
|
||||
|
||||
@@ -33,7 +33,5 @@ export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions
|
||||
if (type && hasNoContentType) {
|
||||
jqXhr.setRequestHeader('Content-Type', 'application/json');
|
||||
}
|
||||
const csrfCookie = window.cookieStore.get('_gorilla_csrf');
|
||||
jqXhr.setRequestHeader('X-CSRF-Token', csrfCookie);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { agentInterceptor } from '@/react/portainer/services/axios/axios';
|
||||
import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
|
||||
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
|
||||
|
||||
/* @ngInject */
|
||||
@@ -26,11 +25,6 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
|
||||
request: agentInterceptor,
|
||||
}));
|
||||
|
||||
$httpProvider.interceptors.push(() => ({
|
||||
response: csrfTokenReaderInterceptorAngular,
|
||||
request: csrfInterceptor,
|
||||
}));
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
mouseenter: 'mouseleave',
|
||||
click: 'click',
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { CacheAxiosResponse } from 'axios-cache-interceptor';
|
||||
import { IHttpResponse } from 'angular';
|
||||
|
||||
import axios from './axios/axios';
|
||||
|
||||
axios.interceptors.response.use(csrfTokenReaderInterceptor);
|
||||
axios.interceptors.request.use(csrfInterceptor);
|
||||
|
||||
let csrfToken: string | null = null;
|
||||
|
||||
export function csrfTokenReaderInterceptor(
|
||||
config: CacheAxiosResponse | AxiosResponse
|
||||
) {
|
||||
const csrfTokenHeader = config.headers['x-csrf-token'];
|
||||
if (csrfTokenHeader) {
|
||||
csrfToken = csrfTokenHeader;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function csrfTokenReaderInterceptorAngular(
|
||||
config: IHttpResponse<unknown>
|
||||
) {
|
||||
const csrfTokenHeader = config.headers('x-csrf-token');
|
||||
if (csrfTokenHeader) {
|
||||
csrfToken = csrfTokenHeader;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function csrfInterceptor(config: InternalAxiosRequestConfig) {
|
||||
if (!csrfToken) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const newConfig = { ...config };
|
||||
newConfig.headers['X-CSRF-Token'] = csrfToken;
|
||||
return newConfig;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/hashicorp/golang-lru v0.6.0
|
||||
@@ -55,7 +54,6 @@ require (
|
||||
github.com/segmentio/encoding v0.5.3
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/negroni v1.0.0
|
||||
github.com/viney-shih/go-lock v1.1.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go.podman.io/image/v5 v5.37.0
|
||||
@@ -75,8 +73,6 @@ require (
|
||||
oras.land/oras-go/v2 v2.6.0
|
||||
)
|
||||
|
||||
require github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
|
||||
@@ -462,8 +462,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
@@ -478,15 +476,11 @@ github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gophercloud/gophercloud/v2 v2.10.0 h1:NRadC0aHNvy4iMoFXj5AFiPmut/Sj3hAPAo9B59VMGc=
|
||||
github.com/gophercloud/gophercloud/v2 v2.10.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk=
|
||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||
@@ -953,8 +947,6 @@ github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Q
|
||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV2eI=
|
||||
|
||||
Reference in New Issue
Block a user