fix: security — CORS preflight origin gating, IDOR payment-existence check scoped to user, per-IP anonymous reservation cap

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 348a2e5bfc
commit 37f6723d9e
3 changed files with 15 additions and 12 deletions
+1 -1
View File
@@ -2955,7 +2955,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
var paymentExists bool var paymentExists bool
if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil { if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND EXISTS (SELECT 1 FROM bookings WHERE id = $2 AND user_id = $3))", bookingID, bookingID, userID).Scan(&paymentExists); err != nil {
log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err) log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+2 -2
View File
@@ -267,8 +267,8 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
var anonCount int var anonCount int
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM time_blockers SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1 WHERE description LIKE 'RESERVATION:anon:' || $1 || ':%' AND created_at > $2
`, tenMinutesAgo).Scan(&anonCount); err != nil { `, ipHash, tenMinutesAgo).Scan(&anonCount); err != nil {
log.Printf("Failed to check anon cap: %v", err) log.Printf("Failed to check anon cap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+11 -8
View File
@@ -472,11 +472,14 @@ func corsMiddleware(next http.Handler) http.Handler {
if origin != "" && originAllowed(origin, allowedOrigins) { if origin != "" && originAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key")
}
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
if origin == "" || !originAllowed(origin, allowedOrigins) {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
@@ -857,7 +860,7 @@ func main() {
r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods) r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods)
r.Post("/{id}/2fa/remove", user.AdminRemoveUser2FAHandler) r.With(mw.RateLimitByUser(10, time.Minute)).Post("/{id}/2fa/remove", user.AdminRemoveUser2FAHandler)
// Admin-scoped 2FA mint: the operator requests a code FOR the // Admin-scoped 2FA mint: the operator requests a code FOR the
// customer whose saved card is being charged at the till/admin // customer whose saved card is being charged at the till/admin
// payment modal. Mints keyed to the CUSTOMER so the code is // payment modal. Mints keyed to the CUSTOMER so the code is
@@ -893,15 +896,15 @@ func main() {
// Admin payment routes // Admin payment routes
r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment) r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment)
r.Post("/admin/bookings/{id}/refund", payments.AdminRefundBooking) r.With(mw.RateLimitByUser(10, time.Minute)).Post("/admin/bookings/{id}/refund", payments.AdminRefundBooking)
r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus) r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus)
r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment) r.With(mw.RateLimitByUser(10, time.Minute)).Post("/admin/payments/{payment_id}/refund", payments.RefundPayment)
// Admin gift card routes // Admin gift card routes
r.Get("/admin/gift-cards", payments.GetGiftCards) r.Get("/admin/gift-cards", payments.GetGiftCards)
r.Post("/admin/gift-cards", payments.CreateGiftCard) r.With(mw.RateLimitByUser(10, time.Minute)).Post("/admin/gift-cards", payments.CreateGiftCard)
r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard) r.With(mw.RateLimitByUser(10, time.Minute)).Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard)
r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard) r.With(mw.RateLimitByUser(10, time.Minute)).Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard)
r.Post("/admin/gift-cards/cancel", payments.AdminCancelGiftCard) r.Post("/admin/gift-cards/cancel", payments.AdminCancelGiftCard)
r.Get("/admin/gift-cards/expired-balances", payments.GetExpiredBalances) r.Get("/admin/gift-cards/expired-balances", payments.GetExpiredBalances)
r.Post("/admin/gift-cards/expired-balances/claim", payments.ClaimExpiredBalance) r.Post("/admin/gift-cards/expired-balances/claim", payments.ClaimExpiredBalance)