steam 2fa for polling and security
This commit is contained in:
64
local/utl/audit/audit.go
Normal file
64
local/utl/audit/audit.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
ActionLogin AuditAction = "LOGIN"
|
||||
ActionLogout AuditAction = "LOGOUT"
|
||||
ActionServerCreate AuditAction = "SERVER_CREATE"
|
||||
ActionServerUpdate AuditAction = "SERVER_UPDATE"
|
||||
ActionServerDelete AuditAction = "SERVER_DELETE"
|
||||
ActionServerStart AuditAction = "SERVER_START"
|
||||
ActionServerStop AuditAction = "SERVER_STOP"
|
||||
ActionUserCreate AuditAction = "USER_CREATE"
|
||||
ActionUserUpdate AuditAction = "USER_UPDATE"
|
||||
ActionUserDelete AuditAction = "USER_DELETE"
|
||||
ActionConfigUpdate AuditAction = "CONFIG_UPDATE"
|
||||
ActionSteamAuth AuditAction = "STEAM_AUTH"
|
||||
ActionPermissionGrant AuditAction = "PERMISSION_GRANT"
|
||||
ActionPermissionRevoke AuditAction = "PERMISSION_REVOKE"
|
||||
)
|
||||
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Action AuditAction `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Details string `json:"details"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func LogAction(ctx context.Context, userID, username string, action AuditAction, resource, details, ipAddress, userAgent string, success bool) {
|
||||
logging.InfoWithContext("AUDIT", "User %s (%s) performed %s on %s from %s - Success: %t - Details: %s",
|
||||
username, userID, action, resource, ipAddress, success, details)
|
||||
}
|
||||
|
||||
func LogAuthAction(ctx context.Context, username, ipAddress, userAgent string, success bool, details string) {
|
||||
action := ActionLogin
|
||||
if !success {
|
||||
details = "Failed: " + details
|
||||
}
|
||||
|
||||
LogAction(ctx, "", username, action, "authentication", details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogServerAction(ctx context.Context, userID, username string, action AuditAction, serverID, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, userID, username, action, "server:"+serverID, details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogUserManagementAction(ctx context.Context, adminUserID, adminUsername string, action AuditAction, targetUserID, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, adminUserID, adminUsername, action, "user:"+targetUserID, details, ipAddress, userAgent, success)
|
||||
}
|
||||
|
||||
func LogConfigAction(ctx context.Context, userID, username string, configType, ipAddress, userAgent string, success bool, details string) {
|
||||
LogAction(ctx, userID, username, ActionConfigUpdate, "config:"+configType, details, ipAddress, userAgent, success)
|
||||
}
|
||||
179
local/utl/command/interactive_executor.go
Normal file
179
local/utl/command/interactive_executor.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InteractiveCommandExecutor extends CommandExecutor to handle interactive commands
|
||||
type InteractiveCommandExecutor struct {
|
||||
*CommandExecutor
|
||||
tfaManager *model.Steam2FAManager
|
||||
}
|
||||
|
||||
func NewInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager) *InteractiveCommandExecutor {
|
||||
return &InteractiveCommandExecutor{
|
||||
CommandExecutor: baseExecutor,
|
||||
tfaManager: tfaManager,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteInteractive runs a command that may require 2FA input
|
||||
func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, e.ExePath, args...)
|
||||
|
||||
if e.WorkDir != "" {
|
||||
cmd.Dir = e.WorkDir
|
||||
}
|
||||
|
||||
// Create pipes for stdin, stdout, and stderr
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||
}
|
||||
defer stdin.Close()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||
}
|
||||
defer stdout.Close()
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
defer stderr.Close()
|
||||
|
||||
logging.Info("Executing interactive command: %s %s", e.ExePath, strings.Join(args, " "))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start command: %v", err)
|
||||
}
|
||||
|
||||
// Create channels for output monitoring
|
||||
outputDone := make(chan error)
|
||||
|
||||
// Monitor stdout and stderr for 2FA prompts
|
||||
go e.monitorOutput(ctx, stdout, stderr, serverID, outputDone)
|
||||
|
||||
// Wait for either the command to finish or output monitoring to complete
|
||||
cmdErr := cmd.Wait()
|
||||
outputErr := <-outputDone
|
||||
|
||||
if outputErr != nil {
|
||||
logging.Warn("Output monitoring error: %v", outputErr)
|
||||
}
|
||||
|
||||
return cmdErr
|
||||
}
|
||||
|
||||
func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
|
||||
defer close(done)
|
||||
|
||||
// Create scanners for both outputs
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
|
||||
outputChan := make(chan string)
|
||||
|
||||
// Read from stdout
|
||||
go func() {
|
||||
for stdoutScanner.Scan() {
|
||||
line := stdoutScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDOUT: %s", line)
|
||||
}
|
||||
outputChan <- line
|
||||
}
|
||||
}()
|
||||
|
||||
// Read from stderr
|
||||
go func() {
|
||||
for stderrScanner.Scan() {
|
||||
line := stderrScanner.Text()
|
||||
if e.LogOutput {
|
||||
logging.Info("STDERR: %s", line)
|
||||
}
|
||||
outputChan <- line
|
||||
}
|
||||
}()
|
||||
|
||||
// Monitor for 2FA prompts
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
return
|
||||
case line, ok := <-outputChan:
|
||||
if !ok {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this line indicates a 2FA prompt
|
||||
if e.is2FAPrompt(line) {
|
||||
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
|
||||
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
|
||||
// Common SteamCMD 2FA prompts
|
||||
twoFAKeywords := []string{
|
||||
"please enter your steam guard code",
|
||||
"steam guard",
|
||||
"two-factor",
|
||||
"authentication code",
|
||||
"please check your steam mobile app",
|
||||
"confirm in application",
|
||||
}
|
||||
|
||||
lowerLine := strings.ToLower(line)
|
||||
for _, keyword := range twoFAKeywords {
|
||||
if strings.Contains(lowerLine, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error {
|
||||
logging.Info("2FA prompt detected: %s", promptLine)
|
||||
|
||||
// Create a 2FA request
|
||||
request := e.tfaManager.CreateRequest(promptLine, serverID)
|
||||
logging.Info("Created 2FA request with ID: %s", request.ID)
|
||||
|
||||
// Wait for user to complete the 2FA process
|
||||
// Use a reasonable timeout (e.g., 5 minutes)
|
||||
timeout := 5 * time.Minute
|
||||
success, err := e.tfaManager.WaitForCompletion(request.ID, timeout)
|
||||
|
||||
if err != nil {
|
||||
logging.Error("2FA completion failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !success {
|
||||
logging.Error("2FA was not completed successfully")
|
||||
return fmt.Errorf("2FA authentication failed")
|
||||
}
|
||||
|
||||
logging.Info("2FA completed successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type RouteGroups struct {
|
||||
StateHistory fiber.Router
|
||||
Membership fiber.Router
|
||||
System fiber.Router
|
||||
Steam2FA fiber.Router
|
||||
}
|
||||
|
||||
func CheckError(err error) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "0.10.3"
|
||||
Version = "0.10.5"
|
||||
Prefix = "v1"
|
||||
Secret string
|
||||
SecretCode string
|
||||
|
||||
67
local/utl/errors/safe_error.go
Normal file
67
local/utl/errors/safe_error.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type SafeError struct {
|
||||
Message string
|
||||
Code int
|
||||
Fatal bool
|
||||
}
|
||||
|
||||
func (e *SafeError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func NewSafeError(message string, code int) *SafeError {
|
||||
return &SafeError{
|
||||
Message: message,
|
||||
Code: code,
|
||||
Fatal: false,
|
||||
}
|
||||
}
|
||||
|
||||
func NewFatalError(message string, code int) *SafeError {
|
||||
return &SafeError{
|
||||
Message: message,
|
||||
Code: code,
|
||||
Fatal: true,
|
||||
}
|
||||
}
|
||||
|
||||
func HandleError(err error, context string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if safeErr, ok := err.(*SafeError); ok {
|
||||
if safeErr.Fatal {
|
||||
logging.Error("Fatal error in %s: %s", context, safeErr.Message)
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
logging.Error("Application shutting down due to fatal error")
|
||||
os.Exit(safeErr.Code)
|
||||
} else {
|
||||
logging.Warn("Fatal error occurred but not exiting in non-production environment")
|
||||
}
|
||||
} else {
|
||||
logging.Error("Error in %s: %s", context, safeErr.Message)
|
||||
}
|
||||
} else {
|
||||
logging.Error("Unexpected error in %s: %v", context, err)
|
||||
}
|
||||
}
|
||||
|
||||
func SafeFatal(message string, args ...interface{}) {
|
||||
formattedMessage := fmt.Sprintf(message, args...)
|
||||
err := NewFatalError(formattedMessage, 1)
|
||||
HandleError(err, "application")
|
||||
}
|
||||
|
||||
func SafeLog(message string, args ...interface{}) {
|
||||
formattedMessage := fmt.Sprintf(message, args...)
|
||||
err := NewSafeError(formattedMessage, 0)
|
||||
HandleError(err, "application")
|
||||
}
|
||||
91
local/utl/graceful/shutdown.go
Normal file
91
local/utl/graceful/shutdown.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package graceful
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ShutdownManager struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
handlers []func() error
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
var globalManager *ShutdownManager
|
||||
var once sync.Once
|
||||
|
||||
func GetManager() *ShutdownManager {
|
||||
once.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
globalManager = &ShutdownManager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
handlers: make([]func() error, 0),
|
||||
}
|
||||
|
||||
go globalManager.watchSignals()
|
||||
})
|
||||
return globalManager
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) watchSignals() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-sigChan
|
||||
sm.Shutdown(30 * time.Second)
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) AddHandler(handler func() error) {
|
||||
sm.mutex.Lock()
|
||||
defer sm.mutex.Unlock()
|
||||
sm.handlers = append(sm.handlers, handler)
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) Context() context.Context {
|
||||
return sm.ctx
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) AddGoroutine() {
|
||||
sm.wg.Add(1)
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) GoroutineDone() {
|
||||
sm.wg.Done()
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) RunGoroutine(fn func(ctx context.Context)) {
|
||||
sm.wg.Add(1)
|
||||
go func() {
|
||||
defer sm.wg.Done()
|
||||
fn(sm.ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (sm *ShutdownManager) Shutdown(timeout time.Duration) {
|
||||
sm.cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
sm.wg.Wait()
|
||||
|
||||
sm.mutex.Lock()
|
||||
for _, handler := range sm.handlers {
|
||||
handler()
|
||||
}
|
||||
sm.mutex.Unlock()
|
||||
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
@@ -2,57 +2,73 @@ package jwt
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/errors"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
goerrors "errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// SecretKey holds the JWT signing key loaded from environment
|
||||
var SecretKey []byte
|
||||
|
||||
// Claims represents the JWT claims.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// init initializes the JWT secret key from environment variable
|
||||
func Init() {
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("JWT_SECRET environment variable is required and cannot be empty")
|
||||
type JWTHandler struct {
|
||||
SecretKey []byte
|
||||
}
|
||||
|
||||
type OpenJWTHandler struct {
|
||||
*JWTHandler
|
||||
}
|
||||
|
||||
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
|
||||
func NewOpenJWTHandler(jwtSecret string) *OpenJWTHandler {
|
||||
jwtHandler := NewJWTHandler(jwtSecret)
|
||||
return &OpenJWTHandler{
|
||||
JWTHandler: jwtHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
|
||||
func NewJWTHandler(jwtSecret string) *JWTHandler {
|
||||
if jwtSecret == "" {
|
||||
errors.SafeFatal("JWT_SECRET environment variable is required and cannot be empty")
|
||||
}
|
||||
|
||||
var secretKey []byte
|
||||
|
||||
// Decode base64 secret if it looks like base64, otherwise use as-is
|
||||
if decoded, err := base64.StdEncoding.DecodeString(jwtSecret); err == nil && len(decoded) >= 32 {
|
||||
SecretKey = decoded
|
||||
secretKey = decoded
|
||||
} else {
|
||||
SecretKey = []byte(jwtSecret)
|
||||
secretKey = []byte(jwtSecret)
|
||||
}
|
||||
|
||||
// Ensure minimum key length for security
|
||||
if len(SecretKey) < 32 {
|
||||
log.Fatal("JWT_SECRET must be at least 32 bytes long for security")
|
||||
if len(secretKey) < 32 {
|
||||
errors.SafeFatal("JWT_SECRET must be at least 32 bytes long for security")
|
||||
}
|
||||
return &JWTHandler{
|
||||
SecretKey: secretKey,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSecretKey generates a cryptographically secure random key for JWT signing
|
||||
// This is a utility function for generating new secrets, not used in normal operation
|
||||
func GenerateSecretKey() string {
|
||||
func (jh *JWTHandler) GenerateSecretKey() string {
|
||||
key := make([]byte, 64) // 512 bits
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatal("Failed to generate random key: ", err)
|
||||
errors.SafeFatal("Failed to generate random key: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
// GenerateToken generates a new JWT for a given user.
|
||||
func GenerateToken(user *model.User) (string, error) {
|
||||
func (jh *JWTHandler) GenerateToken(user *model.User) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID.String(),
|
||||
@@ -62,10 +78,10 @@ func GenerateToken(user *model.User) (string, error) {
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(SecretKey)
|
||||
return token.SignedString(jh.SecretKey)
|
||||
}
|
||||
|
||||
func GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
|
||||
func (jh *JWTHandler) GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
|
||||
expirationTime := expiry
|
||||
claims := &Claims{
|
||||
UserID: user.ID.String(),
|
||||
@@ -75,15 +91,15 @@ func GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(SecretKey)
|
||||
return token.SignedString(jh.SecretKey)
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT and returns the claims if the token is valid.
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
func (jh *JWTHandler) ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return SecretKey, nil
|
||||
return jh.SecretKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -91,7 +107,7 @@ func ValidateToken(tokenString string) (*Claims, error) {
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
return nil, goerrors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
76
local/utl/security/download_verifier.go
Normal file
76
local/utl/security/download_verifier.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DownloadVerifier struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDownloadVerifier() *DownloadVerifier {
|
||||
return &DownloadVerifier{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (dv *DownloadVerifier) VerifyAndDownload(url, outputPath, expectedSHA256 string) error {
|
||||
if url == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("output path cannot be empty")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "ACC-Server-Manager/1.0")
|
||||
|
||||
resp, err := dv.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
writer := io.MultiWriter(file, hash)
|
||||
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
if expectedSHA256 != "" {
|
||||
actualHash := fmt.Sprintf("%x", hash.Sum(nil))
|
||||
if actualHash != expectedSHA256 {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("file hash mismatch: expected %s, got %s", expectedSHA256, actualHash)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
95
local/utl/security/path_validator.go
Normal file
95
local/utl/security/path_validator.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PathValidator struct {
|
||||
allowedBasePaths []string
|
||||
blockedPatterns []*regexp.Regexp
|
||||
}
|
||||
|
||||
func NewPathValidator() *PathValidator {
|
||||
blockedPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`\.\.`),
|
||||
regexp.MustCompile(`[<>:"|?*]`),
|
||||
regexp.MustCompile(`^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$`),
|
||||
regexp.MustCompile(`\x00`),
|
||||
regexp.MustCompile(`^\\\\`),
|
||||
regexp.MustCompile(`^[a-zA-Z]:\\Windows`),
|
||||
regexp.MustCompile(`^[a-zA-Z]:\\Program Files`),
|
||||
}
|
||||
|
||||
return &PathValidator{
|
||||
allowedBasePaths: []string{
|
||||
`C:\ACC-Servers`,
|
||||
`D:\ACC-Servers`,
|
||||
`E:\ACC-Servers`,
|
||||
`C:\SteamCMD`,
|
||||
`D:\SteamCMD`,
|
||||
`E:\SteamCMD`,
|
||||
},
|
||||
blockedPatterns: blockedPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
func (pv *PathValidator) ValidateInstallPath(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("path cannot be empty")
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
for _, pattern := range pv.blockedPatterns {
|
||||
if pattern.MatchString(absPath) || pattern.MatchString(strings.ToUpper(filepath.Base(absPath))) {
|
||||
return fmt.Errorf("path contains forbidden patterns")
|
||||
}
|
||||
}
|
||||
|
||||
allowed := false
|
||||
for _, basePath := range pv.allowedBasePaths {
|
||||
if strings.HasPrefix(strings.ToLower(absPath), strings.ToLower(basePath)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return fmt.Errorf("path must be within allowed directories: %v", pv.allowedBasePaths)
|
||||
}
|
||||
|
||||
if len(absPath) > 260 {
|
||||
return fmt.Errorf("path too long (max 260 characters)")
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(absPath)
|
||||
if parentInfo, err := os.Stat(parentDir); err == nil {
|
||||
if !parentInfo.IsDir() {
|
||||
return fmt.Errorf("parent path is not a directory")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pv *PathValidator) AddAllowedBasePath(path string) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base path: %v", err)
|
||||
}
|
||||
|
||||
pv.allowedBasePaths = append(pv.allowedBasePaths, absPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pv *PathValidator) GetAllowedBasePaths() []string {
|
||||
return append([]string(nil), pv.allowedBasePaths...)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func Start(di *dig.Container) *fiber.App {
|
||||
app.Use(securityMW.SecurityHeaders())
|
||||
app.Use(securityMW.LogSecurityEvents())
|
||||
app.Use(securityMW.TimeoutMiddleware(30 * time.Second))
|
||||
app.Use(securityMW.RequestContextTimeout(60 * time.Second))
|
||||
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
|
||||
app.Use(securityMW.ValidateUserAgent())
|
||||
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data"))
|
||||
|
||||
Reference in New Issue
Block a user