| 1 | package movement |
| 2 | |
| 3 | import ( |
| 4 | "time" |
| 5 | |
| 6 | "gorm.io/gorm" |
| 7 | ) |
| 8 | |
| 9 | // Two budgets, both persisted, because the process this runs in hibernates as |
| 10 | // its normal state and an in-memory counter forgets everything on the next |
| 11 | // wake — which is exactly when somebody attacking it would like it to. |
| 12 | // |
| 13 | // They exist because proof of work is a speed bump, not a wall: 2^18 SHA-256 |
| 14 | // is under a millisecond on a commodity GPU. What a determined bulk attack |
| 15 | // actually costs us is not junk rows, it is thousands of messages to |
| 16 | // strangers from our own verified sending identity, and then the sending |
| 17 | // domain suspended. These are the thing standing in front of that. |
| 18 | |
| 19 | const ( |
| 20 | // PerAddressPerDay bounds how often one address can be mailed. Three is |
| 21 | // room for a genuine retry or two after a transient failure, and no room |
| 22 | // for using someone's inbox as a weapon. |
| 23 | PerAddressPerDay = 3 |
| 24 | |
| 25 | // GlobalPerHour bounds everything the app sends, our own confirmations |
| 26 | // and auth's sign-in links together. |
| 27 | GlobalPerHour = 200 |
| 28 | |
| 29 | perAddressWindow = 24 * time.Hour |
| 30 | ) |
| 31 | |
| 32 | // hourBucket names the global counter's row. Fixed buckets rather than a |
| 33 | // sliding window: a burst straddling a boundary can send up to twice the |
| 34 | // ceiling within one rolling hour, which at these numbers is acceptable and |
| 35 | // is written down here rather than discovered later. |
| 36 | func hourBucket(t time.Time) string { |
| 37 | return t.UTC().Format("2006-01-02T15") |
| 38 | } |
| 39 | |
| 40 | // addressAllowed reports whether row may be mailed now. |
| 41 | // |
| 42 | // The window rolls from the last message rather than from a fixed clock |
| 43 | // boundary, so someone mailed three times in a minute waits a full day rather |
| 44 | // than until the top of the hour. |
| 45 | func addressAllowed(row *Signup, now time.Time) bool { |
| 46 | if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow { |
| 47 | return true |
| 48 | } |
| 49 | return row.MailCount < PerAddressPerDay |
| 50 | } |
| 51 | |
| 52 | // nextAddressCount returns what MailCount becomes after this send. |
| 53 | func nextAddressCount(row *Signup, now time.Time) int { |
| 54 | if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow { |
| 55 | return 1 |
| 56 | } |
| 57 | return row.MailCount + 1 |
| 58 | } |
| 59 | |
| 60 | // globalAllowed reads the hour's count and, when there is room, claims one. |
| 61 | // |
| 62 | // Read and increment are separate statements, which is safe here and only |
| 63 | // here: db.Open caps the writer pool at a single connection and this runs |
| 64 | // inside that connection's transaction, so no second writer can interleave. |
| 65 | // The increment is still expressed as count = count + 1 rather than a value |
| 66 | // computed in Go, so it stays correct if that ever stops being true. |
| 67 | func globalAllowed(tx *gorm.DB, now time.Time) (bool, error) { |
| 68 | bucket := hourBucket(now) |
| 69 | |
| 70 | var row MailCounter |
| 71 | err := tx.Where("hour_bucket = ?", bucket).First(&row).Error |
| 72 | switch { |
| 73 | case err == gorm.ErrRecordNotFound: |
| 74 | row = MailCounter{HourBucket: bucket, Count: 0} |
| 75 | case err != nil: |
| 76 | return false, err |
| 77 | } |
| 78 | |
| 79 | if row.Count >= GlobalPerHour { |
| 80 | return false, nil |
| 81 | } |
| 82 | |
| 83 | res := tx.Exec( |
| 84 | `INSERT INTO mail_counters (hour_bucket, count) VALUES (?, 1) |
| 85 | ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1`, bucket) |
| 86 | return true, res.Error |
| 87 | } |
| 88 | |