diff --git a/internal/ai/receipt.go b/internal/ai/receipt.go index e4ee498..bbcbdc8 100644 --- a/internal/ai/receipt.go +++ b/internal/ai/receipt.go @@ -9,6 +9,7 @@ package ai import ( + "fmt" "os" "strings" ) @@ -72,10 +73,10 @@ func readFile(path string) ([]byte, error) { return nil, err } if info.IsDir() { - return nil, err + return nil, fmt.Errorf("readFile: %q is a directory, not a file", path) } if info.Size() > 10<<20 { - return nil, err + return nil, fmt.Errorf("readFile: %q exceeds 10 MB limit", path) } return os.ReadFile(path) } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 7745c59..759fb50 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -9,6 +9,7 @@ import ( "html/template" "log" "net/http" + "os" "strings" "sync" "time" @@ -199,13 +200,15 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) { return } - // Set the HTTP-only session cookie with a 24-hour TTL. + // Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h). + secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://") http.SetCookie(w, &http.Cookie{ Name: "session_token", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, + Secure: secure, Expires: time.Now().Add(24 * time.Hour), }) diff --git a/main.go b/main.go index a0fd924..8338ea1 100644 --- a/main.go +++ b/main.go @@ -50,9 +50,6 @@ func main() { smtpUser := os.Getenv("SMTP_USER") smtpPass := os.Getenv("SMTP_PASS") - // DeepSeek API key is read directly by the ai package. - _ = os.Getenv("DEEPSEEK_API_KEY") - // ----------------------------------------------------------------------- // Database // ----------------------------------------------------------------------- @@ -109,6 +106,25 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RealIP) + // Request body size limit on all endpoints (10 MB). + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 10<<20) + next.ServeHTTP(w, r) + }) + }) + + // Security headers. + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; script-src 'self' https://unpkg.com; style-src 'self' 'unsafe-inline'") + next.ServeHTTP(w, r) + }) + }) // PWA headers for service worker. r.Use(func(next http.Handler) http.Handler { @@ -147,9 +163,6 @@ func main() { http.ServeFile(w, r, "static/favicon.svg") })) - // Serve uploaded receipt images. - r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP) - // ---- Public routes (no auth required) ---- r.Get("/", authHandler.LandingPage) @@ -179,6 +192,9 @@ func main() { // Filing. r.Post("/events/{id}/file", fileHandler.FileEvent) + + // Storage (receipt images) — protected by auth middleware. + r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP) }) // -----------------------------------------------------------------------