From 75cede3b1b202c4d05099747fa41b6f932e8c415 Mon Sep 17 00:00:00 2001 From: Adam French Date: Sun, 29 Mar 2026 23:59:10 +0100 Subject: [PATCH] Fix security vulnerabilities across backend, frontend, and infra - Fix auth bypass in UpdatePost/DeletePost (missing return after auth check) - Remove Spotify access token from callback response - Replace internal error messages with generic responses in all handlers - Harden GraphQL: complexity limit, disable playground/introspection in prod - Add security headers (X-Frame-Options, HSTS, etc.) to nginx - Disable Hasura console/dev mode in production - Add DOMPurify sanitization to Markdown component - Fix cookie removal to use correct domain/path from auth config - Fix nil dereference in rowing handler when Claude API errors - Fix wildcard CORS on stamp endpoint - Pin nginx and certbot Docker image versions Co-Authored-By: Claude Opus 4.6 --- backend/handlers/handle_activity.go | 9 ++++-- backend/handlers/handle_auth.go | 43 ++++++++++++++----------- backend/handlers/handle_favorites.go | 9 ++++-- backend/handlers/handle_post.go | 47 +++++++++++++++++----------- backend/handlers/handle_rowing.go | 22 +++++-------- backend/handlers/handle_spotify.go | 16 +++++----- backend/main.go | 24 +++++++++++--- docker-compose.dev.yml | 6 ++++ docker-compose.yml | 6 ++-- nginx/Dockerfile | 2 +- nginx/nginx.conf.template | 7 ++++- vue/package-lock.json | 17 ++++++++++ vue/package.json | 1 + vue/src/components/util/Markdown.vue | 8 +++-- 14 files changed, 141 insertions(+), 76 deletions(-) diff --git a/backend/handlers/handle_activity.go b/backend/handlers/handle_activity.go index 0877838..dd0a742 100644 --- a/backend/handlers/handle_activity.go +++ b/backend/handlers/handle_activity.go @@ -1,6 +1,7 @@ package handlers import ( + "log" "net/http" "adam-french.co.uk/backend/models" @@ -16,7 +17,8 @@ type CreateActivityInput struct { func (store *Store) GetActivity(ctx *gin.Context) { var activitys []models.Activity if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } ctx.JSON(http.StatusOK, activitys) @@ -25,14 +27,15 @@ func (store *Store) GetActivity(ctx *gin.Context) { func (store *Store) CreateActivity(ctx *gin.Context) { var input CreateActivityInput if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, err.Error()) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link} tx := store.DB.Create(&activity) if tx.Error != nil { - ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) + log.Println(tx.Error) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } diff --git a/backend/handlers/handle_auth.go b/backend/handlers/handle_auth.go index cd729a7..16b4c22 100644 --- a/backend/handlers/handle_auth.go +++ b/backend/handlers/handle_auth.go @@ -1,6 +1,7 @@ package handlers import ( + "log" "net/http" "adam-french.co.uk/backend/models" @@ -12,13 +13,13 @@ import ( func (store *Store) AuthMiddlewear(ctx *gin.Context) { access_token, err := ctx.Cookie("access_token") if err != nil { - ctx.AbortWithStatusJSON(401, err.Error()) + ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) return } claims, err := store.Auth.VerifyJWT(access_token) if err != nil { - ctx.AbortWithStatusJSON(401, err.Error()) + ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) return } @@ -58,13 +59,13 @@ func (store *Store) CheckToken(ctx *gin.Context) { claims, err := store.Auth.VerifyJWT(access_token) if err != nil { - ctx.JSON(401, err.Error()) + ctx.JSON(401, gin.H{"error": "unauthorized"}) return } userIDF, ok := (*claims)["id"].(float64) if !ok { - ctx.JSON(401, gin.H{"error": "claims does not contain id"}) + ctx.JSON(401, gin.H{"error": "unauthorized"}) return } userID := uint(userIDF) @@ -72,8 +73,9 @@ func (store *Store) CheckToken(ctx *gin.Context) { user := models.User{ID: userID} tx := store.DB.First(&user) if tx.Error != nil { - ctx.JSON(http.StatusNotFound, tx.Error.Error()) - removeCookies(ctx) + log.Println(tx.Error) + ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + store.removeCookies(ctx) return } @@ -83,13 +85,13 @@ func (store *Store) CheckToken(ctx *gin.Context) { func (store *Store) RefreshToken(ctx *gin.Context) { refreshToken, err := ctx.Cookie("refresh_token") if err != nil { - ctx.JSON(http.StatusUnauthorized, err.Error()) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } claims, err := store.Auth.VerifyJWT(refreshToken) if err != nil { - ctx.JSON(http.StatusUnauthorized, err.Error()) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } @@ -103,14 +105,16 @@ func (store *Store) RefreshToken(ctx *gin.Context) { user := models.User{ID: userID} tx := store.DB.First(&user) if tx.Error != nil { - ctx.JSON(http.StatusNotFound, tx.Error.Error()) - removeCookies(ctx) + log.Println(tx.Error) + ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + store.removeCookies(ctx) return } tokens, err := store.Auth.GenerateJWT(&user) if err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } @@ -138,7 +142,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) { func (store *Store) Login(ctx *gin.Context) { var input UserCredentials if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, err.Error()) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } @@ -155,7 +159,8 @@ func (store *Store) Login(ctx *gin.Context) { tokens, err := store.Auth.GenerateJWT(&user) if err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } @@ -181,27 +186,27 @@ func (store *Store) Login(ctx *gin.Context) { } func (store *Store) Logout(ctx *gin.Context) { - removeCookies(ctx) + store.removeCookies(ctx) ctx.Status(http.StatusOK) } -func removeCookies(ctx *gin.Context) { +func (store *Store) removeCookies(ctx *gin.Context) { ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie( "access_token", "", -1, - "", - "", + store.Auth.Config.Endpoint, + store.Auth.Config.Domain, true, true, ) ctx.SetCookie( "refresh_token", "", -1, - "", - "", + store.Auth.Config.Endpoint, + store.Auth.Config.Domain, true, true, ) } diff --git a/backend/handlers/handle_favorites.go b/backend/handlers/handle_favorites.go index b70dceb..c4b77ab 100644 --- a/backend/handlers/handle_favorites.go +++ b/backend/handlers/handle_favorites.go @@ -1,6 +1,7 @@ package handlers import ( + "log" "net/http" "adam-french.co.uk/backend/models" @@ -16,7 +17,8 @@ type CreateFavoriteInput struct { func (store *Store) GetFavorites(ctx *gin.Context) { var favorites []models.Favorite if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } ctx.JSON(http.StatusOK, favorites) @@ -25,14 +27,15 @@ func (store *Store) GetFavorites(ctx *gin.Context) { func (store *Store) CreateFavorite(ctx *gin.Context) { var input CreateFavoriteInput if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, err.Error()) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link} tx := store.DB.Create(&favorite) if tx.Error != nil { - ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) + log.Println(tx.Error) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } diff --git a/backend/handlers/handle_post.go b/backend/handlers/handle_post.go index d660400..e6489d3 100644 --- a/backend/handlers/handle_post.go +++ b/backend/handlers/handle_post.go @@ -1,6 +1,7 @@ package handlers import ( + "log" "net/http" "strconv" @@ -17,7 +18,8 @@ type CreatePostInput struct { func (store *Store) GetPosts(ctx *gin.Context) { var posts []models.Post if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } ctx.JSON(http.StatusOK, posts) @@ -34,7 +36,8 @@ func (store *Store) GetPost(ctx *gin.Context) { post := models.Post{ID: uint(postID)} if err := store.DB.First(&post).Error; err != nil { - ctx.JSON(http.StatusNotFound, err.Error()) + log.Println(err) + ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } @@ -44,25 +47,25 @@ func (store *Store) GetPost(ctx *gin.Context) { func (store *Store) CreatePost(ctx *gin.Context) { var input CreatePostInput if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, err.Error()) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } claimsVal, ok := ctx.Get("userClaims") if !ok { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } claims, ok := claimsVal.(*jwt.MapClaims) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userIDF, ok := (*claims)["id"].(float64) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userID := uint(userIDF) @@ -71,7 +74,8 @@ func (store *Store) CreatePost(ctx *gin.Context) { post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID} tx := store.DB.Create(&post) if tx.Error != nil { - ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) + log.Println(tx.Error) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } @@ -82,36 +86,38 @@ func (store *Store) UpdatePost(ctx *gin.Context) { postID := ctx.Param("id") var post models.Post if err := store.DB.First(&post, postID).Error; err != nil { - ctx.JSON(http.StatusNotFound, err.Error()) + log.Println(err) + ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } claimsVal, ok := ctx.Get("userClaims") if !ok { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } claims, ok := claimsVal.(*jwt.MapClaims) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userIDF, ok := (*claims)["id"].(float64) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userID := uint(userIDF) if !(userID == post.AuthorID) { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return } var input CreatePostInput if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, err.Error()) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } @@ -119,7 +125,8 @@ func (store *Store) UpdatePost(ctx *gin.Context) { post.Content = input.Content tx := store.DB.Save(&post) if tx.Error != nil { - ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) + log.Println(tx.Error) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } @@ -130,31 +137,33 @@ func (store *Store) DeletePost(ctx *gin.Context) { postID := ctx.Param("id") var post models.Post if err := store.DB.First(&post, postID).Error; err != nil { - ctx.JSON(http.StatusNotFound, err.Error()) + log.Println(err) + ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } claimsVal, ok := ctx.Get("userClaims") if !ok { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } claims, ok := claimsVal.(*jwt.MapClaims) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userIDF, ok := (*claims)["id"].(float64) if !ok { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } userID := uint(userIDF) if !(userID == post.AuthorID) { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return } store.DB.Delete(&post) diff --git a/backend/handlers/handle_rowing.go b/backend/handlers/handle_rowing.go index fcd9c89..1091572 100644 --- a/backend/handlers/handle_rowing.go +++ b/backend/handlers/handle_rowing.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "io" + "log" "net/http" "strings" @@ -24,7 +25,8 @@ type ExtractedRowingData struct { func (store *Store) GetRowing(ctx *gin.Context) { var rowing []models.Rowing if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil { - ctx.JSON(http.StatusInternalServerError, err.Error()) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } ctx.JSON(http.StatusOK, rowing) @@ -118,18 +120,13 @@ No text, no markdown, no explanation. Just the JSON object.`), }, }) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "raw": message.Content[0].Text, - "error": err.Error(), - }) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"}) return } if len(message.Content) == 0 { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "raw": message.Content[0].Text, - "error": "empty response from Claude", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"}) return } @@ -144,11 +141,8 @@ No text, no markdown, no explanation. Just the JSON object.`), err = json.Unmarshal([]byte(raw), &extractedData) if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to parse JSON response", - "detail": err.Error(), - "raw": raw, - }) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"}) return } diff --git a/backend/handlers/handle_spotify.go b/backend/handlers/handle_spotify.go index 7217b06..e36987e 100644 --- a/backend/handlers/handle_spotify.go +++ b/backend/handlers/handle_spotify.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "log" "net/http" "time" @@ -17,12 +18,14 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) { token, err := store.SpotifyAuth.Token(c, state, ctx.Request) if err != nil { - ctx.String(http.StatusInternalServerError, "Couldn't get token: %v", err) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"}) return } if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil { - ctx.String(http.StatusInternalServerError, "Failed to save token: %v", err) + log.Println(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } @@ -32,9 +35,6 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{ "message": "Authentication successful", - "token": token.AccessToken, - "type": token.TokenType, - "expiry": token.Expiry, }) } @@ -48,7 +48,8 @@ func (store *Store) ListeningTo(ctx *gin.Context) { playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c) if err != nil { - ctx.JSON(500, gin.H{"error": err.Error()}) + log.Println(err) + ctx.JSON(500, gin.H{"error": "failed to fetch currently playing"}) return } @@ -70,7 +71,8 @@ func (store *Store) RecentlyPlayed(ctx *gin.Context) { played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts) if err != nil { - ctx.JSON(500, gin.H{"error": err.Error()}) + log.Println(err) + ctx.JSON(500, gin.H{"error": "failed to fetch recently played"}) return } diff --git a/backend/main.go b/backend/main.go index 496fe19..55db1cd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -8,8 +8,12 @@ import ( "time" "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/extension" + "github.com/99designs/gqlgen/graphql/handler/lru" + "github.com/99designs/gqlgen/graphql/handler/transport" "github.com/99designs/gqlgen/graphql/playground" "github.com/gin-gonic/gin" + "github.com/vektah/gqlparser/v2/ast" "adam-french.co.uk/backend/graph" "adam-french.co.uk/backend/handlers" @@ -129,15 +133,27 @@ func main() { r.GET("/notes/*path", store.GetNoteFile) // GRAPHQL - gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{ + gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{ Resolvers: &graph.Resolver{Store: &store}, })) + gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second}) + gqlSrv.AddTransport(transport.Options{}) + gqlSrv.AddTransport(transport.GET{}) + gqlSrv.AddTransport(transport.POST{}) + gqlSrv.AddTransport(transport.MultipartForm{}) + gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) + gqlSrv.Use(extension.FixedComplexityLimit(200)) + if os.Getenv("GQL_INTROSPECTION") == "true" { + gqlSrv.Use(extension.Introspection{}) + } r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) { gqlSrv.ServeHTTP(c.Writer, c.Request) }) - r.GET("/graphql", func(c *gin.Context) { - playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request) - }) + if os.Getenv("GQL_PLAYGROUND") == "true" { + r.GET("/graphql", func(c *gin.Context) { + playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request) + }) + } // HELLO WORLD r.GET("/", func(c *gin.Context) { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c6b03f6..a423813 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,6 +9,8 @@ services: backend: environment: - SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback + - GQL_PLAYGROUND=true + - GQL_INTROSPECTION=true nginx: environment: - DEV_MODE=true @@ -16,6 +18,10 @@ services: ports: - 80:80 - 443:443 + hasura: + environment: + HASURA_GRAPHQL_ENABLE_CONSOLE: "true" + HASURA_GRAPHQL_DEV_MODE: "true" certbot: profiles: - disabled diff --git a/docker-compose.yml b/docker-compose.yml index b7b13dc..e541bce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: - vue_dist:/etc/nginx/html certbot: - image: certbot/certbot + image: certbot/certbot:v3.1.0 container_name: certbot volumes: - ./certbot/entrypoint.sh:/entrypoint.sh @@ -95,8 +95,8 @@ services: environment: HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}" - HASURA_GRAPHQL_ENABLE_CONSOLE: "true" - HASURA_GRAPHQL_DEV_MODE: "true" + HASURA_GRAPHQL_ENABLE_CONSOLE: "false" + HASURA_GRAPHQL_DEV_MODE: "false" HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log" icecast2: diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 747f14c..cbdee25 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:latest +FROM nginx:1.27 RUN rm -rf /etc/nginx/html/* && \ apt-get update && apt-get install -y gettext-base openssl && \ rm -rf /var/lib/apt/lists/* diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index f04be15..ec093b9 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -77,6 +77,11 @@ http { ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + # Vite hashed assets - immutable, cache 1 year location /assets/ { expires 1y; @@ -117,7 +122,7 @@ http { } location = /img/stamps/mine.gif { - add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Origin "https://www.$DOMAIN"; expires 7d; add_header Cache-Control "public"; } diff --git a/vue/package-lock.json b/vue/package-lock.json index 2ca50d2..e7a79d6 100644 --- a/vue/package-lock.json +++ b/vue/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.2.1", "axios": "^1.13.2", + "dompurify": "^3.3.3", "katex": "^0.16.27", "markdown-it": "^14.1.0", "markdown-it-wikilinks": "^1.4.0", @@ -1633,6 +1634,13 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -2226,6 +2234,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/vue/package.json b/vue/package.json index 71bf0d4..7699a2d 100644 --- a/vue/package.json +++ b/vue/package.json @@ -16,6 +16,7 @@ "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.2.1", "axios": "^1.13.2", + "dompurify": "^3.3.3", "katex": "^0.16.27", "markdown-it": "^14.1.0", "markdown-it-wikilinks": "^1.4.0", diff --git a/vue/src/components/util/Markdown.vue b/vue/src/components/util/Markdown.vue index 8992724..8a740d3 100644 --- a/vue/src/components/util/Markdown.vue +++ b/vue/src/components/util/Markdown.vue @@ -1,18 +1,22 @@