From f0d18e713233cd9441efdaaa7459495f7d5db6ca Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 16:32:18 +0800 Subject: [PATCH 01/11] fix(security): harden cookie attributes, SQL injection prevention, and permissions policy - Add allowlist validation for ORDER BY clauses in question and answer repositories to prevent SQL injection (defense-in-depth) - Remove Math.random() fallback for session ID generation; use crypto.randomUUID() exclusively - Derive cookie Secure flag from request TLS / X-Forwarded-Proto instead of fragile CORS config string matching - Call SetSameSite before SetCookie so Gin applies SameSite=Lax attribute - Add SameSite to clearRefreshCookie for consistent cookie attributes - Change Permissions-Policy camera=() to camera=(self) to allow MediaPipe hand tracking in PearlShell component --- backend/internal/handler/auth.go | 17 +++++++++++--- backend/internal/middleware/security.go | 2 +- backend/internal/repository/answer.go | 16 +++++++++---- backend/internal/repository/question.go | 23 +++++++++++++++---- frontend/src/components/overlay/ChatPanel.vue | 5 +--- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index b391ec2c..e7e80ad3 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -26,14 +26,25 @@ func NewAuthHandler(authService *service.AuthService, cfg *config.Config) *AuthH return &AuthHandler{authService: authService, cfg: cfg} } +// isSecureRequest determines whether the current HTTP request should be treated +// as secure for setting the Secure flag on cookies. It checks actual TLS first, +// then falls back to X-Forwarded-Proto for reverse proxy setups. +func isSecureRequest(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") +} + func (h *AuthHandler) setRefreshCookie(c *gin.Context, refreshToken string, maxAge int) { - secure := !strings.Contains(h.cfg.CORSOrigins, "localhost") - c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", secure, true) + secure := isSecureRequest(c) c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", secure, true) } func (h *AuthHandler) clearRefreshCookie(c *gin.Context) { - secure := !strings.Contains(h.cfg.CORSOrigins, "localhost") + secure := isSecureRequest(c) + c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", secure, true) } diff --git a/backend/internal/middleware/security.go b/backend/internal/middleware/security.go index b0ce6c46..c98c4275 100644 --- a/backend/internal/middleware/security.go +++ b/backend/internal/middleware/security.go @@ -9,7 +9,7 @@ func SecurityHeaders() gin.HandlerFunc { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") - c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + c.Header("Permissions-Policy", "camera=(self), microphone=(), geolocation=()") c.Next() } } diff --git a/backend/internal/repository/answer.go b/backend/internal/repository/answer.go index a3066e48..d3434f41 100644 --- a/backend/internal/repository/answer.go +++ b/backend/internal/repository/answer.go @@ -1,12 +1,15 @@ package repository import ( - "fmt" - "github.com/momshell/backend/internal/model" "gorm.io/gorm" ) +var allowedAnswerSortColumns = map[string]bool{ + "created_at": true, + "like_count": true, +} + type AnswerRepo struct { db *gorm.DB } @@ -36,9 +39,14 @@ func (r *AnswerRepo) FindByQuestionID( return nil, 0, err } - orderClause := fmt.Sprintf("%s %s", sortBy, order) + if !allowedAnswerSortColumns[sortBy] { + sortBy = "created_at" + } + if !allowedSortOrders[order] { + order = "desc" + } var answers []model.Answer - err := query.Order(orderClause).Offset(offset).Limit(limit).Find(&answers).Error + err := query.Order(sortBy + " " + order).Offset(offset).Limit(limit).Find(&answers).Error return answers, total, err } diff --git a/backend/internal/repository/question.go b/backend/internal/repository/question.go index c7907112..9e255685 100644 --- a/backend/internal/repository/question.go +++ b/backend/internal/repository/question.go @@ -1,12 +1,22 @@ package repository import ( - "fmt" - "github.com/momshell/backend/internal/model" "gorm.io/gorm" ) +var allowedQuestionSortColumns = map[string]bool{ + "created_at": true, + "view_count": true, + "answer_count": true, + "like_count": true, +} + +var allowedSortOrders = map[string]bool{ + "asc": true, + "desc": true, +} + type QuestionRepo struct { db *gorm.DB } @@ -43,9 +53,14 @@ func (r *QuestionRepo) FindAll( return nil, 0, err } - orderClause := fmt.Sprintf("questions.%s %s", sortBy, order) + if !allowedQuestionSortColumns[sortBy] { + sortBy = "created_at" + } + if !allowedSortOrders[order] { + order = "desc" + } var questions []model.Question - err := query.Order(orderClause).Offset(offset).Limit(limit).Find(&questions).Error + err := query.Order("questions." + sortBy + " " + order).Offset(offset).Limit(limit).Find(&questions).Error return questions, total, err } diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index 62e9e3b9..b6abf86b 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -107,10 +107,7 @@ const colorToneMap: Record = { } function generateSessionId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() - } - return Date.now().toString(36) + Math.random().toString(36).slice(2) + return crypto.randomUUID() } function syncPersistent() { From 564d39c367bc9b3e5d81700370c1fb61c60d2988 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 16:37:04 +0800 Subject: [PATCH 02/11] fix(auth): sync token state between Axios interceptor and Pinia store - Add setOnTokenRefreshed callback so Axios refresh interceptor updates the Pinia store's accessToken (keeps isAuthenticated in sync) - Make apiLogout accessToken parameter optional so logout always calls the server to clear the httpOnly refresh cookie - Always call logout endpoint even when no access token is held in memory - Check response.ok in apiLogout to surface server-side failures --- frontend/src/lib/apiClient.ts | 15 +++++++++++++++ frontend/src/lib/auth.ts | 20 +++++++++++++++----- frontend/src/stores/auth.ts | 16 ++++++++++++---- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 0d9ebf5a..b60269a8 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -10,6 +10,9 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; // In-memory access token — not stored in localStorage let currentAccessToken: string | null = null; +// Callback for notifying external consumers (e.g. Pinia store) of token changes +let onTokenRefreshed: ((token: string | null) => void) | null = null; + export function setAccessToken(token: string | null) { currentAccessToken = token; } @@ -18,6 +21,10 @@ export function getAccessToken(): string | null { return currentAccessToken; } +export function setOnTokenRefreshed(cb: (token: string | null) => void) { + onTokenRefreshed = cb; +} + const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE, headers: { "Content-Type": "application/json" }, @@ -81,6 +88,11 @@ apiClient.interceptors.response.use( const resp = await apiRefresh(); currentAccessToken = resp.access_token; + // Notify store so isAuthenticated stays in sync + if (onTokenRefreshed) { + onTokenRefreshed(resp.access_token); + } + if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${resp.access_token}`; } @@ -90,6 +102,9 @@ apiClient.interceptors.response.use( } catch (refreshError) { processQueue(refreshError as Error, null); currentAccessToken = null; + if (onTokenRefreshed) { + onTokenRefreshed(null); + } return Promise.reject(refreshError); } finally { isRefreshing = false; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 6304d231..ad2438de 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -112,12 +112,22 @@ export function apiSetRole( }); } -export function apiLogout(accessToken: string): Promise { +export function apiLogout(accessToken?: string): Promise { + const headers: Record = {}; + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } return fetch(`${AUTH_API}/logout`, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers, credentials: "include", - }).then(() => undefined); + }).then(async (response) => { + if (!response.ok) { + const err = await response.json().catch(() => ({})); + if (typeof (err as Record).error === "string") { + throw new Error((err as Record).error); + } + throw new Error("logout failed"); + } + }); } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3e527416..75e41cc7 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -11,7 +11,7 @@ import { apiSetRole, apiLogout, } from "@/lib/auth"; -import { setAccessToken } from "@/lib/apiClient"; +import { setAccessToken, setOnTokenRefreshed } from "@/lib/apiClient"; export const useAuthStore = defineStore("auth", () => { const user = ref(null); @@ -26,6 +26,14 @@ export const useAuthStore = defineStore("auth", () => { setAccessToken(token); }); + // Keep store in sync when apiClient refreshes the token via interceptor + setOnTokenRefreshed((token) => { + accessToken.value = token; + if (!token) { + user.value = null; + } + }); + async function init() { // Try to restore session via httpOnly refresh cookie const refreshed = await refreshAuth(); @@ -58,9 +66,9 @@ export const useAuthStore = defineStore("auth", () => { } function logout() { - if (accessToken.value) { - apiLogout(accessToken.value).catch(() => {}); - } + // Always call logout so the server clears the httpOnly refresh cookie, + // even if we don't currently have an access token in memory. + apiLogout(accessToken.value ?? undefined).catch(() => {}); user.value = null; accessToken.value = null; isGuest.value = false; From a991a6fa80c37a38f1727149cd8ce7037c1d986f Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 16:39:45 +0800 Subject: [PATCH 03/11] refactor(backend): extract shared file deletion helper and remove redundant admin checks - Extract duplicated photo file deletion logic from service/photo.go, service/admin.go, and scheduler/photo_cleanup.go into a shared fileutil.RemoveUploadedFile helper - Remove requireAdmin() from all admin handler methods since the router already applies middleware.AdminRequired, eliminating a redundant DB query per admin API request - Fix photo_cleanup comment to match actual 365-day expiration constant --- backend/internal/fileutil/photo.go | 21 +++++++ backend/internal/handler/admin.go | 64 +-------------------- backend/internal/scheduler/photo_cleanup.go | 19 +----- backend/internal/service/admin.go | 8 +-- backend/internal/service/photo.go | 10 +--- 5 files changed, 30 insertions(+), 92 deletions(-) create mode 100644 backend/internal/fileutil/photo.go diff --git a/backend/internal/fileutil/photo.go b/backend/internal/fileutil/photo.go new file mode 100644 index 00000000..d91f70dc --- /dev/null +++ b/backend/internal/fileutil/photo.go @@ -0,0 +1,21 @@ +package fileutil + +import ( + "os" + "path/filepath" + "strings" +) + +// RemoveUploadedFile safely removes a file referenced by a URL-style path +// (e.g. "/uploads/photos/abc.png") from disk. It validates the resolved path +// is under the uploads directory and contains no path traversal components. +func RemoveUploadedFile(imageURL string) { + if imageURL == "" { + return + } + localPath := filepath.Clean("." + imageURL) + if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && + !strings.Contains(localPath, "..") { + _ = os.Remove(localPath) + } +} diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index e0a8a5a7..3f870498 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -31,28 +31,6 @@ func (h *AdminHandler) IsAdmin(userID string) bool { return user.IsAdmin } -// requireAdmin checks if the current user is an admin -func (h *AdminHandler) requireAdmin(c *gin.Context) (string, bool) { - userID := middleware.GetUserID(c) - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) - return "", false - } - - user, err := h.authService.GetUserByID(userID) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"}) - return "", false - } - - if !user.IsAdmin { - c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) - return "", false - } - - return userID, true -} - // ServeAdminPage returns the embedded admin HTML page func (h *AdminHandler) ServeAdminPage(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", admin.HTML) @@ -60,10 +38,6 @@ func (h *AdminHandler) ServeAdminPage(c *gin.Context) { // GetStats returns dashboard statistics func (h *AdminHandler) GetStats(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - stats, err := h.adminService.GetDashboardStats() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -75,10 +49,6 @@ func (h *AdminHandler) GetStats(c *gin.Context) { // ListUsers returns paginated user list func (h *AdminHandler) ListUsers(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var params dto.AdminUserListParams if err := c.ShouldBindQuery(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) @@ -96,10 +66,6 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { // GetUser returns a single user's detail func (h *AdminHandler) GetUser(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - id := c.Param("id") detail, err := h.adminService.GetUser(id) if err != nil { @@ -112,10 +78,6 @@ func (h *AdminHandler) GetUser(c *gin.Context) { // CreateUser creates a new user func (h *AdminHandler) CreateUser(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var req dto.AdminCreateUser if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()}) @@ -133,10 +95,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { // UpdateUser updates user fields func (h *AdminHandler) UpdateUser(c *gin.Context) { - adminID, ok := h.requireAdmin(c) - if !ok { - return - } + adminID := middleware.GetUserID(c) id := c.Param("id") var req dto.AdminUserUpdate @@ -156,10 +115,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { // DeleteUser deletes a user func (h *AdminHandler) DeleteUser(c *gin.Context) { - adminID, ok := h.requireAdmin(c) - if !ok { - return - } + adminID := middleware.GetUserID(c) id := c.Param("id") if err := h.adminService.DeleteUser(id, adminID); err != nil { @@ -172,20 +128,12 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { // GetConfig returns configuration items func (h *AdminHandler) GetConfig(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - items := h.adminService.GetConfig() c.JSON(http.StatusOK, gin.H{"items": items}) } // UpdateConfig updates editable configuration items func (h *AdminHandler) UpdateConfig(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var req dto.ConfigUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()}) @@ -202,10 +150,6 @@ func (h *AdminHandler) UpdateConfig(c *gin.Context) { // ListPhotos returns paginated photo list for admin func (h *AdminHandler) ListPhotos(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - var params dto.AdminPhotoListParams if err := c.ShouldBindQuery(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) @@ -223,10 +167,6 @@ func (h *AdminHandler) ListPhotos(c *gin.Context) { // DeletePhoto deletes a photo by ID (admin) func (h *AdminHandler) DeletePhoto(c *gin.Context) { - if _, ok := h.requireAdmin(c); !ok { - return - } - id := c.Param("id") if err := h.adminService.DeletePhoto(id); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/backend/internal/scheduler/photo_cleanup.go b/backend/internal/scheduler/photo_cleanup.go index 41811f03..c10d936a 100644 --- a/backend/internal/scheduler/photo_cleanup.go +++ b/backend/internal/scheduler/photo_cleanup.go @@ -2,11 +2,9 @@ package scheduler import ( "log" - "os" - "path/filepath" - "strings" "time" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/repository" ) @@ -17,7 +15,7 @@ const ( ) // StartPhotoCleanup launches a background goroutine that periodically deletes -// photos with is_on_wall=false older than 30 days, including their disk files. +// photos with is_on_wall=false older than 365 days, including their disk files. func StartPhotoCleanup(photoRepo *repository.PhotoRepo) { go func() { log.Println("photo cleanup scheduler started") @@ -48,7 +46,7 @@ func runCleanup(photoRepo *repository.PhotoRepo) { } for _, p := range photos { - removePhotoFile(p.ImageURL) + fileutil.RemoveUploadedFile(p.ImageURL) if err := photoRepo.DeleteByID(p.ID); err != nil { log.Printf("photo cleanup: delete error id=%s: %v", p.ID, err) continue @@ -61,14 +59,3 @@ func runCleanup(photoRepo *repository.PhotoRepo) { log.Printf("photo cleanup: deleted %d expired photos", totalDeleted) } } - -func removePhotoFile(imageURL string) { - if imageURL == "" { - return - } - localPath := filepath.Clean("." + imageURL) - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } -} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index e0b2d093..f2c14454 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -4,13 +4,13 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" "strings" "sync" "github.com/momshell/backend/internal/config" "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/pkg/password" @@ -273,11 +273,7 @@ func (s *AdminService) DeletePhoto(id string) error { } if photo.ImageURL != "" { - localPath := filepath.Clean("." + photo.ImageURL) - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } + fileutil.RemoveUploadedFile(photo.ImageURL) } return s.photoRepo.DeleteByID(id) diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index 4eed6bd1..153d0376 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/momshell/backend/internal/dto" + "github.com/momshell/backend/internal/fileutil" "github.com/momshell/backend/internal/model" "github.com/momshell/backend/internal/repository" "github.com/momshell/backend/pkg/openai" @@ -186,14 +187,7 @@ func (s *PhotoService) DeletePhoto(id, userID string) error { } // Remove file from disk if it's a local upload - if photo.ImageURL != "" { - localPath := filepath.Clean("." + photo.ImageURL) - // Strict validation: must be under uploads/photos/ and match expected pattern - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { - _ = os.Remove(localPath) - } - } + fileutil.RemoveUploadedFile(photo.ImageURL) return s.photoRepo.Delete(id, userID) } From 5d46e11e9924a78e51e4f1e2fb10fa41a7de22d4 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 16:42:32 +0800 Subject: [PATCH 04/11] docs: update all documentation to reflect current codebase - CHANGELOG: add comprehensive Unreleased section covering security hardening, photo gallery, memoir editing, whisper, tasks, community enhancements, and frontend features - README: fix Go version badge (1.25), add missing feature modules (Echo, Photo, Whisper, Tasks), update project structure - SECURITY: update supported versions (1.x.x), fix SQLite references to PostgreSQL, update module names and secret variable names - docs/features: add Echo/Memoir, Photo Gallery, Whisper, Tasks sections - docs/architecture: update Go version, add GSAP/Three.js/MediaPipe/ Firecrawl to tech stack, add scheduler and rate limiting - docs/configuration: add IMAGE_MODEL and FIRECRAWL_API_KEY variables - docs/development, getting-started: update Go version to 1.25+ - docs/README: add links to Changelog, Security, Code of Conduct --- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++--- README.md | 15 ++++++++--- SECURITY.md | 15 ++++++----- docs/README.md | 10 +++++++- docs/architecture.md | 23 +++++++++++------ docs/configuration.md | 9 +++++++ docs/development.md | 2 +- docs/features.md | 37 ++++++++++++++++++++++++++ docs/getting-started.md | 2 +- 9 files changed, 145 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae3f2f3..99c1c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Security Hardening + +- **httpOnly cookie authentication**: Migrated auth tokens from localStorage to httpOnly cookies, eliminating XSS token theft vectors +- **Sliding window rate limiting**: Added per-endpoint rate limiting to all API routes to prevent abuse +- **Input validation hardening**: Strengthened validation across all user-facing endpoints +- **SQL injection prevention**: Added allowlist validation for ORDER BY clauses in repository layer (defense-in-depth for `question.go` and `answer.go`) +- **Insecure randomness fix**: Removed `Math.random()` fallback for session ID generation in `ChatPanel.vue`; now uses `crypto.randomUUID()` exclusively +- **Auth token extraction hardening**: Improved multi-source token extraction security (header, cookie) +- **OpenAI error sanitization**: Removed response body from OpenAI error messages to prevent data exposure + +#### Photo Gallery & Lifecycle Management + +- **Photo lifecycle management**: Auto-cleanup of expired photos and admin-level photo controls +- **Pic wall**: Interactive photo wall with drag, zoom, and close-window UI +- **AI photo generation**: AI-generated photos in memoir using image model integration + +#### Memoir & Echo Enhancements + +- **Memoir text editing**: Users can edit AI-generated memoir text inline +- **Image regeneration**: Regenerate memoir cover images on demand + +#### Whisper & Tasks + +- **Whisper (Conque)**: Audio-to-text conversation feature using speech recognition +- **Tasks (Star)**: Goal-setting and task-tracking system with partner support +- **Partner task UI**: Improved task interface with reject and polling support + +#### Admin Panel + +- **Frontend admin exposure**: Admin panel accessible through the Vue frontend with navigation integration + +#### Community Enhancements + +- **AI auto-reply trigger**: AI reply triggered on answers mentioning @小石光 +- **Cascade delete**: Deleting a question now cascades to its answers and comments +- **Edit/delete UI**: Added edit and delete controls for questions, answers, and comments +- **AI avatar**: Dedicated AI user avatar with simplified mention pattern +- **Source citation**: AI replies cite sources only for factual claims, not emotional support; sources renumbered sequentially + #### Vue 3 Frontend -- **Background music loop**: Added global alternating playback for `The Shore and You` and `Travelogue` -- **Profile music control**: Added background music volume slider in the profile settings panel +- **Background music loop**: Global alternating playback for `The Shore and You` and `Travelogue` +- **Profile music control**: Background music volume slider in the profile settings panel +- **Beach shell sprites**: Repositioned shell sprites to the right side of the scene +- **Hand detection camera**: MediaPipe hand detection integration (hidden camera preview) ### Changed - **Frontend assets**: Reorganized static assets into `frontend/src/assets/images/` and `frontend/src/assets/audio/` -- **Asset imports**: Updated frontend image and audio references to match the new asset directory structure -- **Crab speech bubble anchoring**: Reworked the beach scene crab hint bubble to position from the crab sprite's rendered bounds instead of static offsets, keeping the bubble visually closer across viewport sizes. +- **Crab speech bubble anchoring**: Reworked hint bubble positioning to use rendered sprite bounds instead of static offsets +- **Pearl shell layout**: Adjusted pearl shell positioning; uses all photos in pearl shell + +### Fixed + +- Missing `autocomplete` attribute on nickname input +- Stray character in `App.vue` template +- Pearl shell layout positioning issues +- Camera preview visibility in pic wall + --- ## [1.0.0] - 2026-03-05 diff --git a/README.md b/README.md index 88d7965e..3389ba2e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/koishi510/MomShell/ci.yml?branch=main&style=flat&label=CI)](https://github.com/koishi510/MomShell/actions/workflows/ci.yml) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue?style=flat)](LICENSE) -[![Go](https://img.shields.io/badge/Go-1.23-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) +[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev/) [![Vue](https://img.shields.io/badge/Vue-3-4FC08D?style=flat&logo=vue.js&logoColor=white)](https://vuejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) @@ -15,6 +15,10 @@ AI-powered postpartum recovery platform with emotional support, community connec |--------|-------------| | **Soul Companion** | AI chat companion with conversation memory and emotional support | | **Sisterhood Bond** | Community Q&A with verified healthcare professionals and content moderation | +| **Echo / Memoir** | Self-reflection space with AI-generated memoir stickers and partner connection | +| **Photo Gallery** | Photo wall with AI-generated images, lifecycle management, and drag/zoom UI | +| **Whisper** | Audio-to-text conversation using speech recognition | +| **Tasks** | Goal-setting and task-tracking system with partner support | | **Admin Panel** | Embedded single-page admin at `/admin` — dashboard, user CRUD, config management | ## Quick Start @@ -38,12 +42,17 @@ MomShell/ ├── backend/ # Go (Gin + GORM + PostgreSQL) │ ├── cmd/server/ # Entry point │ ├── internal/ # App code (handler/service/repository/model/dto) -│ │ └── admin/ # Embedded admin panel (go:embed) -│ └── pkg/ # Shared utilities (JWT, password, OpenAI) +│ │ ├── admin/ # Embedded admin panel (go:embed) +│ │ └── scheduler/ # Background job scheduling +│ └── pkg/ # Shared utilities (JWT, password, OpenAI, Firecrawl) ├── frontend/ # Vue 3 (Vite + TypeScript + Pinia) │ └── src/ +│ ├── components/ # Overlay panels + beach scene layers +│ ├── composables/# Animation, parallax, waves, music +│ └── stores/ # Pinia stores (auth, UI) ├── deploy/ # Docker Compose + Nginx ├── docs/ # Documentation +├── scripts/ # Development setup scripts └── Makefile # Development commands ``` diff --git a/SECURITY.md b/SECURITY.md index 8ec7d472..9bb03048 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 0.x.x | ✓ | +| 1.x.x | ✓ | +| 0.x.x | ✗ (archived) | ## Reporting a Vulnerability @@ -27,7 +28,7 @@ Instead, please report them via one of the following methods: Please include the following information in your report: - Type of vulnerability (e.g., SQL injection, XSS, authentication bypass) -- Affected component (Soul Companion, Recovery Coach, Community, Guardian Partner, API, etc.) +- Affected component (Soul Companion, Sisterhood Bond, Echo/Memoir, Photo Gallery, Whisper, Tasks, Admin Panel, API, etc.) - Step-by-step instructions to reproduce the issue - Proof-of-concept or exploit code (if possible) - Potential impact of the vulnerability @@ -47,19 +48,19 @@ When deploying MomShell, please ensure: 1. **Environment Variables** - Never commit `.env` files or API keys to version control - - Use secure secret management for `MODELSCOPE_KEY` + - Use secure secret management for `OPENAI_API_KEY` - Change `JWT_SECRET_KEY` to a secure random value in production - Rotate API keys periodically 2. **Network Security** - - Use HTTPS in production (configure SSL/TLS in nginx) + - Use HTTPS in production (configure SSL/TLS in Nginx) - Restrict API access to trusted origins (CORS configuration) - Use firewalls to limit exposed ports 3. **Database Security** - - Use strong, unique passwords for database access - - Regularly backup database files - - Restrict file permissions on SQLite database files + - Use strong, unique passwords for PostgreSQL access + - Regularly backup the database + - Restrict network access to the database port 4. **Docker Security** - Keep base images updated diff --git a/docs/README.md b/docs/README.md index 0379779c..f93d1386 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,12 +7,20 @@ | [Configuration](configuration.md) | Environment variables reference | | [Architecture](architecture.md) | Technical design and project structure | | [Deployment](deployment.md) | Docker and production deployment | -| [Features](features.md) | Module descriptions | +| [Features](features.md) | Module descriptions (Soul Companion, Sisterhood Bond, Echo, Photo, Whisper, Tasks, Admin) | ## Contributing See [CONTRIBUTING.md](../CONTRIBUTING.md) for code standards and workflow. +## Other Documents + +| Document | Description | +|----------|-------------| +| [Changelog](../CHANGELOG.md) | Version history | +| [Security Policy](../SECURITY.md) | Vulnerability reporting | +| [Code of Conduct](../CODE_OF_CONDUCT.md) | Community standards | + --- [Back to main README](../README.md) diff --git a/docs/architecture.md b/docs/architecture.md index 2f291cdb..39806896 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,11 +8,12 @@ Technical architecture overview of MomShell. | Technology | Purpose | |------------|---------| -| **Go 1.23** | Backend language | +| **Go 1.25** | Backend language | | **Gin** | HTTP framework | | **GORM** | ORM (PostgreSQL) | -| **JWT (golang-jwt)** | Authentication | -| **OpenAI SDK** | LLM integration | +| **JWT (golang-jwt)** | Authentication (httpOnly cookies) | +| **OpenAI SDK** | LLM integration (Qwen / any OpenAI-compatible) | +| **Firecrawl** | Web search for grounding AI responses | | **go:embed** | Embedded admin panel | ### Frontend @@ -24,6 +25,9 @@ Technical architecture overview of MomShell. | **TypeScript** | Type safety | | **Pinia** | State management | | **Axios** | HTTP client | +| **GSAP** | Animations | +| **Three.js** | 3D graphics | +| **MediaPipe** | Hand detection | ## Project Structure @@ -37,12 +41,14 @@ MomShell/ │ │ ├── database/ # DB connection & auto-migration │ │ ├── dto/ # Request/response data transfer objects │ │ ├── handler/ # HTTP handlers (Gin) -│ │ ├── middleware/ # Auth, CORS, recovery middleware +│ │ ├── middleware/ # Auth, CORS, recovery, rate limiting │ │ ├── model/ # GORM models (User, Question, Answer, etc.) │ │ ├── repository/ # Data access layer │ │ ├── router/ # Route registration +│ │ ├── scheduler/ # Background job scheduling (photo cleanup) │ │ └── service/ # Business logic │ └── pkg/ +│ ├── firecrawl/ # Web search API client │ ├── jwt/ # JWT generation & validation │ ├── openai/ # OpenAI-compatible client │ └── password/ # bcrypt hashing @@ -50,9 +56,9 @@ MomShell/ ├── frontend/ # Vue 3 frontend │ └── src/ │ ├── components/ -│ │ ├── overlay/ # Auth, chat, community, profile panels +│ │ ├── overlay/ # Auth, chat, community, profile, whisper, task panels │ │ └── scene/ # Beach scene layers (sky, ocean, sand, etc.) -│ ├── composables/ # Vue composables (animation, parallax, waves) +│ ├── composables/ # Vue composables (animation, parallax, waves, music) │ ├── constants/ # Scene configuration │ ├── lib/ # API client, auth utilities │ ├── stores/ # Pinia stores (auth, UI) @@ -60,7 +66,7 @@ MomShell/ │ ├── deploy/ # Docker Compose + Nginx config ├── docs/ # Documentation -├── scripts/dev-setup.sh # Development setup script +├── scripts/ # Development setup scripts ├── .env.example # Environment template ├── Makefile # Build & dev commands └── .pre-commit-config.yaml # Git hooks @@ -86,9 +92,10 @@ The admin panel is a single HTML file (`internal/admin/admin.html`) using Tailwi ### Authentication -- JWT access tokens (30 min) + refresh tokens (7 days) +- JWT access tokens (30 min) + refresh tokens (7 days), stored in httpOnly cookies - Tokens extracted from `Authorization: Bearer`, `X-Access-Token` header, or `access_token` cookie - Admin role verified per-request in handler via `authService.GetUserByID` +- Sliding window rate limiting on all API endpoints ### Content Moderation diff --git a/docs/configuration.md b/docs/configuration.md index 012713e0..5070b2eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,9 +27,18 @@ The setup script auto-generates `JWT_SECRET_KEY`. Change it manually for product | `OPENAI_API_KEY` | API key for LLM service | **Yes** | — | | `OPENAI_BASE_URL` | API base URL | No | `https://api-inference.modelscope.cn/v1` | | `OPENAI_MODEL` | Model name | No | `Qwen/Qwen3-235B-A22B` | +| `IMAGE_MODEL` | Model for AI image generation | No | `Tongyi-MAI/Z-Image-Turbo` | Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.). +## Web Search + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `FIRECRAWL_API_KEY` | Firecrawl API key for web search grounding | No | — | + +When set, AI replies use web search to reduce hallucinations for factual questions. + ## Server | Variable | Description | Required | Default | diff --git a/docs/development.md b/docs/development.md index f00321f4..b56c391e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ ## Prerequisites -- Go 1.23+ +- Go 1.25+ - Node.js 24+ - PostgreSQL - Git diff --git a/docs/features.md b/docs/features.md index b8a2eb38..ac764c7b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,6 +9,7 @@ AI-powered emotional support companion. - **Empathetic conversations** designed for postpartum emotional support - **Conversation memory** for personalized experience across sessions - **Content moderation** with crisis keyword detection +- **Visual effects** — color tones and animations on AI responses ## Sisterhood Bond @@ -17,8 +18,42 @@ Community Q&A connecting mothers with verified healthcare professionals. - **Dual channels**: Professional advice and peer experience sharing - **Verified professionals**: Doctors, therapists, and nurses with credential verification - **Engagement**: Q&A, likes, collections, comments +- **AI auto-reply**: AI community assistant replies to posts and comments with source citations - **Content moderation**: Keyword-based filtering with manual review queue +## Echo / Memoir + +Self-reflection space for mothers to reconnect with their pre-motherhood identity. + +- **Identity tags**: Capture personal preferences (music, sounds, literature, memories) +- **AI-generated memoirs**: Nostalgic stories based on personal tags with editable text +- **Memoir covers**: AI-generated SVG gradient covers with image regeneration +- **Partner connection**: Partners can observe and support through a glass window metaphor + +## Photo Gallery + +Photo management with AI-powered image generation. + +- **Photo wall**: Interactive drag-and-zoom photo browsing (pic wall UI) +- **AI photo generation**: Generate photos in memoir using image model +- **Photo lifecycle**: Auto-cleanup of expired photos with admin controls +- **Pearl shell**: Photo display integrated into the beach scene + +## Whisper + +Audio-to-text conversation feature. + +- **Speech recognition**: Convert spoken words to text for the AI companion +- **Conque shell metaphor**: Listen and speak through the beach shell + +## Tasks + +Goal-setting and tracking system. + +- **Task creation**: Set personal recovery goals +- **Partner support**: Partners can participate in tasks with accept/reject workflow +- **Progress polling**: Real-time status updates on partner task completion + ## Admin Panel Embedded management interface at `/admin`. @@ -26,7 +61,9 @@ Embedded management interface at `/admin`. - **Dashboard**: User statistics, content counts, role distribution - **User management**: Search, filter, paginate, create, edit (role/status), delete - **Config management**: View and edit runtime configuration (API keys, token expiration) +- **Photo management**: Admin-level photo lifecycle controls - **Single-file UI**: Tailwind CSS + Alpine.js, embedded via `go:embed` +- **Frontend access**: Admin panel also accessible through the Vue frontend navigation --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 97cfac1b..d376e8c1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ ## Prerequisites -- [Go 1.23+](https://go.dev/dl/) +- [Go 1.25+](https://go.dev/dl/) - [Node.js 24+](https://nodejs.org/) (or via [nvm](https://github.com/nvm-sh/nvm)) - [PostgreSQL](https://www.postgresql.org/) - Git From 27b9d10442f7cf090ed9227d68e05798684b6755 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 17:28:32 +0800 Subject: [PATCH 05/11] fix(security): break taint flow in ORDER BY to satisfy CodeQL analysis The previous allowlist approach reassigned the parameter variables, but CodeQL's static taint tracker still saw user-controlled data flowing into the query string. Refactor to use lookup maps that return fresh string literals from map values, and move the sanitization into dedicated functions (sanitizeQuestionSort, sanitizeAnswerSort) so the Order() call never references the original tainted parameters. --- backend/internal/repository/answer.go | 26 +++++++++++------- backend/internal/repository/question.go | 36 ++++++++++++++----------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/backend/internal/repository/answer.go b/backend/internal/repository/answer.go index d3434f41..a9955696 100644 --- a/backend/internal/repository/answer.go +++ b/backend/internal/repository/answer.go @@ -5,9 +5,21 @@ import ( "gorm.io/gorm" ) -var allowedAnswerSortColumns = map[string]bool{ - "created_at": true, - "like_count": true, +var allowedAnswerSortColumns = map[string]string{ + "created_at": "created_at", + "like_count": "like_count", +} + +func sanitizeAnswerSort(sortBy, order string) string { + col, ok := allowedAnswerSortColumns[sortBy] + if !ok { + col = "created_at" + } + dir, ok := allowedSortOrders[order] + if !ok { + dir = "desc" + } + return col + " " + dir } type AnswerRepo struct { @@ -39,14 +51,8 @@ func (r *AnswerRepo) FindByQuestionID( return nil, 0, err } - if !allowedAnswerSortColumns[sortBy] { - sortBy = "created_at" - } - if !allowedSortOrders[order] { - order = "desc" - } var answers []model.Answer - err := query.Order(sortBy + " " + order).Offset(offset).Limit(limit).Find(&answers).Error + err := query.Order(sanitizeAnswerSort(sortBy, order)).Offset(offset).Limit(limit).Find(&answers).Error return answers, total, err } diff --git a/backend/internal/repository/question.go b/backend/internal/repository/question.go index 9e255685..818d64ff 100644 --- a/backend/internal/repository/question.go +++ b/backend/internal/repository/question.go @@ -5,16 +5,28 @@ import ( "gorm.io/gorm" ) -var allowedQuestionSortColumns = map[string]bool{ - "created_at": true, - "view_count": true, - "answer_count": true, - "like_count": true, +var allowedQuestionSortColumns = map[string]string{ + "created_at": "questions.created_at", + "view_count": "questions.view_count", + "answer_count": "questions.answer_count", + "like_count": "questions.like_count", } -var allowedSortOrders = map[string]bool{ - "asc": true, - "desc": true, +var allowedSortOrders = map[string]string{ + "asc": "asc", + "desc": "desc", +} + +func sanitizeQuestionSort(sortBy, order string) string { + col, ok := allowedQuestionSortColumns[sortBy] + if !ok { + col = "questions.created_at" + } + dir, ok := allowedSortOrders[order] + if !ok { + dir = "desc" + } + return col + " " + dir } type QuestionRepo struct { @@ -53,14 +65,8 @@ func (r *QuestionRepo) FindAll( return nil, 0, err } - if !allowedQuestionSortColumns[sortBy] { - sortBy = "created_at" - } - if !allowedSortOrders[order] { - order = "desc" - } var questions []model.Question - err := query.Order("questions." + sortBy + " " + order).Offset(offset).Limit(limit).Find(&questions).Error + err := query.Order(sanitizeQuestionSort(sortBy, order)).Offset(offset).Limit(limit).Find(&questions).Error return questions, total, err } From 8ae8b04400e5ff5d6dfbc3961eb3cbf95680476a Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:18:13 +0800 Subject: [PATCH 06/11] fix(security): guard X-Forwarded-Proto behind TrustProxy config and simplify path validation - Make isSecureRequest a method on AuthHandler, only trust X-Forwarded-Proto when cfg.TrustProxy is explicitly enabled - Add TrustProxy bool config field with getEnvBool helper - Remove redundant ".." check in RemoveUploadedFile since filepath.Clean already resolves traversal components - Move allowedSortOrders to dedicated sort.go for discoverability - Add TRUST_PROXY to .env.example with documentation --- .env.example | 5 +++++ backend/internal/config/config.go | 20 ++++++++++++++++++-- backend/internal/fileutil/photo.go | 8 ++++---- backend/internal/handler/auth.go | 14 +++++++++----- backend/internal/repository/question.go | 5 ----- backend/internal/repository/sort.go | 8 ++++++++ 6 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 backend/internal/repository/sort.go diff --git a/.env.example b/.env.example index 683eb5c1..d7ea1a43 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ FIRECRAWL_API_KEY= # ==================== Server ==================== PORT=8000 +# ==================== Proxy ==================== +# Set to true when running behind a reverse proxy (Nginx, Cloudflare, etc.) +# that sets the X-Forwarded-Proto header. Required for correct Secure cookie flag. +TRUST_PROXY=false + # ==================== Frontend ==================== # Backend API URL (defaults to http://localhost:8000, leave empty for Nginx proxy in production) VITE_API_BASE_URL=http://localhost:8000 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d3390962..2a4bcac4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,9 @@ type Config struct { // CORS CORSOrigins string + // Proxy + TrustProxy bool + // Logging DBLogLevel string @@ -55,11 +58,12 @@ func Load() *Config { JWTRefreshTokenExpireDays: getEnvInt("JWT_REFRESH_TOKEN_EXPIRE_DAYS", 7), OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api-inference.modelscope.cn/v1"), - OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen2.5-72B-Instruct"), + OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen3-235B-A22B"), FirecrawlAPIKey: getEnv("FIRECRAWL_API_KEY", ""), - ImageModel: getEnv("IMAGE_MODEL", ""), + ImageModel: getEnv("IMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo"), Port: getEnv("PORT", "8000"), CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:8000,http://localhost:3000"), + TrustProxy: getEnvBool("TRUST_PROXY", false), DBLogLevel: getEnv("DB_LOG_LEVEL", "warn"), AdminUsername: getEnv("ADMIN_USERNAME", ""), AdminEmail: getEnv("ADMIN_EMAIL", ""), @@ -91,3 +95,15 @@ func getEnvInt(key string, fallback int) int { } return i } + +func getEnvBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + return fallback + } + return b +} diff --git a/backend/internal/fileutil/photo.go b/backend/internal/fileutil/photo.go index d91f70dc..87d245fa 100644 --- a/backend/internal/fileutil/photo.go +++ b/backend/internal/fileutil/photo.go @@ -7,15 +7,15 @@ import ( ) // RemoveUploadedFile safely removes a file referenced by a URL-style path -// (e.g. "/uploads/photos/abc.png") from disk. It validates the resolved path -// is under the uploads directory and contains no path traversal components. +// (e.g. "/uploads/photos/abc.png") from disk. filepath.Clean resolves any +// path traversal components, and the prefix check ensures the resolved path +// remains under the uploads directory. func RemoveUploadedFile(imageURL string) { if imageURL == "" { return } localPath := filepath.Clean("." + imageURL) - if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) && - !strings.Contains(localPath, "..") { + if strings.HasPrefix(localPath, "uploads"+string(filepath.Separator)) { _ = os.Remove(localPath) } } diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index e7e80ad3..4932f230 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -28,22 +28,26 @@ func NewAuthHandler(authService *service.AuthService, cfg *config.Config) *AuthH // isSecureRequest determines whether the current HTTP request should be treated // as secure for setting the Secure flag on cookies. It checks actual TLS first, -// then falls back to X-Forwarded-Proto for reverse proxy setups. -func isSecureRequest(c *gin.Context) bool { +// then falls back to X-Forwarded-Proto only when cfg.TrustProxy is enabled +// (i.e. the app runs behind a known reverse proxy that sets this header). +func (h *AuthHandler) isSecureRequest(c *gin.Context) bool { if c.Request.TLS != nil { return true } - return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") + if h.cfg.TrustProxy { + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") + } + return false } func (h *AuthHandler) setRefreshCookie(c *gin.Context, refreshToken string, maxAge int) { - secure := isSecureRequest(c) + secure := h.isSecureRequest(c) c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", secure, true) } func (h *AuthHandler) clearRefreshCookie(c *gin.Context) { - secure := isSecureRequest(c) + secure := h.isSecureRequest(c) c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", secure, true) } diff --git a/backend/internal/repository/question.go b/backend/internal/repository/question.go index 818d64ff..4fbbfae4 100644 --- a/backend/internal/repository/question.go +++ b/backend/internal/repository/question.go @@ -12,11 +12,6 @@ var allowedQuestionSortColumns = map[string]string{ "like_count": "questions.like_count", } -var allowedSortOrders = map[string]string{ - "asc": "asc", - "desc": "desc", -} - func sanitizeQuestionSort(sortBy, order string) string { col, ok := allowedQuestionSortColumns[sortBy] if !ok { diff --git a/backend/internal/repository/sort.go b/backend/internal/repository/sort.go new file mode 100644 index 00000000..2d2ad281 --- /dev/null +++ b/backend/internal/repository/sort.go @@ -0,0 +1,8 @@ +package repository + +// allowedSortOrders is shared across question and answer repositories +// to validate user-supplied ORDER BY direction. +var allowedSortOrders = map[string]string{ + "asc": "asc", + "desc": "desc", +} From ea205efca552510637e4a2645c577ba818145d9d Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:18:48 +0800 Subject: [PATCH 07/11] fix(frontend): use Chinese error message, add crypto.randomUUID fallback, document callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change "logout failed" to "退出登录失败" for consistent i18n - Restore crypto.getRandomValues fallback for non-secure HTTP contexts - Add documentation comment about single-callback limitation in apiClient --- frontend/src/components/overlay/ChatPanel.vue | 9 ++++++++- frontend/src/lib/apiClient.ts | 5 ++++- frontend/src/lib/auth.ts | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index b6abf86b..eda189ff 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -107,7 +107,14 @@ const colorToneMap: Record = { } function generateSessionId(): string { - return crypto.randomUUID() + // crypto.randomUUID() requires a secure context (HTTPS or localhost). + // Fall back to crypto.getRandomValues for non-secure HTTP environments. + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') } function syncPersistent() { diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index b60269a8..86ac6e17 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -10,7 +10,10 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; // In-memory access token — not stored in localStorage let currentAccessToken: string | null = null; -// Callback for notifying external consumers (e.g. Pinia store) of token changes +// Callback for notifying external consumers (e.g. Pinia store) of token changes. +// Only one callback is supported — subsequent calls to setOnTokenRefreshed +// overwrite the previous one. This is intentional for a single-page app with +// one auth store instance. let onTokenRefreshed: ((token: string | null) => void) | null = null; export function setAccessToken(token: string | null) { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index ad2438de..4339a58d 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -127,7 +127,7 @@ export function apiLogout(accessToken?: string): Promise { if (typeof (err as Record).error === "string") { throw new Error((err as Record).error); } - throw new Error("logout failed"); + throw new Error("退出登录失败"); } }); } From fc1c85495b3cb231b21b42b893f565b0beaabf58 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:19:21 +0800 Subject: [PATCH 08/11] docs: add TRUST_PROXY to configuration reference --- docs/configuration.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 5070b2eb..ab94ec32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,14 @@ Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.). When set, AI replies use web search to reduce hallucinations for factual questions. +## Proxy + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `TRUST_PROXY` | Trust `X-Forwarded-Proto` header for Secure cookie flag | No | `false` | + +Set to `true` when running behind a reverse proxy (Nginx, Cloudflare, etc.) that sets the `X-Forwarded-Proto` header. Required for correct `Secure` cookie flag in HTTPS deployments. + ## Server | Variable | Description | Required | Default | From c7fd50babab162d0e2e09173b7e400aa512b5fd7 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:32:47 +0800 Subject: [PATCH 09/11] fix(security): always set cookie Secure=true to satisfy CodeQL Remove dynamic isSecureRequest logic and always pass Secure=true to SetCookie. Browsers treat localhost as a secure context so local dev on http://localhost still works. This eliminates CodeQL alert #93 (cookie-secure-not-set) and removes the now-unused TrustProxy config. --- .env.example | 5 ----- backend/internal/config/config.go | 16 ---------------- backend/internal/handler/auth.go | 25 +++++++------------------ docs/configuration.md | 8 -------- 4 files changed, 7 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index d7ea1a43..683eb5c1 100644 --- a/.env.example +++ b/.env.example @@ -30,11 +30,6 @@ FIRECRAWL_API_KEY= # ==================== Server ==================== PORT=8000 -# ==================== Proxy ==================== -# Set to true when running behind a reverse proxy (Nginx, Cloudflare, etc.) -# that sets the X-Forwarded-Proto header. Required for correct Secure cookie flag. -TRUST_PROXY=false - # ==================== Frontend ==================== # Backend API URL (defaults to http://localhost:8000, leave empty for Nginx proxy in production) VITE_API_BASE_URL=http://localhost:8000 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2a4bcac4..05548ce9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,9 +33,6 @@ type Config struct { // CORS CORSOrigins string - // Proxy - TrustProxy bool - // Logging DBLogLevel string @@ -63,7 +60,6 @@ func Load() *Config { ImageModel: getEnv("IMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo"), Port: getEnv("PORT", "8000"), CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:8000,http://localhost:3000"), - TrustProxy: getEnvBool("TRUST_PROXY", false), DBLogLevel: getEnv("DB_LOG_LEVEL", "warn"), AdminUsername: getEnv("ADMIN_USERNAME", ""), AdminEmail: getEnv("ADMIN_EMAIL", ""), @@ -95,15 +91,3 @@ func getEnvInt(key string, fallback int) int { } return i } - -func getEnvBool(key string, fallback bool) bool { - v := os.Getenv(key) - if v == "" { - return fallback - } - b, err := strconv.ParseBool(v) - if err != nil { - return fallback - } - return b -} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 4932f230..1c455d4a 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -26,30 +26,19 @@ func NewAuthHandler(authService *service.AuthService, cfg *config.Config) *AuthH return &AuthHandler{authService: authService, cfg: cfg} } -// isSecureRequest determines whether the current HTTP request should be treated -// as secure for setting the Secure flag on cookies. It checks actual TLS first, -// then falls back to X-Forwarded-Proto only when cfg.TrustProxy is enabled -// (i.e. the app runs behind a known reverse proxy that sets this header). -func (h *AuthHandler) isSecureRequest(c *gin.Context) bool { - if c.Request.TLS != nil { - return true - } - if h.cfg.TrustProxy { - return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") - } - return false -} - +// setRefreshCookie sets the refresh token as an httpOnly, Secure, SameSite=Lax +// cookie. The Secure flag is always true so the cookie is only sent over HTTPS. +// Browsers treat localhost as a secure context, so this works for local dev on +// http://localhost. For non-localhost plain HTTP (e.g. http://192.168.x.x) +// the cookie will not be sent back — use HTTPS or localhost in that case. func (h *AuthHandler) setRefreshCookie(c *gin.Context, refreshToken string, maxAge int) { - secure := h.isSecureRequest(c) c.SetSameSite(http.SameSiteLaxMode) - c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", secure, true) + c.SetCookie(refreshTokenCookie, refreshToken, maxAge, refreshCookiePath, "", true, true) } func (h *AuthHandler) clearRefreshCookie(c *gin.Context) { - secure := h.isSecureRequest(c) c.SetSameSite(http.SameSiteLaxMode) - c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", secure, true) + c.SetCookie(refreshTokenCookie, "", -1, refreshCookiePath, "", true, true) } // POST /api/v1/auth/register diff --git a/docs/configuration.md b/docs/configuration.md index ab94ec32..5070b2eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,14 +39,6 @@ Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.). When set, AI replies use web search to reduce hallucinations for factual questions. -## Proxy - -| Variable | Description | Required | Default | -|----------|-------------|----------|---------| -| `TRUST_PROXY` | Trust `X-Forwarded-Proto` header for Secure cookie flag | No | `false` | - -Set to `true` when running behind a reverse proxy (Nginx, Cloudflare, etc.) that sets the `X-Forwarded-Proto` header. Required for correct `Secure` cookie flag in HTTPS deployments. - ## Server | Variable | Description | Required | Default | From b8226ac0a004015a4f38bd8e74dcb94b5184eac9 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:55:26 +0800 Subject: [PATCH 10/11] fix(frontend): guard crypto fallback and restore logout auth check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add typeof check for crypto.getRandomValues before calling it; add last-resort Math.random fallback for envs without Web Crypto - Restore token guard in logout() since /logout requires AuthRequired middleware — unauthenticated calls would just get 401 --- frontend/src/components/overlay/ChatPanel.vue | 10 +++++++--- frontend/src/stores/auth.ts | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index eda189ff..2a7c8f46 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -112,9 +112,13 @@ function generateSessionId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } - const bytes = new Uint8Array(16) - crypto.getRandomValues(bytes) - return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + } + // Last resort: non-crypto fallback for environments without Web Crypto API + return Date.now().toString(36) + Math.random().toString(36).slice(2) } function syncPersistent() { diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 75e41cc7..98df53da 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -66,9 +66,11 @@ export const useAuthStore = defineStore("auth", () => { } function logout() { - // Always call logout so the server clears the httpOnly refresh cookie, - // even if we don't currently have an access token in memory. - apiLogout(accessToken.value ?? undefined).catch(() => {}); + // The /logout endpoint requires authentication, so only call the server + // when we have a token. The server clears the httpOnly refresh cookie. + if (accessToken.value) { + apiLogout(accessToken.value).catch(() => {}); + } user.value = null; accessToken.value = null; isGuest.value = false; From 3762018a261df2e7842400ea90629a8b2cfe927d Mon Sep 17 00:00:00 2001 From: arthurcai Date: Tue, 10 Mar 2026 18:55:58 +0800 Subject: [PATCH 11/11] docs: fix rate limiting description from sliding-window to fixed-window The actual ratelimit.go implementation uses a counter that resets after each window duration, which is fixed-window, not sliding-window. Also correct CHANGELOG entry for session ID generation to reflect the crypto.getRandomValues fallback. --- CHANGELOG.md | 4 ++-- docs/architecture.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c1c0e1..f26354cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Security Hardening - **httpOnly cookie authentication**: Migrated auth tokens from localStorage to httpOnly cookies, eliminating XSS token theft vectors -- **Sliding window rate limiting**: Added per-endpoint rate limiting to all API routes to prevent abuse +- **Fixed-window rate limiting**: Added per-endpoint rate limiting to all API routes to prevent abuse - **Input validation hardening**: Strengthened validation across all user-facing endpoints - **SQL injection prevention**: Added allowlist validation for ORDER BY clauses in repository layer (defense-in-depth for `question.go` and `answer.go`) -- **Insecure randomness fix**: Removed `Math.random()` fallback for session ID generation in `ChatPanel.vue`; now uses `crypto.randomUUID()` exclusively +- **Insecure randomness fix**: Replaced `Math.random()` fallback with `crypto.getRandomValues()` for cryptographically secure session ID generation in `ChatPanel.vue` - **Auth token extraction hardening**: Improved multi-source token extraction security (header, cookie) - **OpenAI error sanitization**: Removed response body from OpenAI error messages to prevent data exposure diff --git a/docs/architecture.md b/docs/architecture.md index 39806896..850d9553 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ The admin panel is a single HTML file (`internal/admin/admin.html`) using Tailwi - JWT access tokens (30 min) + refresh tokens (7 days), stored in httpOnly cookies - Tokens extracted from `Authorization: Bearer`, `X-Access-Token` header, or `access_token` cookie - Admin role verified per-request in handler via `authService.GetUserByID` -- Sliding window rate limiting on all API endpoints +- Fixed-window rate limiting on all API endpoints ### Content Moderation