diff --git a/backend/handlers/handle_auth.go b/backend/handlers/handle_auth.go index ccf72cb..d43974b 100644 --- a/backend/handlers/handle_auth.go +++ b/backend/handlers/handle_auth.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "net/http" "adam-french.co.uk/backend/models" @@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) { claims, err := store.Auth.VerifyJWT(refreshToken) if err != nil { ctx.JSON(http.StatusUnauthorized, err.Error()) + return } - fmt.Printf("claims: %v\n", claims) - userIDF, ok := (*claims)["id"].(float64) if !ok { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"}) @@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) { return } + ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie( "access_token", tokens.AccessToken, @@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) { user := models.User{} if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { - ctx.JSON(http.StatusNotFound, err.Error()) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { - ctx.JSON(http.StatusUnauthorized, err.Error()) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) return } @@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) { return } + ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie( "access_token", tokens.AccessToken, @@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) { } func removeCookies(ctx *gin.Context) { + ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie( "access_token", "", diff --git a/backend/handlers/handle_message_upload.go b/backend/handlers/handle_message_upload.go index 149b348..8b05648 100644 --- a/backend/handlers/handle_message_upload.go +++ b/backend/handlers/handle_message_upload.go @@ -17,6 +17,12 @@ var allowedExtensions = map[string]bool{ ".pdf": true, ".txt": true, } +var extensionToMIMEPrefix = map[string]string{ + ".jpg": "image/", ".jpeg": "image/", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", + ".mp4": "video/", ".webm": "video/webm", ".mp3": "audio/", ".ogg": "audio/", + ".pdf": "application/pdf", ".txt": "text/", +} + func (store *Store) UploadMessageFile(ctx *gin.Context) { file, err := ctx.FormFile("file") if err != nil { @@ -36,6 +42,23 @@ func (store *Store) UploadMessageFile(ctx *gin.Context) { return } + // Validate actual content type matches extension + f, err := file.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) + return + } + buf := make([]byte, 512) + n, _ := f.Read(buf) + f.Close() + detectedType := http.DetectContentType(buf[:n]) + + expectedPrefix, ok := extensionToMIMEPrefix[ext] + if ok && !strings.HasPrefix(detectedType, expectedPrefix) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match extension"}) + return + } + b := make([]byte, 16) if _, err := rand.Read(b); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) diff --git a/backend/handlers/handle_user.go b/backend/handlers/handle_user.go index a92d3af..6293c9a 100644 --- a/backend/handlers/handle_user.go +++ b/backend/handlers/handle_user.go @@ -15,6 +15,21 @@ type UserCredentials struct { } func (store *Store) CreateUser(ctx *gin.Context) { + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + if !(*claims)["admin"].(bool) { + ctx.JSON(http.StatusForbidden, gin.H{"error": "admin access required"}) + return + } + var input UserCredentials if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, err.Error()) @@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) { tx := store.DB.Create(&user) if tx.Error != nil { ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) - } - - // Generate JWT token - tokens, err := store.Auth.GenerateJWT(&user) - if err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) return } - ctx.SetCookie( - "access_token", - tokens.AccessToken, - int(store.Auth.Config.AccessTokenLifetime.Seconds()), - store.Auth.Config.Endpoint, - store.Auth.Config.Domain, - true, true, - ) - ctx.SetCookie( - "refresh_token", - tokens.RefreshToken, - int(store.Auth.Config.RefreshTokenLifetime.Seconds()), - store.Auth.Config.Endpoint, - store.Auth.Config.Domain, - true, true, - ) - ctx.JSON(http.StatusOK, user) } @@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) { return } + ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie( "access_token", "", diff --git a/backend/main.go b/backend/main.go index 31a2bed..56dfaac 100644 --- a/backend/main.go +++ b/backend/main.go @@ -41,7 +41,8 @@ func main() { if os.Getenv("SEED_DB") == "true" { services.SeedDatabase(db) } - services.InitWebSocket(db) + domainName := os.Getenv("DOMAIN") + services.InitWebSocket(db, domainName) // SPOTIFY spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE") @@ -57,7 +58,6 @@ func main() { claudeClient := services.InitClaude(&claudeConfig) authSecret := os.Getenv("BACKEND_SECRET") - domainName := os.Getenv("DOMAIN") backendEndpoint := os.Getenv("BACKEND_ENDPOINT") accessTokenLifetime := 24 * time.Hour refreshTokenLifetime := 365 * 24 * time.Hour @@ -96,7 +96,7 @@ func main() { protected.PUT("/user/:id", store.UpdateUser) protected.DELETE("/user/:id", store.DeleteUser) r.GET("/user", store.GetUsers) - r.POST("/user", store.CreateUser) + protected.POST("/user", store.CreateUser) // AUTH r.POST("/auth/login", store.Login) @@ -112,7 +112,7 @@ func main() { // MESSAGES r.GET("/ws", store.ConnectWebSocket) - r.POST("/messages/upload", store.UploadMessageFile) + protected.POST("/messages/upload", store.UploadMessageFile) // NOTES r.GET("/notes/*path", store.GetNoteFile) diff --git a/backend/services/websocket.go b/backend/services/websocket.go index f579702..35b4322 100644 --- a/backend/services/websocket.go +++ b/backend/services/websocket.go @@ -2,7 +2,9 @@ package services import ( "net/http" + "strings" "sync" + "time" "adam-french.co.uk/backend/models" "gorm.io/gorm" @@ -12,11 +14,19 @@ import ( const maxMessages = 50 +var allowedDomain string + var Upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { - return true + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + origin = strings.TrimPrefix(origin, "https://") + origin = strings.TrimPrefix(origin, "http://") + return origin == allowedDomain || origin == "www."+allowedDomain }, } @@ -27,8 +37,14 @@ var ( nextAuthorID uint ) -func InitWebSocket(database *gorm.DB) { +const ( + rateLimitWindow = time.Second + rateLimitMaxMsgs = 10 +) + +func InitWebSocket(database *gorm.DB, domain string) { wsDB = database + allowedDomain = domain } func HandleWebSocket(conn *websocket.Conn) { @@ -50,12 +66,25 @@ func HandleWebSocket(conn *websocket.Conn) { } mu.Unlock() + msgCount := 0 + windowStart := time.Now() + for { var incoming models.Message if err := conn.ReadJSON(&incoming); err != nil { break } + now := time.Now() + if now.Sub(windowStart) > rateLimitWindow { + msgCount = 0 + windowStart = now + } + msgCount++ + if msgCount > rateLimitMaxMsgs { + continue + } + incoming.AuthorID = authorID mu.Lock() diff --git a/docker-compose.yml b/docker-compose.yml index 4ff8480..d6d63ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,8 +69,6 @@ services: - app-network volumes: - dbdata:/var/lib/postgresql/data - ports: - - 5432:5432 icecast2: build: @@ -82,8 +80,6 @@ services: - app-network env_file: - ./.env - ports: - - "${ICECAST_PORT}:${ICECAST_PORT}" gitea-runner: image: gitea/act_runner:latest @@ -99,7 +95,7 @@ services: volumes: - ./gitea-runner/config.yaml:/config.yaml - ./gitea-runner/data:/data - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk. restart: unless-stopped networks: - app-network @@ -124,7 +120,6 @@ services: - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro ports: - - "3000:3000" - "2222:2222" depends_on: - db diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index a0b6b6d..4d3dd72 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -11,6 +11,10 @@ http { client_max_body_size 10M; + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m; + log_format compact '$remote_addr "$request" $status rt=$request_time'; @@ -98,7 +102,28 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location $BACKEND_ENDPOINT/auth/login { + limit_req zone=login burst=3 nodelay; + rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; + proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location $BACKEND_ENDPOINT/messages/upload { + limit_req zone=upload burst=3 nodelay; + rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; + proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location $BACKEND_ENDPOINT/ { + limit_req zone=api burst=20 nodelay; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_set_header Host $host; diff --git a/nginx/nginx_dev.conf.template b/nginx/nginx_dev.conf.template index 9049232..a9cbcef 100644 --- a/nginx/nginx_dev.conf.template +++ b/nginx/nginx_dev.conf.template @@ -11,6 +11,10 @@ http { client_max_body_size 10M; + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m; + log_format compact '$remote_addr "$request" $status rt=$request_time'; @@ -64,7 +68,28 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location $BACKEND_ENDPOINT/auth/login { + limit_req zone=login burst=3 nodelay; + rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; + proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location $BACKEND_ENDPOINT/messages/upload { + limit_req zone=upload burst=3 nodelay; + rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; + proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location $BACKEND_ENDPOINT/ { + limit_req zone=api burst=20 nodelay; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_set_header Host $host; diff --git a/nginx/vue/src/components/util/Chat.vue b/nginx/vue/src/components/util/Chat.vue index 50ecc34..e8732df 100644 --- a/nginx/vue/src/components/util/Chat.vue +++ b/nginx/vue/src/components/util/Chat.vue @@ -39,6 +39,10 @@ function isImageUrl(url) { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); } +function isSafeFileUrl(url) { + return typeof url === "string" && url.startsWith("/uploads/"); +} + onMounted(() => { messagesStore.connect(); }); @@ -57,7 +61,7 @@ onUnmounted(() => {

{{ message.authorId }}: {{ message.text }} -