Repository navigation
feat: add abuse blockers #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -390,14 +390,19 @@ paths: | |
| password: | ||
| type: string | ||
| format: password | ||
| description: User's password | ||
| minLength: 12 | ||
| maxLength: 72 | ||
| description: User's password (12-72 characters) | ||
| team_name: | ||
| type: string | ||
| description: Name of the team (required unless team_invite_uuid is provided) | ||
| team_invite_uuid: | ||
| type: string | ||
| format: uuid | ||
| description: UUID for team invitation (if joining an existing team) | ||
| turnstile_token: | ||
| type: string | ||
| description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. | ||
| responses: | ||
| "200": | ||
| description: Successfully signed up | ||
|
|
@@ -424,6 +429,60 @@ paths: | |
| schema: | ||
| $ref: "#/components/schemas/Error" | ||
|
|
||
| /api/sign-in: | ||
| post: | ||
| summary: Manual sign in endpoint | ||
| requestBody: | ||
| required: true | ||
| content: | ||
| application/json: | ||
| schema: | ||
| type: object | ||
| required: | ||
| - password | ||
| properties: | ||
| email: | ||
| type: string | ||
| format: email | ||
| description: User's email address | ||
| password: | ||
| type: string | ||
| format: password | ||
| description: User's password | ||
| turnstile_token: | ||
| type: string | ||
| description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. | ||
| responses: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Document the sign-in HTTP 500 response.
Add a 🧰 Tools🪛 Checkov (3.3.13)[high] 1-1814: Ensure that the global security field has rules defined (CKV_OPENAPI_4) [high] 1-1814: Ensure that security operations is not empty. (CKV_OPENAPI_5) 🤖 Prompt for AI Agents |
||
| "200": | ||
| description: Successfully signed in | ||
| content: | ||
| application/json: | ||
| schema: | ||
| type: object | ||
| properties: | ||
| token: | ||
| type: string | ||
| description: JWT authentication token | ||
| "400": | ||
| description: Invalid input | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: "#/components/schemas/Error" | ||
| "401": | ||
| description: Invalid email or password | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: "#/components/schemas/Error" | ||
| "429": | ||
| description: Too many sign-in attempts; retry after the window elapses | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: "#/components/schemas/Error" | ||
|
|
||
| /api/forgot-password: | ||
| post: | ||
| summary: Request password reset email | ||
|
|
@@ -441,6 +500,9 @@ paths: | |
| type: string | ||
| format: email | ||
| description: User's email address | ||
| turnstile_token: | ||
| type: string | ||
| description: Cloudflare Turnstile token. Required when Turnstile is enabled server-side; ignored otherwise. | ||
| responses: | ||
| "200": | ||
| description: Success message (always returned regardless of whether email exists) | ||
|
|
@@ -482,7 +544,9 @@ paths: | |
| password: | ||
| type: string | ||
| format: password | ||
| description: New password | ||
| minLength: 12 | ||
| maxLength: 72 | ||
| description: New password (12-72 characters; 72 is bcrypt's byte ceiling) | ||
| responses: | ||
| "200": | ||
| description: Password reset successfully | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| package handlers | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "fmt" | ||
| "net/http" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/labstack/echo/v4" | ||
| "github.com/redis/go-redis/v9" | ||
| ) | ||
|
|
||
| // These throttles use a fixed 60-second-window counter; the simplest | ||
| // useful control. A request in the window increments a per-email counter that | ||
| // expires after the window elapses. | ||
| const ( | ||
| rateLimitWindow = 60 * time.Second | ||
|
|
||
| // signInMaxAttempts allows five sign-in attempts per normalized email per | ||
| // window; the sixth is rejected. This causes at most a short attacker-induced | ||
| // delay, never a permanent account lockout. | ||
| signInMaxAttempts = 5 | ||
|
|
||
| // resetEmailMaxPerWindow sends at most one password-reset email per | ||
| // normalized address per window. | ||
| resetEmailMaxPerWindow = 1 | ||
| ) | ||
|
|
||
| // normalizedEmailRateKey hashes strings.ToLower(strings.TrimSpace(email)) so raw | ||
| // email addresses are never stored in Redis keys. | ||
| func normalizedEmailRateKey(scope, email string) string { | ||
| normalized := strings.ToLower(strings.TrimSpace(email)) | ||
| sum := sha256.Sum256([]byte(normalized)) | ||
| return fmt.Sprintf("ratelimit:%s:%s", scope, hex.EncodeToString(sum[:])) | ||
| } | ||
|
|
||
| // incrementRateLimit increments the counter at key and returns the new count and | ||
| // the TTL remaining on the window. The expiry is set only on the first increment | ||
| // so the window stays anchored to the first request instead of sliding. | ||
| func incrementRateLimit(ctx context.Context, rdb *redis.Client, key string) (int64, time.Duration, error) { | ||
| count, err := rdb.Incr(ctx, key).Result() | ||
| if err != nil { | ||
| return 0, 0, err | ||
| } | ||
| if count == 1 { | ||
| if err := rdb.Expire(ctx, key, rateLimitWindow).Err(); err != nil { | ||
|
Comment on lines
+45
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Set the counter and expiry atomically. If Use a Redis Lua script or another atomic operation that performs 🤖 Prompt for AI Agents |
||
| return count, 0, err | ||
| } | ||
| return count, rateLimitWindow, nil | ||
| } | ||
| ttl, err := rdb.TTL(ctx, key).Result() | ||
| if err != nil { | ||
| return count, 0, err | ||
| } | ||
| return count, ttl, nil | ||
| } | ||
|
|
||
| // checkSignInRateLimit enforces the per-email sign-in throttle. It fails open | ||
| // (returns nil) when Redis is unavailable. The limiter short-circuits on a nil | ||
| // client, not just on command errors because Turnstile remains active. On the | ||
| // sixth attempt within the window it returns a 429 with a Retry-After header. | ||
| func (h *AuthHandler) checkSignInRateLimit(c echo.Context, email string) error { | ||
| if h.Redis == nil { | ||
| return nil | ||
| } | ||
|
|
||
| key := normalizedEmailRateKey("signin", email) | ||
| count, ttl, err := incrementRateLimit(c.Request().Context(), h.Redis, key) | ||
| if err != nil { | ||
| c.Logger().Errorf("sign-in rate limit: redis error, failing open: %v", err) | ||
| return nil | ||
| } | ||
|
|
||
| if count > signInMaxAttempts { | ||
| retryAfter := int(ttl.Seconds()) | ||
| if retryAfter < 1 { | ||
| retryAfter = 1 | ||
| } | ||
| c.Response().Header().Set("Retry-After", strconv.Itoa(retryAfter)) | ||
| return echo.NewHTTPError(http.StatusTooManyRequests, "Too many sign-in attempts. Please try again later.") | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // clearSignInRateLimit resets the sign-in counter after a successful login | ||
| func (h *AuthHandler) clearSignInRateLimit(c echo.Context, email string) { | ||
| if h.Redis == nil { | ||
| return | ||
| } | ||
| key := normalizedEmailRateKey("signin", email) | ||
| if err := h.Redis.Del(c.Request().Context(), key).Err(); err != nil { | ||
| c.Logger().Warnf("sign-in rate limit: failed to clear counter: %v", err) | ||
| } | ||
| } | ||
|
|
||
| // allowResetEmail reports whether a password-reset email may be sent to email | ||
| // during the current window. It increments the counter unconditionally (callers | ||
| // must invoke it uniformly, before any user lookup, so behavior and timing don't | ||
| // diverge by account existence) and fails open when Redis is unavailable. | ||
| func (h *AuthHandler) allowResetEmail(c echo.Context, email string) bool { | ||
| if h.Redis == nil { | ||
| return true | ||
| } | ||
|
|
||
| key := normalizedEmailRateKey("reset", email) | ||
| count, _, err := incrementRateLimit(c.Request().Context(), h.Redis, key) | ||
| if err != nil { | ||
| c.Logger().Errorf("reset-email rate limit: redis error, failing open: %v", err) | ||
| return true | ||
| } | ||
|
|
||
| return count <= resetEmailMaxPerWindow | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Represent the password maximum as a byte limit.
OpenAPI
maxLengthcounts characters, but the backend requirement is 72 UTF-8 bytes. A password with fewer than 72 multibyte characters can pass schema validation and then fail backend validation.Remove the character-based maximum or add a byte-aware contract. State that the maximum is 72 UTF-8 bytes. Regenerate both TypeScript declarations after the change.
Also applies to: 548-549
🧰 Tools
🪛 Checkov (3.3.13)
[high] 1-1814: Ensure that the global security field has rules defined
(CKV_OPENAPI_4)
[high] 1-1814: Ensure that security operations is not empty.
(CKV_OPENAPI_5)
🤖 Prompt for AI Agents