@@ -47,6 +47,7 @@ import ( |
| 47 | 47 | "github.com/tenseleyFlow/shithub/internal/auth/token" |
| 48 | 48 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 49 | 49 | "github.com/tenseleyFlow/shithub/internal/passwords" |
| 50 | + "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 50 | 51 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 51 | 52 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 52 | 53 | "github.com/tenseleyFlow/shithub/internal/web/render" |
@@ -58,14 +59,19 @@ var usernameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`) |
| 58 | 59 | // Deps is everything the auth handlers need. Constructed by the web |
| 59 | 60 | // package and injected at registration time. |
| 60 | 61 | type Deps struct { |
| 61 | | - Logger *slog.Logger |
| 62 | | - Render *render.Renderer |
| 63 | | - Pool *pgxpool.Pool |
| 64 | | - SessionStore session.Store |
| 65 | | - Email email.Sender |
| 66 | | - Branding email.Branding |
| 67 | | - Argon2 password.Params |
| 68 | | - Limiter *throttle.Limiter |
| 62 | + Logger *slog.Logger |
| 63 | + Render *render.Renderer |
| 64 | + Pool *pgxpool.Pool |
| 65 | + SessionStore session.Store |
| 66 | + Email email.Sender |
| 67 | + Branding email.Branding |
| 68 | + Argon2 password.Params |
| 69 | + Limiter *throttle.Limiter |
| 70 | + // RateLimiter wraps the S35 generalised rate-limit table. Used |
| 71 | + // for the per-/24 signup throttle (anti-abuse heuristic). nil |
| 72 | + // disables the secondary throttle; the per-IP S05 limiter still |
| 73 | + // applies. |
| 74 | + RateLimiter *ratelimit.Limiter |
| 69 | 75 | RequireEmailVerification bool |
| 70 | 76 | // SecretBox encrypts at-rest TOTP secrets. May be nil; when nil, the |
| 71 | 77 | // 2FA enrollment endpoints are not registered. |
@@ -770,12 +776,28 @@ func (h *Handlers) renderPage(w http.ResponseWriter, r *http.Request, page strin |
| 770 | 776 | } |
| 771 | 777 | |
| 772 | 778 | func (h *Handlers) throttleSignup(r *http.Request) error { |
| 779 | + // Layer 1 — per-IP cap (S05). Tight per-host throttle so a single |
| 780 | + // machine can't spin up dozens of accounts in an hour. |
| 773 | 781 | if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{ |
| 774 | 782 | Scope: "signup", Identifier: "ip:" + clientIP(r), |
| 775 | 783 | Max: 5, Window: time.Hour, |
| 776 | 784 | }); err != nil { |
| 777 | 785 | return err |
| 778 | 786 | } |
| 787 | + // Layer 2 — per-/24 cap (S35 anti-abuse). Catches spray-from-many- |
| 788 | + // IPs-on-the-same-network patterns. The threshold is intentionally |
| 789 | + // looser (20/hour) than the per-IP cap so legitimate shared-NAT |
| 790 | + // users (universities, corporate networks) aren't false-positives. |
| 791 | + // nil RateLimiter skips this layer — the single-IP throttle still |
| 792 | + // applies. |
| 793 | + if h.d.RateLimiter != nil { |
| 794 | + if ip, ok := ratelimit.ClientIP(r, false); ok { |
| 795 | + d, err := h.d.RateLimiter.AllowSignupIP(r.Context(), ip, 20, time.Hour) |
| 796 | + if err == nil && !d.Allowed { |
| 797 | + return &throttle.ErrThrottled{RetryAfter: d.RetryAfter, Hits: d.Limit + 1} |
| 798 | + } |
| 799 | + } |
| 800 | + } |
| 779 | 801 | return nil |
| 780 | 802 | } |
| 781 | 803 | |