Compare commits
4 Commits
fix/temp-d
...
fix/stripe
| Author | SHA1 | Date | |
|---|---|---|---|
| abb52c7db2 | |||
| c9f8b1e674 | |||
| d592249efb | |||
| cfc97eb1ff |
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -22,6 +22,15 @@ jobs:
|
|||||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||||
port: ${{ secrets.SERVER_PORT }}
|
port: ${{ secrets.SERVER_PORT }}
|
||||||
script: |
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
cd ~/terminalShop
|
cd ~/terminalShop
|
||||||
git pull
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
deployed=$(git rev-parse HEAD)
|
||||||
|
expected="${{ github.sha }}"
|
||||||
|
if [ "$deployed" != "$expected" ]; then
|
||||||
|
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
echo "deploy verified: HEAD=$deployed"
|
||||||
|
|||||||
@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
|
|||||||
## inspiration
|
## inspiration
|
||||||
|
|
||||||
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
|
||||||
|
# something
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||||
if h.webhookSecret == "" {
|
if h.webhookSecret == "" {
|
||||||
// No secret configured — log a loud warning but still process in dev.
|
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
|
||||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
// in production, so the server should never boot into this state; this is
|
||||||
|
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
|
||||||
|
// Slack handlers, which already refuse to run without their secret.
|
||||||
|
if os.Getenv("ENVIRONMENT") == "production" {
|
||||||
|
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
|
||||||
|
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Development only; warn loudly but still process so local testing works.
|
||||||
|
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
|
||||||
} else {
|
} else {
|
||||||
sig := r.Header.Get("Stripe-Signature")
|
sig := r.Header.Get("Stripe-Signature")
|
||||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||||
@@ -124,6 +134,20 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defense in depth: even behind a valid signature, refuse to mark an order
|
||||||
|
// paid unless the PaymentIntent's amount and currency match what we charged.
|
||||||
|
// Under ConvertCart these always match (Amount == order.Total at creation), so
|
||||||
|
// a mismatch means a malformed or unexpected event we should not trust.
|
||||||
|
if pi.Amount != int64(order.Total) {
|
||||||
|
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
|
||||||
|
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
|
||||||
|
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&order).Updates(map[string]any{
|
if err := tx.Model(&order).Updates(map[string]any{
|
||||||
"status": models.OrderStatusPaid,
|
"status": models.OrderStatusPaid,
|
||||||
|
|||||||
@@ -222,3 +222,38 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
|||||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
||||||
|
testDB, user := setupCartTestDB(t)
|
||||||
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
|
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
||||||
|
|
||||||
|
// PI claims 1 cent; seed order.Total is 500.
|
||||||
|
pi := map[string]any{
|
||||||
|
"id": order.StripePaymentID,
|
||||||
|
"amount": 1,
|
||||||
|
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(pi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal pi: %v", err)
|
||||||
|
}
|
||||||
|
evt := stripe.Event{
|
||||||
|
Type: "payment_intent.succeeded",
|
||||||
|
Data: &stripe.EventData{Raw: raw},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewWebhookHandler("", "")
|
||||||
|
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
var reloaded models.Order
|
||||||
|
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reloaded order: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.Status == models.OrderStatusPaid {
|
||||||
|
t.Fatalf("order marked paid despite amount mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func (c *Config) validate() error {
|
|||||||
{"JWT_SECRET", c.JWTSecret},
|
{"JWT_SECRET", c.JWTSecret},
|
||||||
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
||||||
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
||||||
|
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
|
||||||
}
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
|
|||||||
Reference in New Issue
Block a user