From dba5831a9245db595d015c4809d0a4a6b6ebe74c Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 09:16:55 -0700 Subject: [PATCH 1/6] Do not follow symlinks when performing chmod operations --- internal/ufs/fs_unix.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ufs/fs_unix.go b/internal/ufs/fs_unix.go index dff36c9b8..6f62b4cb9 100644 --- a/internal/ufs/fs_unix.go +++ b/internal/ufs/fs_unix.go @@ -90,7 +90,7 @@ func (fs *UnixFS) Chmodat(dirfd int, name string, mode FileMode) error { } func (fs *UnixFS) fchmodat(op string, dirfd int, name string, mode FileMode) error { - return ensurePathError(unix.Fchmodat(dirfd, name, uint32(mode), 0), op, name) + return ensurePathError(unix.Fchmodat(dirfd, name, uint32(mode), AT_SYMLINK_NOFOLLOW), op, name) } // Chown changes the numeric uid and gid of the named file. From 87f58ee4f1c9fbc8a1a877d32b42e3e29b9ae3bb Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 09:20:05 -0700 Subject: [PATCH 2/6] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efdb610f..ab0b80fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.12.2 +### Fixed +* Fixes a bug where `fs.Chmod` would change the symlink target possibly allowing a malicious user to modify files outside their home directory. +* Improved error handling when downloading files to not log as a 500-level error, preferring a 400-level response. + ## v1.12.1 ### Added * Add mount for /etc/machine-id for servers for Hytale ([#292](https://github.com/pterodactyl/wings/pull/292)) From d0ddc80844479302abdaf9654de3bacd511c0f5c Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 11:00:27 -0700 Subject: [PATCH 3/6] Verify scope of tokens provided by panel --- router/router.go | 6 +++++- router/router_download.go | 4 ++-- router/router_server_files.go | 2 +- router/router_transfer.go | 7 ++++++- router/tokens/backup.go | 1 + router/tokens/file.go | 2 ++ router/tokens/token.go | 33 +++++++++++++++++++++++++++++++++ router/tokens/transfer.go | 1 + router/tokens/upload.go | 1 + router/tokens/websocket.go | 1 + router/websocket/websocket.go | 4 ++-- 11 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 router/tokens/token.go diff --git a/router/router.go b/router/router.go index 0cde372c6..bdac76065 100644 --- a/router/router.go +++ b/router/router.go @@ -1,6 +1,8 @@ package router import ( + "regexp" + "emperror.dev/errors" "github.com/apex/log" "github.com/gin-gonic/gin" @@ -11,6 +13,8 @@ import ( wserver "github.com/pterodactyl/wings/server" ) +var tokenRegex = regexp.MustCompile(`([?|&]token=)([^&]+)($|&)`) + // Configure configures the routing infrastructure for this daemon instance. func Configure(m *wserver.Manager, client remote.Client) *gin.Engine { gin.SetMode("release") @@ -34,7 +38,7 @@ func Configure(m *wserver.Manager, client remote.Client) *gin.Engine { "status": params.StatusCode, "latency": params.Latency, "request_id": params.Keys["request_id"], - }).Debugf("%s %s", params.MethodColor()+params.Method+params.ResetColor(), params.Path) + }).Debugf("%s %s", params.Method, tokenRegex.ReplaceAllString(params.Path, "$1***$3")) return "" })) diff --git a/router/router_download.go b/router/router_download.go index 8ebcaa557..ec098236f 100644 --- a/router/router_download.go +++ b/router/router_download.go @@ -28,7 +28,7 @@ func getDownloadBackup(c *gin.Context) { } // Get the server using the UUID from the token. - if _, ok := manager.Get(token.ServerUuid); !ok || !token.IsUniqueRequest() { + if _, ok := manager.Get(token.ServerUuid); !ok || !token.IsUniqueRequest() || !token.HasScope(tokens.BackupDownload) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ "error": "The requested resource was not found on this server.", }) @@ -82,7 +82,7 @@ func getDownloadFile(c *gin.Context) { } s, ok := manager.Get(token.ServerUuid) - if !ok || !token.IsUniqueRequest() { + if !ok || !token.IsUniqueRequest() || !token.HasScope(tokens.FileDownload) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ "error": "The requested resource was not found on this server.", }) diff --git a/router/router_server_files.go b/router/router_server_files.go index 5b15a5c44..88acfdb77 100644 --- a/router/router_server_files.go +++ b/router/router_server_files.go @@ -575,7 +575,7 @@ func postServerUploadFiles(c *gin.Context) { } s, ok := manager.Get(token.ServerUuid) - if !ok || !token.IsUniqueRequest() { + if !ok || !token.IsUniqueRequest() || !token.HasScope(tokens.FileUpload) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ "error": "The requested resource was not found on this server.", }) diff --git a/router/router_transfer.go b/router/router_transfer.go index 1b062b054..0a635ef0b 100644 --- a/router/router_transfer.go +++ b/router/router_transfer.go @@ -31,7 +31,7 @@ func postTransfers(c *gin.Context) { if len(auth) != 2 || auth[0] != "Bearer" { c.Header("WWW-Authenticate", "Bearer") c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "The required authorization heads were not present in the request.", + "error": "The required authorization headers were not present in the request.", }) return } @@ -42,6 +42,11 @@ func postTransfers(c *gin.Context) { return } + if !token.HasScope(tokens.ServerTransfer) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden."}) + return + } + manager := middleware.ExtractManager(c) u, err := uuid.Parse(token.Subject) if err != nil { diff --git a/router/tokens/backup.go b/router/tokens/backup.go index 5408574cf..c0f43e51a 100644 --- a/router/tokens/backup.go +++ b/router/tokens/backup.go @@ -6,6 +6,7 @@ import ( type BackupPayload struct { jwt.Payload + Scoped ServerUuid string `json:"server_uuid"` BackupUuid string `json:"backup_uuid"` diff --git a/router/tokens/file.go b/router/tokens/file.go index 97b991d33..c60f39897 100644 --- a/router/tokens/file.go +++ b/router/tokens/file.go @@ -6,6 +6,8 @@ import ( type FilePayload struct { jwt.Payload + Scoped + FilePath string `json:"file_path"` ServerUuid string `json:"server_uuid"` UniqueId string `json:"unique_id"` diff --git a/router/tokens/token.go b/router/tokens/token.go new file mode 100644 index 000000000..96d3bdbb6 --- /dev/null +++ b/router/tokens/token.go @@ -0,0 +1,33 @@ +package tokens + +import ( + "strings" +) + +type JwtScope string + +const ( + Websocket = JwtScope("websocket") + FileUpload = JwtScope("file-upload") + FileDownload = JwtScope("file-download") + BackupDownload = JwtScope("backup-download") + ServerTransfer = JwtScope("transfer") +) + +type Scoped struct { + Scope string `json:"scope"` +} + +func (s Scoped) Scopes() []string { + return strings.Split(s.Scope, " ") +} + +func (s Scoped) HasScope(scope JwtScope) bool { + for _, v := range s.Scopes() { + if v == string(scope) { + return true + } + } + + return false +} diff --git a/router/tokens/transfer.go b/router/tokens/transfer.go index 5de05dc4c..b695f532b 100644 --- a/router/tokens/transfer.go +++ b/router/tokens/transfer.go @@ -6,6 +6,7 @@ import ( type TransferPayload struct { jwt.Payload + Scoped } // GetPayload returns the JWT payload. diff --git a/router/tokens/upload.go b/router/tokens/upload.go index 4fb3b1c0b..ad5d16b0f 100644 --- a/router/tokens/upload.go +++ b/router/tokens/upload.go @@ -6,6 +6,7 @@ import ( type UploadPayload struct { jwt.Payload + Scoped ServerUuid string `json:"server_uuid"` UserUuid string `json:"user_uuid"` diff --git a/router/tokens/websocket.go b/router/tokens/websocket.go index 2ad3b9dfe..8ad3fb55e 100644 --- a/router/tokens/websocket.go +++ b/router/tokens/websocket.go @@ -52,6 +52,7 @@ func DenyForServer(s string, u string) { type WebsocketPayload struct { jwt.Payload sync.RWMutex + Scoped UserUUID string `json:"user_uuid"` ServerUUID string `json:"server_uuid"` diff --git a/router/websocket/websocket.go b/router/websocket/websocket.go index 34b0db981..4482667e6 100644 --- a/router/websocket/websocket.go +++ b/router/websocket/websocket.go @@ -74,7 +74,7 @@ func NewTokenPayload(token []byte) (*tokens.WebsocketPayload, error) { return nil, ErrJwtOnDenylist } - if !payload.HasPermission(PermissionConnect) { + if !payload.HasPermission(PermissionConnect) || !payload.HasScope(tokens.Websocket) { return nil, ErrJwtNoConnectPerm } @@ -213,7 +213,7 @@ func (h *Handler) TokenValid() error { return ErrJwtOnDenylist } - if !j.HasPermission(PermissionConnect) { + if !j.HasPermission(PermissionConnect) || !j.HasScope(tokens.Websocket) { return ErrJwtNoConnectPerm } From c2de795725e4178dc8c3eb359466dcde8ae3e681 Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 11:01:25 -0700 Subject: [PATCH 4/6] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0b80fcf..955a24712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## v1.12.2 ### Fixed * Fixes a bug where `fs.Chmod` would change the symlink target possibly allowing a malicious user to modify files outside their home directory. -* Improved error handling when downloading files to not log as a 500-level error, preferring a 400-level response. +* Improved error handling when downloading files to not log as a 500-level error, preferring a 400-level response. +* Fixes JWT verification logic to confirm that the token has the required scopes for the target subsystem. ## v1.12.1 ### Added From 2bf1ee29a8c863175261f92cbd31a77b43c29876 Mon Sep 17 00:00:00 2001 From: robert dennis <31261583+robertdrakedennis@users.noreply.github.com> Date: Fri, 29 May 2026 14:40:01 -0400 Subject: [PATCH 5/6] restrict configuration in egg templating --- parser/helpers.go | 11 ++++++++--- parser/parser.go | 19 +++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/parser/helpers.go b/parser/helpers.go index be09c686f..a6aed945d 100644 --- a/parser/helpers.go +++ b/parser/helpers.go @@ -237,7 +237,7 @@ func (f *ConfigurationFile) LookupConfigurationValue(cfr ConfigurationFileReplac // Look for the key in the configuration file, and if found return that value to the // calling function. - match, _, _, err := jsonparser.Get(f.configuration, path...) + match, dataType, _, err := jsonparser.Get(f.configuration, path...) if err != nil { if err != jsonparser.KeyPathNotFoundError { return string(match), err @@ -248,7 +248,12 @@ func (f *ConfigurationFile) LookupConfigurationValue(cfr ConfigurationFileReplac // If there is no key, keep the original value intact, that way it is obvious there // is a replace issue at play. return string(match), nil - } else { - return configMatchRegex.ReplaceAllString(cfr.ReplaceWith.String(), string(match)), nil } + + // Only substitute scalar values, not whole objects or arrays. + if dataType == jsonparser.Object || dataType == jsonparser.Array { + return cfr.ReplaceWith.String(), nil + } + + return configMatchRegex.ReplaceAllString(cfr.ReplaceWith.String(), string(match)), nil } diff --git a/parser/parser.go b/parser/parser.go index e7c98b3b2..d6f51c40d 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -188,13 +188,28 @@ func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error { return nil } +type templatableConfig struct { + Docker struct { + Interface string `json:"interface"` + Network struct { + Interface string `json:"interface"` + } `json:"network"` + } `json:"docker"` +} + +func newTemplatableConfig(c *config.Configuration) templatableConfig { + var t templatableConfig + t.Docker.Interface = c.Docker.Network.Interface + t.Docker.Network.Interface = c.Docker.Network.Interface + return t +} + // Parse parses a given configuration file and updates all the values within // as defined in the API response from the Panel. func (f *ConfigurationFile) Parse(file ufs.File) error { // log.WithField("path", path).WithField("parser", f.Parser.String()).Debug("parsing server configuration file") - // What the fuck is going on here? - if mb, err := json.Marshal(config.Get()); err != nil { + if mb, err := json.Marshal(newTemplatableConfig(config.Get())); err != nil { return err } else { f.configuration = mb From d6a89d71ca9371a2a638b099d33187905fb1295d Mon Sep 17 00:00:00 2001 From: robert dennis <31261583+robertdrakedennis@users.noreply.github.com> Date: Fri, 29 May 2026 23:24:58 -0400 Subject: [PATCH 6/6] add conditional support for ioweight --- environment/settings.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/environment/settings.go b/environment/settings.go index 1d57154ee..6850167f4 100644 --- a/environment/settings.go +++ b/environment/settings.go @@ -3,6 +3,7 @@ package environment import ( "fmt" "math" + "os" "strconv" "github.com/apex/log" @@ -107,11 +108,15 @@ func (l Limits) AsContainerResources() container.Resources { Memory: l.BoundedMemoryLimit(), MemoryReservation: l.MemoryLimit * 1024 * 1024, MemorySwap: l.ConvertedSwap(), - BlkioWeight: l.IoWeight, OomKillDisable: &l.OOMDisabled, PidsLimit: &pids, } + // Only set the block IO weight when the host's cgroup hierarchy can honor it. + if blkioWeightSupported() { + resources.BlkioWeight = l.IoWeight + } + // If the CPU Limit is not set, don't send any of these fields through. Providing // them seems to break some Java services that try to read the available processors. // @@ -131,6 +136,26 @@ func (l Limits) AsContainerResources() container.Resources { return resources } +// blkioWeightSupported reports whether the host's cgroup hierarchy can honor a +// container block IO weight. On cgroup v2 the io.weight knob must be present or +// runc fails container creation; cgroup v1/hybrid always supports it. +func blkioWeightSupported() bool { + // cgroup v1/hybrid honors the weight via blkio.weight; only v2 needs probing. + if _, err := os.Stat("/sys/fs/cgroup/cgroup.controllers"); err != nil { + return true + } + // On v2 the knob lives on the delegated child cgroups, not the root. + for _, p := range []string{ + "/sys/fs/cgroup/system.slice/io.weight", + "/sys/fs/cgroup/io.weight", + } { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + type Variables map[string]interface{} // Get is an ugly hacky function to handle environment variables that get passed