diff --git a/router/tokens/websocket.go b/router/tokens/websocket.go index d9d710f..685fb0c 100644 --- a/router/tokens/websocket.go +++ b/router/tokens/websocket.go @@ -2,11 +2,40 @@ package tokens import ( "encoding/json" + "github.com/apex/log" "github.com/gbrlsnchs/jwt/v3" "strings" "sync" + "time" ) +// The time at which Wings was booted. No JWT's created before this time are allowed to +// connect to the socket since they may have been marked as denied already and therefore +// could be invalid at this point. +// +// By doing this we make it so that a user who gets disconnected from Wings due to a Wings +// reboot just needs to request a new token as if their old token had expired naturally. +var wingsBootTime = time.Now() + +// A map that contains any JTI's that have been denied by the Panel and the time at which +// they were marked as denied. Therefore any JWT with the same JTI and an IssuedTime that +// is the same as or before this time should be considered invalid. +// +// This is used to allow the Panel to revoke tokens en-masse for a given user & server +// combination since the JTI for tokens is just MD5(user.id + server.uuid). When a server +// is booted this listing is fetched from the panel and the Websocket is dynamically updated. +var denylist sync.Map + +// Adds a JTI to the denylist by marking any JWTs generated before the current time as +// being invalid if they use the same JTI. +func DenyJTI(jti string) { + log.WithField("jti", jti).Debugf("adding all JWTs with JTI of \"%s\" created before current time to denylist", jti) + + denylist.Store(jti, time.Now()) +} + +// A JWT payload for Websocket connections. This JWT is passed along to the Websocket after +// it has been connected to by sending an "auth" event. type WebsocketPayload struct { jwt.Payload sync.RWMutex @@ -24,6 +53,7 @@ func (p *WebsocketPayload) GetPayload() *jwt.Payload { return &p.Payload } +// Returns the UUID of the server associated with this JWT. func (p *WebsocketPayload) GetServerUuid() string { p.RLock() defer p.RUnlock() @@ -31,6 +61,33 @@ func (p *WebsocketPayload) GetServerUuid() string { return p.ServerUUID } +// Check if the JWT has been marked as denied by the instance due to either being issued +// before Wings was booted, or because we have denied all tokens with the same JTI +// occurring before a set time. +func (p *WebsocketPayload) Denylisted() bool { + // If there is no IssuedAt present for the token, we cannot validate the token so + // just immediately mark it as not valid. + if p.IssuedAt == nil { + return true + } + + // If the time that the token was issued is before the time at which Wings was booted + // then the token is invalid for our purposes, even if the token "has permission". + if p.IssuedAt.Time.Before(wingsBootTime) { + return true + } + + // Finally, if the token was issued before a time that is currently denied for this + // token instance, ignore the permissions response. + if t, ok := denylist.Load(p.JWTID); ok { + if p.IssuedAt.Time.Before(t.(time.Time)) { + return true + } + } + + return false +} + // Checks if the given token payload has a permission string. func (p *WebsocketPayload) HasPermission(permission string) bool { p.RLock() @@ -38,7 +95,7 @@ func (p *WebsocketPayload) HasPermission(permission string) bool { for _, k := range p.Permissions { if k == permission || (!strings.HasPrefix(permission, "admin") && k == "*") { - return true + return !p.Denylisted() } } diff --git a/router/websocket/websocket.go b/router/websocket/websocket.go index f897867..9a72aaf 100644 --- a/router/websocket/websocket.go +++ b/router/websocket/websocket.go @@ -14,7 +14,6 @@ import ( "github.com/pterodactyl/wings/environment/docker" "github.com/pterodactyl/wings/router/tokens" "github.com/pterodactyl/wings/server" - "github.com/pterodactyl/wings/server/filesystem" "net/http" "strings" "sync" @@ -45,12 +44,14 @@ var ( ErrJwtNotPresent = errors.New("jwt: no jwt present") ErrJwtNoConnectPerm = errors.New("jwt: missing connect permission") ErrJwtUuidMismatch = errors.New("jwt: server uuid mismatch") + ErrJwtOnDenylist = errors.New("jwt: created too far in past (denylist)") ) func IsJwtError(err error) bool { return errors.Is(err, ErrJwtNotPresent) || errors.Is(err, ErrJwtNoConnectPerm) || errors.Is(err, ErrJwtUuidMismatch) || + errors.Is(err, ErrJwtOnDenylist) || errors.Is(err, jwt.ErrExpValidation) } @@ -62,8 +63,12 @@ func NewTokenPayload(token []byte) (*tokens.WebsocketPayload, error) { return nil, err } + if payload.Denylisted() { + return nil, ErrJwtOnDenylist + } + if !payload.HasPermission(PermissionConnect) { - return nil, errors.New("not authorized to connect to this socket") + return nil, ErrJwtNoConnectPerm } return &payload, nil @@ -188,6 +193,10 @@ func (h *Handler) TokenValid() error { return err } + if j.Denylisted() { + return ErrJwtOnDenylist + } + if !j.HasPermission(PermissionConnect) { return ErrJwtNoConnectPerm } @@ -204,25 +213,23 @@ func (h *Handler) TokenValid() error { // error message, otherwise we just send back a standard error message. func (h *Handler) SendErrorJson(msg Message, err error, shouldLog ...bool) error { j := h.GetJwt() - expected := errors.Is(err, server.ErrSuspended) || - errors.Is(err, server.ErrIsRunning) || - errors.Is(err, filesystem.ErrNotEnoughDiskSpace) + isJWTError := IsJwtError(err) - message := "an unexpected error was encountered while handling this request" - if expected || (j != nil && j.HasPermission(PermissionReceiveErrors)) { - message = err.Error() + wsm := Message{ + Event: ErrorEvent, + Args: []string{"an unexpected error was encountered while handling this request"}, + } + if isJWTError || (j != nil && j.HasPermission(PermissionReceiveErrors)) { + wsm.Event = JwtErrorEvent + wsm.Args = []string{err.Error()} } - m, u := h.GetErrorMessage(message) - - wsm := Message{Event: ErrorEvent} + m, u := h.GetErrorMessage(wsm.Args[0]) wsm.Args = []string{m} - if len(shouldLog) == 0 || (len(shouldLog) == 1 && shouldLog[0] == true) { - if !expected && !IsJwtError(err) { - h.server.Log().WithFields(log.Fields{"event": msg.Event, "error_identifier": u.String(), "error": err}). - Error("failed to handle websocket process; an error was encountered processing an event") - } + if !isJWTError && (len(shouldLog) == 0 || (len(shouldLog) == 1 && shouldLog[0] == true)) { + h.server.Log().WithFields(log.Fields{"event": msg.Event, "error_identifier": u.String(), "error": err}). + Error("failed to handle websocket process; an error was encountered processing an event") } return h.unsafeSendJson(wsm)