fix: resolve 7 critical security findings from code review
CR-1: Path traversal in createReceiptZip — validate image_path is within storage/ CR-2: Missing authz on EditExpense/UpdateExpense — verify event ownership CR-3: OTP timing side-channel — use crypto/subtle.ConstantTimeCompare CR-4: Logout doesn't invalidate session — moved to AuthHandler with Sessions.Delete() CR-5: OTP reuse race condition — mutex lock around validate+delete CR-6: Live credentials on disk — removed .env from disk entirely CR-7: No TLS — documented as expected behind-proxy deployment Additional: - Removed stale github.com/expenseflow import path from auth.go - Made EnvironmentFile optional (prefix with -) so .env is not required - App runs and starts clean without any .env file
This commit is contained in:
parent
2f26abfb2f
commit
e831fcf617
7 changed files with 58 additions and 21 deletions
|
|
@ -11,7 +11,7 @@ WorkingDirectory=/opt/receiptnext
|
||||||
ExecStart=/opt/receiptnext/app
|
ExecStart=/opt/receiptnext/app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
EnvironmentFile=/opt/receiptnext/.env
|
EnvironmentFile=-/opt/receiptnext/.env
|
||||||
StandardOutput=append:/var/log/receiptnext.log
|
StandardOutput=append:/var/log/receiptnext.log
|
||||||
StandardError=append:/var/log/receiptnext.log
|
StandardError=append:/var/log/receiptnext.log
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,7 @@ WorkingDirectory=${INSTALL_DIR}
|
||||||
ExecStart=${INSTALL_DIR}/app
|
ExecStart=${INSTALL_DIR}/app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
EnvironmentFile=${INSTALL_DIR}/.env
|
EnvironmentFile=-${INSTALL_DIR}/.env
|
||||||
StandardOutput=append:/var/log/receiptnext.log
|
StandardOutput=append:/var/log/receiptnext.log
|
||||||
StandardError=append:/var/log/receiptnext.log
|
StandardError=append:/var/log/receiptnext.log
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -33,7 +34,8 @@ func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
|
||||||
if time.Now().After(expiresAt) {
|
if time.Now().After(expiresAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return provided == stored
|
// Use constant-time comparison to prevent timing side-channel attacks.
|
||||||
|
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// attemptData stores the failure count and timestamp for a single email.
|
// attemptData stores the failure count and timestamp for a single email.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cclohmar/ReceiptNext/internal/auth"
|
"github.com/cclohmar/ReceiptNext/internal/auth"
|
||||||
|
|
@ -35,6 +36,8 @@ type AuthHandler struct {
|
||||||
Sessions *auth.SessionStore
|
Sessions *auth.SessionStore
|
||||||
FailureTracker *auth.FailureTracker
|
FailureTracker *auth.FailureTracker
|
||||||
EmailSender *email.Sender
|
EmailSender *email.Sender
|
||||||
|
|
||||||
|
otpMu sync.Mutex // prevents OTP reuse via race conditions
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -164,19 +167,21 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the OTP code and expiry.
|
// Validate + delete OTP atomically to prevent race-condition reuse.
|
||||||
|
h.otpMu.Lock()
|
||||||
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
|
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
|
||||||
|
h.otpMu.Unlock()
|
||||||
h.FailureTracker.RecordFailure(emailAddr)
|
h.FailureTracker.RecordFailure(emailAddr)
|
||||||
renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.")
|
renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful verification: clean up and create session.
|
// Successful verification: delete OTP immediately (still under lock).
|
||||||
h.FailureTracker.Reset(emailAddr)
|
h.FailureTracker.Reset(emailAddr)
|
||||||
if err := database.DeleteOTP(h.DB, emailAddr); err != nil {
|
if err := database.DeleteOTP(h.DB, emailAddr); err != nil {
|
||||||
log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
// Non-fatal — the OTP is already validated.
|
|
||||||
}
|
}
|
||||||
|
h.otpMu.Unlock()
|
||||||
|
|
||||||
// Retrieve the user record to obtain the user ID.
|
// Retrieve the user record to obtain the user ID.
|
||||||
user, err := database.GetUserByEmail(h.DB, emailAddr)
|
user, err := database.GetUserByEmail(h.DB, emailAddr)
|
||||||
|
|
@ -292,6 +297,25 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
|
||||||
// collectOTP reads the 6 individual digit form values and concatenates them
|
// collectOTP reads the 6 individual digit form values and concatenates them
|
||||||
// into a single 6-character OTP code string. Returns an empty string if any
|
// into a single 6-character OTP code string. Returns an empty string if any
|
||||||
// digit is missing.
|
// digit is missing.
|
||||||
|
// Logout clears the session cookie and invalidates the server-side session.
|
||||||
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Invalidate the server-side session.
|
||||||
|
if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" {
|
||||||
|
h.Sessions.Delete(cookie.Value)
|
||||||
|
}
|
||||||
|
// Clear the cookie on the client side.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func collectOTP(r *http.Request) string {
|
func collectOTP(r *http.Request) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,13 @@ func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify ownership: the expense's event must belong to the current user.
|
||||||
|
event, err := database.GetEventByID(h.DB, expense.EventID)
|
||||||
|
if err != nil || event == nil || event.UserID != getUserID(r) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR [%s] handlers: EditExpense: parse template: %v",
|
log.Printf("ERROR [%s] handlers: EditExpense: parse template: %v",
|
||||||
|
|
@ -426,6 +433,13 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify ownership: the expense's event must belong to the current user.
|
||||||
|
event, err := database.GetEventByID(h.DB, existing.EventID)
|
||||||
|
if err != nil || event == nil || event.UserID != getUserID(r) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
expense := database.Expense{
|
expense := database.Expense{
|
||||||
ID: expenseID,
|
ID: expenseID,
|
||||||
EventID: existing.EventID,
|
EventID: existing.EventID,
|
||||||
|
|
|
||||||
|
|
@ -364,11 +364,19 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal — only allow files within the storage directory.
|
||||||
|
cleanPath := filepath.Clean(exp.ImagePath)
|
||||||
|
if !strings.HasPrefix(cleanPath, "storage") && !strings.HasPrefix(cleanPath, "./storage") {
|
||||||
|
log.Printf("WARN [%s] handlers: createReceiptZip: blocked path traversal attempt: %q",
|
||||||
|
time.Now().Format(time.RFC3339), exp.ImagePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Read the image file from disk.
|
// Read the image file from disk.
|
||||||
data, err := os.ReadFile(exp.ImagePath)
|
data, err := os.ReadFile(cleanPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
|
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
|
||||||
time.Now().Format(time.RFC3339), exp.ImagePath, err)
|
time.Now().Format(time.RFC3339), cleanPath, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
main.go
15
main.go
|
|
@ -156,20 +156,9 @@ func main() {
|
||||||
r.Post("/request-otp", authHandler.RequestOTP)
|
r.Post("/request-otp", authHandler.RequestOTP)
|
||||||
r.Post("/verify-otp", authHandler.VerifyOTP)
|
r.Post("/verify-otp", authHandler.VerifyOTP)
|
||||||
|
|
||||||
// ---- Logout ----
|
// ---- Logout (invalidates server-side session + clears cookie) ----
|
||||||
|
|
||||||
r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
r.Post("/logout", authHandler.Logout)
|
||||||
// Delete the session cookie.
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "session_token",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
w.Header().Set("HX-Redirect", "/")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ---- Protected routes (auth required) ----
|
// ---- Protected routes (auth required) ----
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue