Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3deb85b0da | ||
|
|
e115055a1b | ||
|
|
384cb53c64 | ||
|
|
4240cbf029 | ||
|
|
eb28dd4f4e | ||
|
|
78127f8f3d |
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.4",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.7",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.7\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.27.4
|
||||
// @version 2.27.7
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
36
api/http/middlewares/plaintext_http_request.go
Normal file
36
api/http/middlewares/plaintext_http_request.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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 xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
||||
req = csrf.PlaintextHTTPRequest(r)
|
||||
}
|
||||
|
||||
h.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func PlaintextHTTPRequest(next http.Handler) http.Handler {
|
||||
return &plainTextHTTPRequestHandler{next: next}
|
||||
}
|
||||
@@ -7,12 +7,31 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Note that we discard any non-canonical headers by design
|
||||
var allowedHeaders = map[string]struct{}{
|
||||
"Accept": {},
|
||||
"Accept-Encoding": {},
|
||||
"Accept-Language": {},
|
||||
"Cache-Control": {},
|
||||
"Content-Length": {},
|
||||
"Content-Type": {},
|
||||
"Private-Token": {},
|
||||
"User-Agent": {},
|
||||
"X-Portaineragent-Target": {},
|
||||
"X-Portainer-Volumename": {},
|
||||
"X-Registry-Auth": {},
|
||||
}
|
||||
|
||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{Director: createDirector(target)}
|
||||
}
|
||||
|
||||
func createDirector(target *url.URL) func(*http.Request) {
|
||||
targetQuery := target.RawQuery
|
||||
director := func(req *http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
@@ -26,8 +45,14 @@ func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
}
|
||||
|
||||
for k := range req.Header {
|
||||
if _, ok := allowedHeaders[k]; !ok {
|
||||
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
|
||||
delete(req.Header, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{Director: director}
|
||||
}
|
||||
|
||||
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
|
||||
|
||||
190
api/http/proxy/factory/reverse_proxy_test.go
Normal file
190
api/http/proxy/factory/reverse_proxy_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func Test_createDirector(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target *url.URL
|
||||
req *http.Request
|
||||
expectedReq *http.Request
|
||||
}{
|
||||
{
|
||||
name: "base case",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "no User-Agent",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Sensitive Headers",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{
|
||||
"Authorization": "secret",
|
||||
"Proxy-Authorization": "secret",
|
||||
"Cookie": "secret",
|
||||
"X-Csrf-Token": "secret",
|
||||
"X-Api-Key": "secret",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Portaineragent-Target": "test-agent-1",
|
||||
"X-Portainer-Volumename": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Portaineragent-Target": "test-agent-1",
|
||||
"X-Portainer-Volumename": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Non canonical Headers",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
portainer.PortainerAgentTargetHeader: "test-agent-1",
|
||||
"X-Portainer-VolumeName": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
false,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
director := createDirector(tc.target)
|
||||
director(tc.req)
|
||||
|
||||
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
|
||||
t.Fatalf("requests are different: \n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createURL(t *testing.T, urlString string) *url.URL {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create url: %s", err)
|
||||
}
|
||||
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create http request: %s", err)
|
||||
} else {
|
||||
for k, v := range headers {
|
||||
if canonicalHeaders {
|
||||
req.Header.Add(k, v)
|
||||
} else {
|
||||
req.Header[k] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func compareRequests(a, b *http.Request) bool {
|
||||
methodEqual := a.Method == b.Method
|
||||
urlEqual := cmp.Diff(a.URL, b.URL) == ""
|
||||
hostEqual := a.Host == b.Host
|
||||
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
|
||||
headersEqual := cmp.Diff(a.Header, b.Header) == ""
|
||||
|
||||
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func (server *Server) Start() error {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: handler,
|
||||
Handler: middlewares.PlaintextHTTPRequest(handler),
|
||||
ErrorLog: errorLogger,
|
||||
}
|
||||
|
||||
|
||||
@@ -1638,7 +1638,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.27.4"
|
||||
APIVersion = "2.27.7"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/portainer/portainer
|
||||
|
||||
go 1.23.8
|
||||
go 1.23.10
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
@@ -27,7 +27,7 @@ require (
|
||||
github.com/gofrs/uuid v4.2.0+incompatible
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -311,8 +311,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
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=
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.27.4",
|
||||
"version": "2.27.7",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
Reference in New Issue
Block a user