Files
minstrel/internal/apierror/apierror.go
T
bvandeusenandClaude Opus 5.5 3bfddd0862
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:28:29 -04:00

78 lines
2.0 KiB
Go

package apierror
import (
"errors"
"fmt"
)
type Error struct {
Status int
Code string
Message string
Cause error
}
func (e *Error) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
func (e *Error) Unwrap() error { return e.Cause }
// From returns err as *Error if it (or anything in its chain) is one;
// otherwise wraps err in an Internal.
func From(err error) *Error {
if err == nil {
return nil
}
var apiErr *Error
if errors.As(err, &apiErr) {
return apiErr
}
return Internal(err)
}
func NotFound(what string) *Error {
return &Error{Status: 404, Code: what + "_not_found", Message: what + " not found"}
}
func BadRequest(code, message string) *Error {
return &Error{Status: 400, Code: code, Message: message}
}
func Conflict(code, message string) *Error {
return &Error{Status: 409, Code: code, Message: message}
}
func Forbidden(code, message string) *Error {
return &Error{Status: 403, Code: code, Message: message}
}
func Unauthorized(code, message string) *Error {
return &Error{Status: 401, Code: code, Message: message}
}
func TooManyRequests(code, message string) *Error {
return &Error{Status: 429, Code: code, Message: message}
}
func Internal(cause error) *Error {
return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause}
}
// InternalMsg returns a 500 Error with a custom user-facing message
// and a cause for logging. Use when the call site has a meaningful
// message that should appear on the wire (e.g. "lookup failed") rather
// than the generic "internal server error" from Internal.
func InternalMsg(message string, cause error) *Error {
return &Error{Status: 500, Code: "server_error", Message: message, Cause: cause}
}
var (
ErrNotFound = &Error{Status: 404, Code: "not_found", Message: "not found"}
ErrForbidden = &Error{Status: 403, Code: "forbidden", Message: "forbidden"}
ErrUnauthorized = &Error{Status: 401, Code: "unauthorized", Message: "authentication required"}
)