Get AI to fix vunerabilities in site
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

This commit is contained in:
2026-03-09 14:12:29 +00:00
parent 85a2325683
commit 8e50537333
9 changed files with 136 additions and 41 deletions

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(refreshToken) claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil { if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error()) ctx.JSON(http.StatusUnauthorized, err.Error())
return
} }
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) {
user := models.User{} user := models.User{}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { 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 return
} }
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { 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 return
} }
@@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) {
} }
func removeCookies(ctx *gin.Context) { func removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",

View File

@@ -17,6 +17,12 @@ var allowedExtensions = map[string]bool{
".pdf": true, ".txt": true, ".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) { func (store *Store) UploadMessageFile(ctx *gin.Context) {
file, err := ctx.FormFile("file") file, err := ctx.FormFile("file")
if err != nil { if err != nil {
@@ -36,6 +42,23 @@ func (store *Store) UploadMessageFile(ctx *gin.Context) {
return 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) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})

View File

@@ -15,6 +15,21 @@ type UserCredentials struct {
} }
func (store *Store) CreateUser(ctx *gin.Context) { 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 var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, err.Error())
@@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
tx := store.DB.Create(&user) tx := store.DB.Create(&user)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) 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 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) ctx.JSON(http.StatusOK, user)
} }
@@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",

View File

@@ -41,7 +41,8 @@ func main() {
if os.Getenv("SEED_DB") == "true" { if os.Getenv("SEED_DB") == "true" {
services.SeedDatabase(db) services.SeedDatabase(db)
} }
services.InitWebSocket(db) domainName := os.Getenv("DOMAIN")
services.InitWebSocket(db, domainName)
// SPOTIFY // SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE") spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
@@ -57,7 +58,6 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig) claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET") authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT") backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour accessTokenLifetime := 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour refreshTokenLifetime := 365 * 24 * time.Hour
@@ -96,7 +96,7 @@ func main() {
protected.PUT("/user/:id", store.UpdateUser) protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser) protected.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers) r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser) protected.POST("/user", store.CreateUser)
// AUTH // AUTH
r.POST("/auth/login", store.Login) r.POST("/auth/login", store.Login)
@@ -112,7 +112,7 @@ func main() {
// MESSAGES // MESSAGES
r.GET("/ws", store.ConnectWebSocket) r.GET("/ws", store.ConnectWebSocket)
r.POST("/messages/upload", store.UploadMessageFile) protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES // NOTES
r.GET("/notes/*path", store.GetNoteFile) r.GET("/notes/*path", store.GetNoteFile)

View File

@@ -2,7 +2,9 @@ package services
import ( import (
"net/http" "net/http"
"strings"
"sync" "sync"
"time"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"gorm.io/gorm" "gorm.io/gorm"
@@ -12,11 +14,19 @@ import (
const maxMessages = 50 const maxMessages = 50
var allowedDomain string
var Upgrader = websocket.Upgrader{ var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { 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 nextAuthorID uint
) )
func InitWebSocket(database *gorm.DB) { const (
rateLimitWindow = time.Second
rateLimitMaxMsgs = 10
)
func InitWebSocket(database *gorm.DB, domain string) {
wsDB = database wsDB = database
allowedDomain = domain
} }
func HandleWebSocket(conn *websocket.Conn) { func HandleWebSocket(conn *websocket.Conn) {
@@ -50,12 +66,25 @@ func HandleWebSocket(conn *websocket.Conn) {
} }
mu.Unlock() mu.Unlock()
msgCount := 0
windowStart := time.Now()
for { for {
var incoming models.Message var incoming models.Message
if err := conn.ReadJSON(&incoming); err != nil { if err := conn.ReadJSON(&incoming); err != nil {
break break
} }
now := time.Now()
if now.Sub(windowStart) > rateLimitWindow {
msgCount = 0
windowStart = now
}
msgCount++
if msgCount > rateLimitMaxMsgs {
continue
}
incoming.AuthorID = authorID incoming.AuthorID = authorID
mu.Lock() mu.Lock()

View File

@@ -69,8 +69,6 @@ services:
- app-network - app-network
volumes: volumes:
- dbdata:/var/lib/postgresql/data - dbdata:/var/lib/postgresql/data
ports:
- 5432:5432
icecast2: icecast2:
build: build:
@@ -82,8 +80,6 @@ services:
- app-network - app-network
env_file: env_file:
- ./.env - ./.env
ports:
- "${ICECAST_PORT}:${ICECAST_PORT}"
gitea-runner: gitea-runner:
image: gitea/act_runner:latest image: gitea/act_runner:latest
@@ -99,7 +95,7 @@ services:
volumes: volumes:
- ./gitea-runner/config.yaml:/config.yaml - ./gitea-runner/config.yaml:/config.yaml
- ./gitea-runner/data:/data - ./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 restart: unless-stopped
networks: networks:
- app-network - app-network
@@ -124,7 +120,6 @@ services:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
- "3000:3000"
- "2222:2222" - "2222:2222"
depends_on: depends_on:
- db - db

View File

@@ -11,6 +11,10 @@ http {
client_max_body_size 10M; 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 log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -98,7 +102,28 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -11,6 +11,10 @@ http {
client_max_body_size 10M; 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 log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -64,7 +68,28 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -39,6 +39,10 @@ function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
} }
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
}
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
}); });
@@ -57,7 +61,7 @@ onUnmounted(() => {
<p v-for="message in messages" :key="message.id"> <p v-for="message in messages" :key="message.id">
<span class="text-tertiary">{{ message.authorId }}:</span> <span class="text-tertiary">{{ message.authorId }}:</span>
{{ message.text }} {{ message.text }}
<template v-if="message.fileUrl"> <template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
<img v-if="isImageUrl(message.fileUrl)" :src="message.fileUrl" <img v-if="isImageUrl(message.fileUrl)" :src="message.fileUrl"
class="max-w-xs max-h-48 rounded" /> class="max-w-xs max-h-48 rounded" />
<a v-else :href="message.fileUrl" target="_blank" <a v-else :href="message.fileUrl" target="_blank"