diff --git a/.gitignore b/.gitignore index d4a9ae66..b2489a47 100755 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,5 @@ _ReSharper*/ jupyter-docker/jupyter/MythicExamples/.ipynb_checkpoints/ nginx-docker/config/blockips.conf + +.gocache diff --git a/MythicReactUI/src/components/pages/Callbacks/ResponseDisplayScreenshotModal.js b/MythicReactUI/src/components/pages/Callbacks/ResponseDisplayScreenshotModal.js index 6c9d445e..24af2706 100644 --- a/MythicReactUI/src/components/pages/Callbacks/ResponseDisplayScreenshotModal.js +++ b/MythicReactUI/src/components/pages/Callbacks/ResponseDisplayScreenshotModal.js @@ -26,7 +26,7 @@ export function ResponseDisplayScreenshotModal({onClose, images, startIndex}) { < >
-
diff --git a/MythicReactUI/src/components/pages/Home/CallbacksCard.js b/MythicReactUI/src/components/pages/Home/CallbacksCard.js index 8fb407e3..5ff7b0c5 100644 --- a/MythicReactUI/src/components/pages/Home/CallbacksCard.js +++ b/MythicReactUI/src/components/pages/Home/CallbacksCard.js @@ -1535,7 +1535,7 @@ const Top10RecentScreenshotsDashboardElement = ({me, data, editing, removeElemen - onPreviewMedia(e, activeStep)} /> diff --git a/MythicReactUI/src/components/pages/Home/DashboardComponents.js b/MythicReactUI/src/components/pages/Home/DashboardComponents.js index 2353e03f..14749aab 100644 --- a/MythicReactUI/src/components/pages/Home/DashboardComponents.js +++ b/MythicReactUI/src/components/pages/Home/DashboardComponents.js @@ -48,7 +48,7 @@ export const PieChartCard = ({ } const theme = useTheme(); return ( - { const theme = useTheme(); return ( - { const theme = useTheme(); return ( - { } }; return ( - { const theme = useTheme(); return ( - - {openScreenshot && { let access_token = localStorage.getItem("access_token"); - if(!access_token){ - let cookie = document.cookie; - if(cookie && cookie !== ""){ - let cookies = cookie.split(";"); - for(let i = 0; i < cookies.length; i++){ - let cookiePieces = cookies[i].split("="); - if(cookiePieces.length !== 2){ - console.log("bad number of cookie pieces", "cookie", cookies[i]) - } else if(cookiePieces[0].trim() !== "user") { - console.log("unknown cookie", "name", cookiePieces[0].trim()); - } else { - try{ - console.log("user cookie found, trying to parse"); - let cookieString = decodeURIComponent(cookiePieces[1].trim()); - let cookieJSON = JSON.parse(atob(cookieString)); - if("access_token" in cookieJSON){ - successfulLogin(cookieJSON); - restartWebsockets(); - access_token = localStorage.getItem("access_token"); - }else{ - snackActions.warning("Invalid Authentication"); - console.log("Error", cookieJSON); - } - }catch(error){ - console.log("error processing cookie value", error) - } - } - } - } - } //console.log("in isJWTValid", "access_token", access_token); if(access_token){ const decoded_token = jwtDecode(access_token); diff --git a/Mythic_CLI/src/cmd/internal/serviceMetadata.go b/Mythic_CLI/src/cmd/internal/serviceMetadata.go index 887cb980..1992bc0b 100644 --- a/Mythic_CLI/src/cmd/internal/serviceMetadata.go +++ b/Mythic_CLI/src/cmd/internal/serviceMetadata.go @@ -220,7 +220,7 @@ func AddMythicService(service string, removeVolume bool) { "HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL=1000", "HASURA_GRAPHQL_AUTH_HOOK=http://${MYTHIC_SERVER_HOST}:${MYTHIC_SERVER_PORT}/graphql/webhook", "HASURA_GRAPHQL_AUTH_HOOK_MODE=POST", - "MYTHIC_ACTIONS_URL_BASE=http://${MYTHIC_SERVER_HOST}:${MYTHIC_SERVER_PORT}/api/v1.4", + "MYTHIC_ACTIONS_URL_BASE=http://${MYTHIC_SERVER_HOST}:${MYTHIC_SERVER_PORT}/", "HASURA_GRAPHQL_CONSOLE_ASSETS_DIR=/srv/console-assets", } if mythicEnv.GetString("mythic_docker_networking") == "bridge" { diff --git a/mythic-docker/src/authentication/jwt.go b/mythic-docker/src/authentication/jwt.go index 6fce92e2..a8b09150 100644 --- a/mythic-docker/src/authentication/jwt.go +++ b/mythic-docker/src/authentication/jwt.go @@ -3,6 +3,8 @@ package authentication import ( "errors" "fmt" + "strings" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" "github.com/its-a-feature/Mythic/authentication/mythicjwt" @@ -11,20 +13,27 @@ import ( "github.com/its-a-feature/Mythic/logging" "github.com/its-a-feature/Mythic/rabbitmq" "github.com/its-a-feature/Mythic/utils" - "strings" ) var ( ErrUnexpectedSigningMethod = errors.New("Unexpected signing method") - ErrGetApiTokenOrBearer = errors.New("Failed to get apitoken or bearer token") ErrBearerInvalidValue = errors.New("Authorization bearer value is invalid") ErrMissingAuthorizationBearerToken = errors.New("Missing Authorization Bearer token") ErrMissingAuthorizationHeader = errors.New("Missing Authorization header") - ErrMissingJWTToken = errors.New("Missing JWT header") + ErrLegacyAPITokenJWTDeprecated = errors.New("Legacy JWT API tokens are no longer accepted. Generate and use a new API token.") +) + +const ( + ContextKeyClaims = "claims" + ContextKeyAPIToken = "apitoken_struct" + ContextKeyUserID = "user_id" + ContextKeyUsername = "username" ) var ( - SQLGetIDForActiveToken = `SELECT + // LegacyAPITokenJWTCutoff // int64(time.Now().Unix()) for a specific date via iat + LegacyAPITokenJWTCutoff = int64(1776211200) // April 15, 2026 + SQLGetIDForActiveToken = `SELECT apitokens.id, apitokens.operator_id, apitokens.name, apitokens.active, apitokens.token_value, apitokens.deleted, apitokens.eventstepinstance_id, apitokens.token_type, operator.username "operator.username", @@ -32,122 +41,143 @@ var ( operator.current_operation_id "operator.current_operation_id" FROM apitokens JOIN operator ON apitokens.operator_id = operator.id - WHERE apitokens.token_value=$1` + WHERE apitokens.token_value=$1 + LIMIT 1` ) +func getAPITokenFromDB(c *gin.Context, tokenString string) (databaseStructs.Apitokens, error) { + if cachedToken, ok := c.Get(ContextKeyAPIToken); ok { + if token, ok := cachedToken.(databaseStructs.Apitokens); ok { + return token, nil + } + } + databaseApiToken := databaseStructs.Apitokens{} + hashedTokenValue := mythicjwt.HashAPITokenValue(tokenString) + if err := database.DB.Get(&databaseApiToken, SQLGetIDForActiveToken, hashedTokenValue); err != nil { + logging.LogError(err, "Failed to get apitoken from database", "apitoken", tokenString) + return databaseApiToken, err + } + c.Set(ContextKeyAPIToken, databaseApiToken) + return databaseApiToken, nil +} + +func claimsFromAPIToken(databaseApiToken databaseStructs.Apitokens) *mythicjwt.CustomClaims { + claims := mythicjwt.CustomClaims{ + UserID: databaseApiToken.OperatorID, + AuthMethod: databaseApiToken.TokenType, + APITokensID: databaseApiToken.ID, + OperationID: int(databaseApiToken.Operator.CurrentOperationID.Int64), + } + if databaseApiToken.EventStepInstanceID.Valid { + claims.EventStepInstanceID = int(databaseApiToken.EventStepInstanceID.Int64) + } + return &claims +} + +func looksLikeOpaqueAPIToken(tokenString string) bool { + return strings.HasPrefix(tokenString, mythicjwt.APITokenValuePrefix) +} + +func validateAndSetAPITokenContext(c *gin.Context, tokenString string) error { + databaseApiToken, err := getAPITokenFromDB(c, tokenString) + if err != nil { + return err + } + if !databaseApiToken.Active { + go func(token databaseStructs.Apitokens) { + go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Deactivated APIToken, %s, for user %s (%s) attempted to be used", + token.Name, token.Operator.Username, token.Operator.AccountType), + int(token.Operator.CurrentOperationID.Int64), token.Name+fmt.Sprintf("%d", token.ID)+token.Operator.Username, + database.MESSAGE_LEVEL_API, true) + }(databaseApiToken) + return errors.New("Deactivated APIToken attempted to be used") + } + if databaseApiToken.Deleted { + go func(token databaseStructs.Apitokens) { + go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Deleted APIToken, \"%s\", for user %s (%s) attempted to be used", + token.Name, token.Operator.Username, token.Operator.AccountType), + int(token.Operator.CurrentOperationID.Int64), token.Name+fmt.Sprintf("%d", token.ID)+token.Operator.Username, + database.MESSAGE_LEVEL_API, true) + }(databaseApiToken) + return errors.New("Deleted APIToken attempted to be used") + } + source := c.GetHeader("MythicSource") + if source == "web" { + logging.LogError(errors.New("web used apitoken"), "API Token used from the web, this is wrong") + return errors.New("web shouldn't use apitokens") + } + if databaseApiToken.EventStepInstanceID.Valid { + c.Request.Header.Set("MythicSource", "eventing") + c.Set("eventstepinstance_id", int(databaseApiToken.EventStepInstanceID.Int64)) + } else { + c.Request.Header.Set("MythicSource", "apitoken") + } + c.Set(ContextKeyUserID, databaseApiToken.OperatorID) + c.Set(ContextKeyUsername, databaseApiToken.Operator.Username) + c.Set(ContextKeyAPIToken, databaseApiToken) + c.Set("account", databaseApiToken.Operator.AccountType) + c.Set(ContextKeyClaims, claimsFromAPIToken(databaseApiToken)) + return nil +} + func GetClaims(c *gin.Context) (*mythicjwt.CustomClaims, error) { // just get the claims out of the JWT used for the request - tokenString, err := ExtractToken(c) - if err != nil { - if len(tokenString) == 0 { - tokenString, err = ExtractAPIToken(c) - if err != nil { - return nil, err - } - if len(tokenString) == 0 { - return nil, ErrGetApiTokenOrBearer - } + if existingClaims, exists := c.Get(ContextKeyClaims); exists { + if typedClaims, ok := existingClaims.(*mythicjwt.CustomClaims); ok { + return typedClaims, nil } } - - token, _, err := new(jwt.Parser).ParseUnverified(tokenString, &mythicjwt.CustomClaims{}) + tokenString, err := ExtractToken(c) if err != nil { return nil, err } - - if claims, ok := token.Claims.(*mythicjwt.CustomClaims); ok { - c.Set("user_id", claims.UserID) - return claims, nil + if looksLikeOpaqueAPIToken(tokenString) { + apiTokenErr := validateAndSetAPITokenContext(c, tokenString) + if apiTokenErr != nil { + logging.LogError(err, "failed to parse jwt") + return nil, apiTokenErr + } + if existingClaims, exists := c.Get(ContextKeyClaims); exists { + if typedClaims, ok := existingClaims.(*mythicjwt.CustomClaims); ok { + return typedClaims, nil + } + } + return nil, errors.New("failed to get claims from validated apitoken") } - - return nil, err + claims := mythicjwt.CustomClaims{} + _, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + logging.LogError(ErrUnexpectedSigningMethod, "signing method", token.Header["alg"]) + return nil, fmt.Errorf("%w: %v", ErrUnexpectedSigningMethod, token.Header["alg"]) + } + return utils.MythicConfig.JWTSecret, nil + }) + if err != nil { + logging.LogError(err, "failed to parse jwt") + return nil, err + } + if claims.IssuedAt < LegacyAPITokenJWTCutoff { + logging.LogError(ErrLegacyAPITokenJWTDeprecated, + "auth_method", claims.AuthMethod, + "issued_at", claims.IssuedAt, + "cutoff", LegacyAPITokenJWTCutoff) + return nil, ErrLegacyAPITokenJWTDeprecated + } + c.Set(ContextKeyUserID, claims.UserID) + c.Set(ContextKeyClaims, &claims) + return &claims, nil } func TokenValid(c *gin.Context) error { - tokenString, err := ExtractToken(c) + claims, err := GetClaims(c) if err != nil { - tokenString, err = ExtractAPIToken(c) - if err != nil { - logging.LogError(err, "Failed to extract apitoken") - return err - } - // we have an apitoken to process - databaseApiToken := databaseStructs.Apitokens{} - if err = database.DB.Get(&databaseApiToken, SQLGetIDForActiveToken, tokenString); err != nil { - logging.LogError(err, "Failed to get apitoken from database", "apitoken", tokenString) - return err - } - if !databaseApiToken.Active { - go func(token databaseStructs.Apitokens) { - go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Deactivated APIToken, %s, for user %s (%s) attempted to be used", - token.Name, token.Operator.Username, token.Operator.AccountType), - int(token.Operator.CurrentOperationID.Int64), "apitoken usage", - database.MESSAGE_LEVEL_API, true) - }(databaseApiToken) - return errors.New("Deactivated APIToken attempted to be used") - } - if databaseApiToken.Deleted { - go func(token databaseStructs.Apitokens) { - go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Deleted APIToken, \"%s\", for user %s (%s) attempted to be used", - token.Name, token.Operator.Username, token.Operator.AccountType), - int(token.Operator.CurrentOperationID.Int64), token.Name+fmt.Sprintf("%d", token.ID)+token.Operator.Username, - database.MESSAGE_LEVEL_API, true) - }(databaseApiToken) - return errors.New("Deleted APIToken attempted to be used") - } - source := c.GetHeader("MythicSource") - if source == "web" { - logging.LogError(errors.New("web used apitoken"), "API Token used from the web, this is wrong") - return errors.New("web shouldn't use apitokens") - } - if databaseApiToken.EventStepInstanceID.Valid { - c.Request.Header.Add("MythicSource", "eventing") - c.Set("eventstepinstance_id", int(databaseApiToken.EventStepInstanceID.Int64)) - } else { - c.Request.Header.Add("MythicSource", "apitoken") - } - c.Set("apitoken_logging_struct", databaseApiToken) - c.Set("user_id", databaseApiToken.OperatorID) - c.Set("username", databaseApiToken.Operator.Username) - c.Set("apitoken", databaseApiToken.Name) - c.Set("account", databaseApiToken.Operator.AccountType) - /* - if hasura, ok := c.Get("hasura"); ok { - logging.LogInfo("got hasura info", "hasura", hasura) - } else { - logging.LogInfo("no hasura info yet") - } - go func(token databaseStructs.Apitokens) { - go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("APIToken, %s, for user %s (%s) just used", - token.Name, token.Operator.Username, token.Operator.AccountType), - int(token.Operator.CurrentOperationID.Int64), token.TokenValue, - database.MESSAGE_LEVEL_DEBUG) - }(databaseApiToken) - - */ - return nil + return err } - claims := mythicjwt.CustomClaims{} - if len(tokenString) > 0 { - _, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - logging.LogError(ErrUnexpectedSigningMethod, "signing method", token.Header["alg"]) - return nil, fmt.Errorf("%w: %v", ErrUnexpectedSigningMethod, token.Header["alg"]) - } - return utils.MythicConfig.JWTSecret, nil - }) - if err != nil { - logging.LogError(err, "Failed to parse JWT with claims", "JWT", tokenString) - return err - } - operator, err := database.GetUserFromID(claims.UserID) - if err == nil { - c.Set("username", operator.Username) - } - c.Set("user_id", claims.UserID) - return nil + operator, err := database.GetUserFromID(claims.UserID) + if err == nil { + c.Set(ContextKeyUsername, operator.Username) } - return errors.New("failed to get token") + return nil } type HasuraRequest struct { @@ -162,24 +192,12 @@ type HasuraRequestGraphQL struct { func ExtractToken(c *gin.Context) (string, error) { token := c.Request.Header.Get("Authorization") - //logging.LogDebug("got Authorization header", "Authorization", token) - if len(token) == 0 { - token = c.Request.Header.Get("apitoken") - } if len(token) == 0 { if c.Request.Method == "POST" { if _, ok := c.Get("hasura"); !ok { - // don't try to double process, only do this once - /* - var buf bytes.Buffer - tee := io.TeeReader(c.Request.Body, &buf) - body, _ := ioutil.ReadAll(tee) - c.Request.Body = ioutil.NopCloser(&buf) - logging.LogInfo("raw hasura info", "raw body", string(body)) - - */ var input HasuraRequest - if err := c.ShouldBindJSON(&input); err != nil { + err := c.ShouldBindJSON(&input) + if err != nil { logging.LogError(err, "Failed to find hasura request") return "", ErrMissingAuthorizationHeader } @@ -187,7 +205,6 @@ func ExtractToken(c *gin.Context) (string, error) { c.Request.Header.Add(key, value) } c.Set("hasura", input) - //logging.LogInfo("hasura info", "hasura", input) } } token = c.Request.Header.Get("Authorization") @@ -195,7 +212,6 @@ func ExtractToken(c *gin.Context) (string, error) { return "", ErrMissingAuthorizationHeader } } - bearerPieces := strings.Split(token, " ") if len(bearerPieces) != 2 { return "", ErrMissingAuthorizationBearerToken @@ -203,44 +219,5 @@ func ExtractToken(c *gin.Context) (string, error) { if len(bearerPieces[1]) > 10 { return bearerPieces[1], nil } - return "", ErrBearerInvalidValue } - -func ExtractAPIToken(c *gin.Context) (string, error) { - token := c.Request.Header.Get("apitoken") - if len(token) == 0 { - if c.Request.Method == "POST" { - if _, ok := c.Get("hasura"); !ok { - // don't try to double process, only do this once - /* - var buf bytes.Buffer - tee := io.TeeReader(c.Request.Body, &buf) - body, _ := ioutil.ReadAll(tee) - c.Request.Body = ioutil.NopCloser(&buf) - logging.LogInfo("raw hasura info", "raw body", string(body)) - - */ - var input HasuraRequest - if err := c.ShouldBindJSON(&input); err != nil { - logging.LogError(err, "Failed to find hasura request") - return "", ErrMissingAuthorizationHeader - } - for key, value := range input.Headers { - c.Request.Header.Add(key, value) - } - c.Set("hasura", input) - //logging.LogInfo("hasura info", "hasura", input) - } - } - token = c.Request.Header.Get("apitoken") - if len(token) == 0 { - if !strings.HasPrefix(c.Request.URL.Path, "/direct/") { - logging.LogError(nil, "[-] No 'apitoken` or 'Authorization: Bearer' token values supplied") - } - return "", ErrMissingJWTToken - } - } - //logging.LogTrace("got apitoken header", "apitoken", token) - return token, nil -} diff --git a/mythic-docker/src/authentication/jwt_test.go b/mythic-docker/src/authentication/jwt_test.go index ca6474da..8f7ae9ac 100644 --- a/mythic-docker/src/authentication/jwt_test.go +++ b/mythic-docker/src/authentication/jwt_test.go @@ -20,8 +20,6 @@ func TestTokenValid(t *testing.T) { } gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) user := databaseStructs.Operator{ID: 123} access_token, _, _, _ := mythicjwt.GenerateJWT(user, mythicjwt.AUTH_METHOD_USER, 0, 0) @@ -69,6 +67,8 @@ func TestTokenValid(t *testing.T) { tt := tt // shadowing t.Run(tt.name, func(t *testing.T) { t.Parallel() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) req := &http.Request{ Header: make(http.Header), } @@ -89,8 +89,6 @@ func TestGetClaims(t *testing.T) { } gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) user := databaseStructs.Operator{ID: 123} access_token, _, _, _ := mythicjwt.GenerateJWT(user, mythicjwt.AUTH_METHOD_USER, 0, 0) @@ -127,33 +125,6 @@ func TestGetClaims(t *testing.T) { want: nil, wantErr: true, }, - { - name: "Valid apitoken Claim", - args: args{ - header: map[string][]string{ - "Apitoken": {access_token}, // "Apitoken" and not "apitoken" here because Go uses the canonical form (first letter of the word and after hyphens as uppercase) - }, - }, - want: &mythicjwt.CustomClaims{ - UserID: 123, - AuthMethod: mythicjwt.AUTH_METHOD_USER, - StandardClaims: jwt.StandardClaims{ - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(mythicjwt.JWTTimespan).UTC().Unix(), - }, - }, - wantErr: false, - }, - { - name: "Empty apitoken", - args: args{ - header: map[string][]string{ - "Apitoken": {""}, - }, - }, - want: nil, - wantErr: true, - }, { name: "No auth", args: args{ @@ -166,7 +137,6 @@ func TestGetClaims(t *testing.T) { name: "Both token bad", args: args{ header: map[string][]string{ - "Apitoken": {"badtoken"}, "Authorization": {"Bearer bad"}, }, }, @@ -178,7 +148,8 @@ func TestGetClaims(t *testing.T) { for _, tt := range tests { tt := tt // shadowing t.Run(tt.name, func(t *testing.T) { - // t.Parallel() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) req := &http.Request{ Header: make(http.Header), } diff --git a/mythic-docker/src/authentication/middleware.go b/mythic-docker/src/authentication/middleware.go index aea8e52c..d63ee8f6 100644 --- a/mythic-docker/src/authentication/middleware.go +++ b/mythic-docker/src/authentication/middleware.go @@ -4,7 +4,9 @@ import ( "fmt" "net" "net/http" + "slices" + "github.com/its-a-feature/Mythic/authentication/mythicjwt" "github.com/its-a-feature/Mythic/rabbitmq" "github.com/gin-gonic/gin" @@ -44,12 +46,12 @@ func IPBlockMiddleware() gin.HandlerFunc { return } } - logging.LogError(nil, "Client IP not in allowed IP blocks", "client_ip", ipAddr) go rabbitmq.SendAllOperationsMessage(fmt.Sprintf("Client IP, %s, not in allowed IP blocks: %v", ipAddr.String(), utils.MythicConfig.AllowedIPBlocks), 0, ipAddr.String(), database.MESSAGE_LEVEL_AUTH, true) c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) c.Abort() + return } } @@ -60,12 +62,14 @@ func RBACMiddleware(allowedRoles []string) gin.HandlerFunc { logging.LogError(err, "Failed to get claims for RBACMiddleware") c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) c.Abort() + return } operatorOperation, err := database.GetUserCurrentOperation(customClaims.UserID) if err != nil { logging.LogError(err, "Failed to get user current operation") c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) c.Abort() + return } if operatorOperation.CurrentOperator.Admin || utils.SliceContains(allowedRoles, operatorOperation.ViewMode) { c.Set("operatorOperation", operatorOperation) @@ -76,7 +80,7 @@ func RBACMiddleware(allowedRoles []string) gin.HandlerFunc { logging.LogError(nil, "Unauthorized view mode for operation") c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) c.Abort() - + return } } @@ -100,3 +104,37 @@ func RBACMiddlewareOperationAdmin() gin.HandlerFunc { database.OPERATOR_OPERATION_VIEW_MODE_LEAD, }) } + +func scopeIncludes(scopes []string, required string) bool { + return slices.Contains(scopes, required) +} + +func DirectFileScopeMiddleware(requiredScope string) gin.HandlerFunc { + return func(c *gin.Context) { + claims, err := GetClaims(c) + if err != nil { + logging.LogError(err, "Failed to get claims for DirectFileScopeMiddleware") + c.JSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + c.Abort() + return + } + // Backward compatibility: existing normal JWT/APIToken flows without scopes continue to work. + if len(claims.Scopes) == 0 { + c.Next() + return + } + if !scopeIncludes(claims.Scopes, requiredScope) { + c.JSON(http.StatusForbidden, gin.H{"message": "Missing Proper Scope"}) + c.Abort() + return + } + if claims.AuthMethod == mythicjwt.AUTH_METHOD_SCOPED && + claims.FileUUID != "" && + claims.FileUUID != c.Param("file_uuid") { + c.JSON(http.StatusForbidden, gin.H{"message": "Forbidden"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/mythic-docker/src/authentication/mythicjwt/apitoken.go b/mythic-docker/src/authentication/mythicjwt/apitoken.go new file mode 100644 index 00000000..2876d6f5 --- /dev/null +++ b/mythic-docker/src/authentication/mythicjwt/apitoken.go @@ -0,0 +1,26 @@ +package mythicjwt + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +const ( + APITokenValueHashPrefix = "sha256:" + APITokenValuePrefix = "mtk_" +) + +func HashAPITokenValue(token string) string { + sum := sha256.Sum256([]byte(token)) + return APITokenValueHashPrefix + hex.EncodeToString(sum[:]) +} + +func GenerateOpaqueAPIToken() (string, string, error) { + randomValue, err := generateRandomPassword(64) + if err != nil { + return "", "", err + } + plainValue := fmt.Sprintf("%s%s", APITokenValuePrefix, randomValue) + return plainValue, HashAPITokenValue(plainValue), nil +} diff --git a/mythic-docker/src/authentication/mythicjwt/jwt.go b/mythic-docker/src/authentication/mythicjwt/jwt.go index 0b9ae9e4..15745d1e 100644 --- a/mythic-docker/src/authentication/mythicjwt/jwt.go +++ b/mythic-docker/src/authentication/mythicjwt/jwt.go @@ -17,11 +17,13 @@ import ( type CustomClaims struct { jwt.StandardClaims - UserID int `json:"user_id"` - AuthMethod string `json:"auth"` - EventStepInstanceID int `json:"eventstepinstance_id"` - APITokensID int `json:"apitokens_id"` - OperationID int `json:"operation_id"` + UserID int `json:"user_id"` + AuthMethod string `json:"auth"` + EventStepInstanceID int `json:"eventstepinstance_id"` + APITokensID int `json:"apitokens_id"` + OperationID int `json:"operation_id"` + Scopes []string `json:"scopes,omitempty"` + FileUUID string `json:"file_uuid,omitempty"` } var ( @@ -33,6 +35,9 @@ var ( AUTH_METHOD_EVENT = "event" AUTH_METHOD_TASK = "task" AUTH_METHOD_GRAPHQL_SPECTATOR = "graphql_spectator" + AUTH_METHOD_SCOPED = "scoped" + SCOPE_FILE_DIRECT_UPLOAD = "file.direct.upload" + SCOPE_FILE_DIRECT_DOWNLOAD = "file.direct.download" ErrFailedToFindRefreshToken = errors.New("Failed to find refresh token for specified access token") ErrRefreshTokenMissmatch = errors.New("Refresh token doesn't match for the given access token") ErrUnexpectedSigningMethod = errors.New("Unexpected signing method") @@ -96,18 +101,20 @@ func RefreshJWT(access_token string, refresh_token string) (string, string, int, } func GenerateJWT(user databaseStructs.Operator, authMethod string, eventStepInstanceID int, APITokensID int) (string, string, int, error) { + jwtStandardClaims := jwt.StandardClaims{ + IssuedAt: time.Now().UTC().Unix(), + ExpiresAt: time.Now().Add(JWTTimespan).UTC().Unix(), + } claims := CustomClaims{ - jwt.StandardClaims{ - IssuedAt: time.Now().UTC().Unix(), - ExpiresAt: time.Now().Add(JWTTimespan).UTC().Unix(), - }, + jwtStandardClaims, user.ID, authMethod, eventStepInstanceID, APITokensID, int(user.CurrentOperationID.Int64), + nil, + "", } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) access_token, err := token.SignedString(utils.MythicConfig.JWTSecret) if err != nil { @@ -127,3 +134,27 @@ func GenerateJWT(user databaseStructs.Operator, authMethod string, eventStepInst } return access_token, "", user.ID, nil } + +func GenerateScopedJWT(user databaseStructs.Operator, scopes []string, fileUUID string, ttl time.Duration) (string, int, error) { + jwtStandardClaims := jwt.StandardClaims{ + IssuedAt: time.Now().UTC().Unix(), + ExpiresAt: time.Now().Add(ttl).UTC().Unix(), + } + claims := CustomClaims{ + jwtStandardClaims, + user.ID, + AUTH_METHOD_SCOPED, + 0, + 0, + int(user.CurrentOperationID.Int64), + scopes, + fileUUID, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + access_token, err := token.SignedString(utils.MythicConfig.JWTSecret) + if err != nil { + logging.LogError(err, "Failed to generate scoped JWT") + return "", 0, err + } + return access_token, user.ID, nil +} diff --git a/mythic-docker/src/database/utils.go b/mythic-docker/src/database/utils.go index 9c3afe04..2c3b14e3 100644 --- a/mythic-docker/src/database/utils.go +++ b/mythic-docker/src/database/utils.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "errors" "fmt" mythicCrypto "github.com/its-a-feature/Mythic/crypto" @@ -71,6 +72,9 @@ func GetUserCurrentOperation(userID int) (*databaseStructs.Operatoroperation, er } func GetUserFromID(userID int) (*databaseStructs.Operator, error) { + if DB == nil { + return nil, errors.New("database connection not initialized") + } operator := databaseStructs.Operator{} err := DB.Get(&operator, `SELECT username, id, "admin", last_login, failed_login_count, salt, "password", secrets, preferences, diff --git a/mythic-docker/src/rabbitmq/check_container_status.go b/mythic-docker/src/rabbitmq/check_container_status.go index 8669ff73..9c479c0d 100644 --- a/mythic-docker/src/rabbitmq/check_container_status.go +++ b/mythic-docker/src/rabbitmq/check_container_status.go @@ -134,7 +134,6 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) { TokenValue: "", Active: true, } - operatorData := databaseStructs.Operator{} operatorOperationData := []databaseStructs.Operatoroperation{} onStartMessage := ContainerOnStartMessage{ ContainerName: containerName, @@ -163,9 +162,6 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) { // current operator is a bot, not deleted, and current operation is this operation apiToken.OperatorID = operatorOperationData[i].CurrentOperator.ID apiToken.CreatedBy = operatorOperationData[i].CurrentOperator.ID - operatorData.ID = operatorOperationData[i].CurrentOperator.ID - operatorData.CurrentOperationID.Valid = true - operatorData.CurrentOperationID.Int64 = int64(operation.ID) onStartMessage.OperationID = operatorOperationData[i].CurrentOperation.ID onStartMessage.OperationName = operatorOperationData[i].CurrentOperation.Name apiToken.Name = fmt.Sprintf("\"%s\"'s OnStart API Call for \"%s\". Deletes after 5min", @@ -193,18 +189,18 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) { logging.LogError(err, "failed to create new apitoken") continue } - accessToken, _, _, err := mythicjwt.GenerateJWT(operatorData, apiToken.TokenType, 0, apiToken.ID) + plainAPITokenValue, storedAPITokenValue, err := mythicjwt.GenerateOpaqueAPIToken() if err != nil { - logging.LogError(err, "failed to generate new JWT") + logging.LogError(err, "failed to generate new API token") continue } - apiToken.TokenValue = accessToken + apiToken.TokenValue = storedAPITokenValue _, err = database.DB.Exec(`UPDATE apitokens SET token_value=$1 WHERE id=$2`, apiToken.TokenValue, apiToken.ID) if err != nil { logging.LogError(err, "Failed to update apitoken with value") continue } - onStartMessage.APIToken = apiToken.TokenValue + onStartMessage.APIToken = plainAPITokenValue go updateAPITokenAfter5Minutes(apiToken.ID) err = RabbitMQConnection.SendContainerOnStart(onStartMessage) if err != nil { diff --git a/mythic-docker/src/rabbitmq/constants.go b/mythic-docker/src/rabbitmq/constants.go index 09cbc3ba..ec51021a 100644 --- a/mythic-docker/src/rabbitmq/constants.go +++ b/mythic-docker/src/rabbitmq/constants.go @@ -244,6 +244,8 @@ const ( MYTHIC_RPC_C2_UPDATE_STATUS = "mythic_rpc_c2_update_status" // MYTHIC_RPC_APITOKEN_CREATE = "mythic_rpc_apitoken_create" + // + MYTHIC_RPC_DIRECT_FILE_TOKEN_CREATE = "mythic_rpc_direct_file_token_create" // Agent Message MYTHIC_RPC_HANDLE_AGENT_JSON = "mythic_rpc_handle_agent_message_json" // CustomBrowser diff --git a/mythic-docker/src/rabbitmq/eventing.go b/mythic-docker/src/rabbitmq/eventing.go index 29848cd7..fde2cdbf 100644 --- a/mythic-docker/src/rabbitmq/eventing.go +++ b/mythic-docker/src/rabbitmq/eventing.go @@ -1047,19 +1047,18 @@ func startEventStepInstance(eventStepInstanceID int) error { logging.LogError(err, "Failed to get new apitoken") continue } - accessToken, _, _, err := mythicjwt.GenerateJWT(databaseStructs.Operator{ID: eventStepInstance.OperatorID}, - mythicjwt.AUTH_METHOD_EVENT, eventStepInstance.ID, apiToken.ID) + plainAPITokenValue, storedAPITokenValue, err := mythicjwt.GenerateOpaqueAPIToken() if err != nil { logging.LogError(err, "Failed to generate access token") continue } - apiToken.TokenValue = accessToken + apiToken.TokenValue = storedAPITokenValue _, err = database.DB.Exec(`UPDATE apitokens SET token_value=$1 WHERE id=$2`, apiToken.TokenValue, apiToken.ID) if err != nil { logging.LogError(err, "Failed to update apitoken") continue } - inputs[key] = apiToken.TokenValue + inputs[key] = plainAPITokenValue } } for i := 0; i < len(allEventSteps); i++ { diff --git a/mythic-docker/src/rabbitmq/recv_mythic_rpc_apitoken_create.go b/mythic-docker/src/rabbitmq/recv_mythic_rpc_apitoken_create.go index 5c37a396..45786775 100644 --- a/mythic-docker/src/rabbitmq/recv_mythic_rpc_apitoken_create.go +++ b/mythic-docker/src/rabbitmq/recv_mythic_rpc_apitoken_create.go @@ -42,7 +42,6 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage) MythicRPCAPIT TokenValue: "", Active: true, } - operatorData := databaseStructs.Operator{} if input.AgentTaskID != nil { task := databaseStructs.Task{AgentTaskID: *input.AgentTaskID} err := database.DB.Get(&task, `SELECT id, operator_id, operation_id, display_id FROM task WHERE agent_task_id=$1`, task.AgentTaskID) @@ -55,7 +54,6 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage) MythicRPCAPIT apiToken.CreatedBy = task.OperatorID apiToken.TaskID.Valid = true apiToken.TaskID.Int64 = int64(task.ID) - operatorData.ID = task.OperatorID apiToken.Name = fmt.Sprintf("Generated Task API Token via MythicRPC for Task %d", task.DisplayID) } else if input.PayloadUUID != nil { payload := databaseStructs.Payload{UuID: *input.PayloadUUID} @@ -67,7 +65,6 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage) MythicRPCAPIT apiToken.TokenType = mythicjwt.AUTH_METHOD_TASK apiToken.OperatorID = payload.OperatorID apiToken.CreatedBy = payload.OperatorID - operatorData.ID = payload.OperatorID apiToken.PayloadID.Valid = true apiToken.PayloadID.Int64 = int64(payload.ID) apiToken.Name = fmt.Sprintf("Generated Payload API Token via MythicRPC for Payload %s", payload.UuID) @@ -97,9 +94,6 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage) MythicRPCAPIT if !operatorOperationData[i].CurrentOperator.Deleted && operatorOperationData[i].CurrentOperator.Active { apiToken.OperatorID = operatorOperationData[i].CurrentOperator.ID apiToken.CreatedBy = operatorOperationData[i].CurrentOperator.ID - operatorData.ID = operatorOperationData[i].CurrentOperator.ID - operatorData.CurrentOperationID.Valid = true - operatorData.CurrentOperationID.Int64 = int64(callback.OperationID) apiToken.Name = fmt.Sprintf("Generated Callback API Token via MythicRPC for Callback %d", callback.DisplayID) } } @@ -129,12 +123,12 @@ func MythicRPCAPITokenCreate(input MythicRPCAPITokenCreateMessage) MythicRPCAPIT response.Error = err.Error() return response } - accessToken, _, _, err := mythicjwt.GenerateJWT(operatorData, apiToken.TokenType, 0, apiToken.ID) + accessToken, storedAPITokenValue, err := mythicjwt.GenerateOpaqueAPIToken() if err != nil { response.Error = err.Error() return response } - apiToken.TokenValue = accessToken + apiToken.TokenValue = storedAPITokenValue _, err = database.DB.Exec(`UPDATE apitokens SET token_value=$1 WHERE id=$2`, apiToken.TokenValue, apiToken.ID) if err != nil { response.Error = err.Error() diff --git a/mythic-docker/src/rabbitmq/recv_mythic_rpc_direct_file_token_create.go b/mythic-docker/src/rabbitmq/recv_mythic_rpc_direct_file_token_create.go new file mode 100644 index 00000000..6b5219a5 --- /dev/null +++ b/mythic-docker/src/rabbitmq/recv_mythic_rpc_direct_file_token_create.go @@ -0,0 +1,143 @@ +package rabbitmq + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/its-a-feature/Mythic/authentication/mythicjwt" + "github.com/its-a-feature/Mythic/database" + databaseStructs "github.com/its-a-feature/Mythic/database/structs" + "github.com/its-a-feature/Mythic/logging" + amqp "github.com/rabbitmq/amqp091-go" +) + +const directFileScopedTokenTTL = 30 * time.Second + +type MythicRPCDirectFileTokenCreateMessage struct { + AgentTaskID *string `json:"agent_task_id"` + AgentCallbackID *string `json:"agent_callback_id"` + PayloadUUID *string `json:"payload_uuid"` + FileUUID string `json:"file_uuid"` + Action string `json:"action"` // upload, download, both +} + +type MythicRPCDirectFileTokenCreateMessageResponse struct { + Success bool `json:"success"` + Error string `json:"error"` + Token string `json:"token"` +} + +func init() { + RabbitMQConnection.AddRPCQueue(RPCQueueStruct{ + Exchange: MYTHIC_EXCHANGE, + Queue: MYTHIC_RPC_DIRECT_FILE_TOKEN_CREATE, + RoutingKey: MYTHIC_RPC_DIRECT_FILE_TOKEN_CREATE, + Handler: processMythicRPCDirectFileTokenCreate, + }) +} + +func scopesForDirectFileAction(action string) ([]string, error) { + switch action { + case "upload": + return []string{mythicjwt.SCOPE_FILE_DIRECT_UPLOAD}, nil + case "download": + return []string{mythicjwt.SCOPE_FILE_DIRECT_DOWNLOAD}, nil + case "both", "": + return []string{mythicjwt.SCOPE_FILE_DIRECT_UPLOAD, mythicjwt.SCOPE_FILE_DIRECT_DOWNLOAD}, nil + default: + return nil, fmt.Errorf("invalid action %q (expected upload, download, or both)", action) + } +} + +func resolveScopedTokenOperatorAndOperation(input MythicRPCDirectFileTokenCreateMessage) (int, int, error) { + if input.AgentTaskID != nil { + task := databaseStructs.Task{} + if err := database.DB.Get(&task, `SELECT operator_id, operation_id FROM task WHERE agent_task_id=$1`, + *input.AgentTaskID); err != nil { + return 0, 0, err + } + return task.OperatorID, task.OperationID, nil + } + if input.PayloadUUID != nil { + payload := databaseStructs.Payload{} + if err := database.DB.Get(&payload, `SELECT operator_id, operation_id FROM payload WHERE uuid=$1`, + *input.PayloadUUID); err != nil { + return 0, 0, err + } + return payload.OperatorID, payload.OperationID, nil + } + if input.AgentCallbackID != nil { + callback := databaseStructs.Callback{} + if err := database.DB.Get(&callback, `SELECT operation_id FROM callback WHERE agent_callback_id=$1`, + *input.AgentCallbackID); err != nil { + return 0, 0, err + } + operatorOperationData := []databaseStructs.Operatoroperation{} + if err := database.DB.Select(&operatorOperationData, `SELECT + operator.account_type "operator.account_type", + operator.id "operator.id", + operator.deleted "operator.deleted", + operator.active "operator.active" + FROM operatoroperation + JOIN operator ON operatoroperation.operator_id = operator.id + WHERE operatoroperation.operation_id=$1`, callback.OperationID); err != nil { + return 0, 0, err + } + for i := range operatorOperationData { + if operatorOperationData[i].CurrentOperator.AccountType == databaseStructs.AccountTypeBot && + !operatorOperationData[i].CurrentOperator.Deleted && + operatorOperationData[i].CurrentOperator.Active { + return operatorOperationData[i].CurrentOperator.ID, callback.OperationID, nil + } + } + return 0, 0, fmt.Errorf("need an active non-deleted bot account assigned to operation %d", callback.OperationID) + } + return 0, 0, fmt.Errorf("no operator/task/callback/payload information provided") +} + +func MythicRPCDirectFileTokenCreate(input MythicRPCDirectFileTokenCreateMessage) MythicRPCDirectFileTokenCreateMessageResponse { + response := MythicRPCDirectFileTokenCreateMessageResponse{Success: false} + if input.FileUUID == "" { + response.Error = "file_uuid is required" + return response + } + scopes, err := scopesForDirectFileAction(input.Action) + if err != nil { + response.Error = err.Error() + return response + } + operatorID, operationID, err := resolveScopedTokenOperatorAndOperation(input) + if err != nil { + response.Error = err.Error() + return response + } + user := databaseStructs.Operator{ + ID: operatorID, + CurrentOperationID: sql.NullInt64{ + Valid: true, + Int64: int64(operationID), + }, + } + token, _, err := mythicjwt.GenerateScopedJWT(user, scopes, input.FileUUID, directFileScopedTokenTTL) + if err != nil { + response.Error = err.Error() + return response + } + response.Success = true + response.Token = token + return response +} + +func processMythicRPCDirectFileTokenCreate(msg amqp.Delivery) interface{} { + incomingMessage := MythicRPCDirectFileTokenCreateMessage{} + responseMsg := MythicRPCDirectFileTokenCreateMessageResponse{Success: false} + if err := json.Unmarshal(msg.Body, &incomingMessage); err != nil { + logging.LogError(err, "Failed to unmarshal JSON into struct") + responseMsg.Error = err.Error() + } else { + return MythicRPCDirectFileTokenCreate(incomingMessage) + } + return responseMsg +} diff --git a/mythic-docker/src/rabbitmq/recv_mythic_rpc_payload_create_from_uuid.go b/mythic-docker/src/rabbitmq/recv_mythic_rpc_payload_create_from_uuid.go index 6a4c6759..53dd4adf 100644 --- a/mythic-docker/src/rabbitmq/recv_mythic_rpc_payload_create_from_uuid.go +++ b/mythic-docker/src/rabbitmq/recv_mythic_rpc_payload_create_from_uuid.go @@ -576,7 +576,7 @@ func associateCommandsWithPayload(databasePayload databaseStructs.Payload, comma if utils.SliceContains(deniedCommandNames, dependency) { // uh oh, we included a command that depends on a rejected command return nil, errors.New(fmt.Sprintf("%s depends on %s, but %s isn't allowed with the currently selected parameters", - command.Cmd, dependency)) + command.Cmd, dependency, dependency)) } // the dependency we have isn't included, but isn't explicitly denied, so just add it finalCommandNames = append(finalCommandNames, dependency) diff --git a/mythic-docker/src/rabbitmq/util_agent_message.go b/mythic-docker/src/rabbitmq/util_agent_message.go index fb74950e..12ac2672 100644 --- a/mythic-docker/src/rabbitmq/util_agent_message.go +++ b/mythic-docker/src/rabbitmq/util_agent_message.go @@ -3,7 +3,7 @@ package rabbitmq import ( "database/sql" "encoding/base64" - "encoding/json/v2" + "encoding/json" "encoding/xml" "errors" "fmt" diff --git a/mythic-docker/src/webserver/controllers/user_generate_apitoken_webhook.go b/mythic-docker/src/webserver/controllers/user_generate_apitoken_webhook.go index eee7870a..0ae7a2c0 100644 --- a/mythic-docker/src/webserver/controllers/user_generate_apitoken_webhook.go +++ b/mythic-docker/src/webserver/controllers/user_generate_apitoken_webhook.go @@ -115,10 +115,14 @@ func GenerateAPITokenWebhook(c *gin.Context) { Name: defaultName, CreatedBy: createdBy, } + if authType == mythicjwt.AUTH_METHOD_EVENT { + apiToken.EventStepInstanceID.Valid = true + apiToken.EventStepInstanceID.Int64 = int64(claims.EventStepInstanceID) + } statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens - (token_value, operator_id, token_type, active, "name", created_by) + (token_value, operator_id, token_type, active, "name", created_by, eventstepinstance_id) VALUES - (:token_value, :operator_id, :token_type, :active, :name, :created_by) + (:token_value, :operator_id, :token_type, :active, :name, :created_by, :eventstepinstance_id) RETURNING id`) if err != nil { c.JSON(http.StatusOK, GenerateAPITokenResponse{ @@ -135,9 +139,7 @@ func GenerateAPITokenWebhook(c *gin.Context) { }) return } - access_token, _, _, err := mythicjwt.GenerateJWT(databaseStructs.Operator{ - ID: userID, - }, authType, claims.EventStepInstanceID, apiToken.ID) + accessToken, storedAPITokenValue, err := mythicjwt.GenerateOpaqueAPIToken() if err != nil { c.JSON(http.StatusOK, GenerateAPITokenResponse{ Status: "error", @@ -145,7 +147,7 @@ func GenerateAPITokenWebhook(c *gin.Context) { }) return } - apiToken.TokenValue = access_token + apiToken.TokenValue = storedAPITokenValue _, err = database.DB.Exec(`UPDATE apitokens SET token_value=$1 WHERE id=$2`, apiToken.TokenValue, apiToken.ID) if err != nil { c.JSON(http.StatusOK, GenerateAPITokenResponse{ @@ -156,7 +158,7 @@ func GenerateAPITokenWebhook(c *gin.Context) { } c.JSON(http.StatusOK, GenerateAPITokenResponse{ Status: "success", - TokenValue: access_token, + TokenValue: accessToken, OperatorID: userID, ID: apiToken.ID, Name: apiToken.Name, diff --git a/mythic-docker/src/webserver/initialize.go b/mythic-docker/src/webserver/initialize.go index 9677eb97..a37677cb 100644 --- a/mythic-docker/src/webserver/initialize.go +++ b/mythic-docker/src/webserver/initialize.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/its-a-feature/Mythic/authentication/mythicjwt" "github.com/its-a-feature/Mythic/database" databaseStructs "github.com/its-a-feature/Mythic/database/structs" "github.com/its-a-feature/Mythic/rabbitmq" @@ -107,7 +108,7 @@ func InitializeGinLogger() gin.HandlerFunc { source = "scripting" } graphQLOperationName := c.GetString("GraphQLName") - if tokenStruct, ok := c.Get("apitoken_logging_struct"); ok { + if tokenStruct, ok := c.Get(authentication.ContextKeyAPIToken); ok { if param.Path == "/graphql/webhook" { if graphQLOperationName == "" { graphQLOperationName = " with a subscription" @@ -130,8 +131,8 @@ func InitializeGinLogger() gin.HandlerFunc { "latency", param.Latency.String(), "responseSize", param.BodySize, "source", source, - "user_id", c.GetInt("user_id"), - "username", c.GetString("username"), + "user_id", c.GetInt(authentication.ContextKeyUserID), + "username", c.GetString(authentication.ContextKeyUsername), "file_id", c.GetString("file_id"), "graphql_name", c.GetString("GraphQLName"), "error", param.ErrorMessage) @@ -142,15 +143,12 @@ func InitializeGinLogger() gin.HandlerFunc { func setRoutes(r *gin.Engine) { // the agent message route is allowed to come from anywhere since it'll come through random c2 - r.POST("/api/v1.4/agent_message", webcontroller.AgentMessageWebhook) - r.GET("/api/v1.4/agent_message", webcontroller.AgentMessageGetWebhook) r.POST("/agent_message", webcontroller.AgentMessageWebhook) r.GET("/agent_message", webcontroller.AgentMessageGetWebhook) // healthcheck endpoint r.Use(authentication.IPBlockMiddleware()) { r.GET("/health", webcontroller.HealthCheckSimple) - r.GET("/healthDetailed", webcontroller.HealthCheckDetailed) // login page r.POST("/auth", webcontroller.Login) r.POST("/invite", webcontroller.UseInviteLink) @@ -163,43 +161,48 @@ func setRoutes(r *gin.Engine) { r.GET("/auth_redirect/:containerName/:IDPName", webcontroller.GetAuthContainerRedirect) r.GET("/auth_acs/:containerName/:IDPName", webcontroller.ProcessIDPResponse) r.POST("/auth_acs/:containerName/:IDPName", webcontroller.ProcessIDPResponse) - // unauthenticated file download based on file UUID - // this is for payload hosting and payload containers to fetch files via web - r.GET("/direct/download/:file_uuid", webcontroller.FileDirectDownloadWebhook) - // unauthenticated file upload based on file UUID - // this is for payload containers to upload files that are too big for rabbitmq - r.POST("/direct/upload/:file_uuid", webcontroller.FileDirectUploadWebhook) - + // create a protected group that requires valid auth with a JWT to access protected := r.Group("/") protected.Use(authentication.JwtAuthMiddleware()) { // EVERYBODY that can authenticate can do the following + r.GET("/healthDetailed", webcontroller.HealthCheckDetailed) // hasura's graphql endpoint to get updated claims for access control //protected.GET("/graphql/webhook", webcontroller.GetHasuraClaims) protected.POST("/graphql/webhook", webcontroller.GetHasuraClaims) // user - protected.GET("/me", webcontroller.GetMe) // controller.login - protected.POST("/api/v1.4/me_webhook", webcontroller.GetMeWebhook) // controller.login - protected.POST("/api/v1.4/generate_apitoken_webhook", webcontroller.GenerateAPITokenWebhook) - protected.POST("/api/v1.4/delete_apitoken_webhook", webcontroller.DeleteAPITokenWebhook) - protected.POST("/api/v1.4/update_current_operation_webhook", webcontroller.UpdateCurrentOperationWebhook) - protected.POST("/api/v1.4/update_operator_password_webhook", webcontroller.UpdateOperatorPasswordWebhook) - protected.POST("/api/v1.4/create_operator", webcontroller.CreateOperatorWebhook) - protected.POST("/api/v1.4/operator_get_secrets_webhook", webcontroller.GetSecretsWebhook) - protected.POST("/api/v1.4/operator_update_secrets_webhook", webcontroller.UpdateSecretsWebhook) - protected.POST("/api/v1.4/operator_get_preferences_webhook", webcontroller.GetPreferencesWebhook) - protected.POST("/api/v1.4/operator_update_preferences_webhook", webcontroller.UpdatePreferencesWebhook) + protected.GET("/me", webcontroller.GetMe) // controller.login + protected.POST("/me_webhook", webcontroller.GetMeWebhook) // controller.login + protected.POST("/generate_apitoken_webhook", webcontroller.GenerateAPITokenWebhook) + protected.POST("/delete_apitoken_webhook", webcontroller.DeleteAPITokenWebhook) + protected.POST("/update_current_operation_webhook", webcontroller.UpdateCurrentOperationWebhook) + protected.POST("/update_operator_password_webhook", webcontroller.UpdateOperatorPasswordWebhook) + protected.POST("/create_operator", webcontroller.CreateOperatorWebhook) + protected.POST("/operator_get_secrets_webhook", webcontroller.GetSecretsWebhook) + protected.POST("/operator_update_secrets_webhook", webcontroller.UpdateSecretsWebhook) + protected.POST("/operator_get_preferences_webhook", webcontroller.GetPreferencesWebhook) + protected.POST("/operator_update_preferences_webhook", webcontroller.UpdatePreferencesWebhook) // operation - protected.POST("/api/v1.4/create_operation_webhook", webcontroller.CreateOperationWebhook) + protected.POST("/create_operation_webhook", webcontroller.CreateOperationWebhook) // global config - protected.POST("/api/v1.4/get_global_settings_webhook", webcontroller.GetGlobalSettingWebhook) + protected.POST("/get_global_settings_webhook", webcontroller.GetGlobalSettingWebhook) // a refresh post will contain the access_token and refresh_token protected.POST("/refresh", webcontroller.RefreshJWT) protected.Static("/static", "./static") - protected.GET("direct/view/:file_uuid", webcontroller.FileDirectViewWebhook) + protected.GET("/direct/view/:file_uuid", + authentication.DirectFileScopeMiddleware(mythicjwt.SCOPE_FILE_DIRECT_DOWNLOAD), + webcontroller.FileDirectViewWebhook) + // authenticated direct file routes. + // Supports normal JWT/APIToken auth and scoped short-lived direct-file tokens. + r.GET("/direct/download/:file_uuid", + authentication.DirectFileScopeMiddleware(mythicjwt.SCOPE_FILE_DIRECT_DOWNLOAD), + webcontroller.FileDirectDownloadWebhook) + r.POST("/direct/upload/:file_uuid", + authentication.DirectFileScopeMiddleware(mythicjwt.SCOPE_FILE_DIRECT_UPLOAD), + webcontroller.FileDirectUploadWebhook) // following require you to have an operation set - allOperationMembers := protected.Group("/api/v1.4/") + allOperationMembers := protected.Group("/") allOperationMembers.Use(authentication.RBACMiddlewareAll()) { // generic all installed services @@ -216,9 +219,9 @@ func setRoutes(r *gin.Engine) { // file allOperationMembers.POST("download_bulk_webhook", webcontroller.DownloadBulkFilesWebhook) allOperationMembers.POST("preview_file_webhook", webcontroller.PreviewFileWebhook) - allOperationMembers.GET("files/screencaptures/:file_uuid", webcontroller.DownloadFileAuthWebhook) + allOperationMembers.GET("screencaptures/:file_uuid", webcontroller.DownloadFileAuthWebhook) } - noSpectators := protected.Group("/api/v1.4/") + noSpectators := protected.Group("/") noSpectators.Use(authentication.RBACMiddlewareNoSpectators()) { // everybody EXCEPT SPECTATORS can do these actions @@ -302,7 +305,7 @@ func setRoutes(r *gin.Engine) { // custom browsers noSpectators.POST("custombrowser_export_function_webhook", webcontroller.CustomBrowserExportFunctionWebhook) } - operationAdminsOnly := protected.Group("/api/v1.4/") + operationAdminsOnly := protected.Group("/") operationAdminsOnly.Use(authentication.RBACMiddlewareOperationAdmin()) { // Only OPERATION_ADMIN and MYTHIC_ADMIN can do these routes