fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)
This commit is contained in:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -16,6 +16,7 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
|
||||
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
|
||||
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
|
||||
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
||||
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
|
||||
const EmailHasAccountErrorMessage = "This address already has an account."
|
||||
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
||||
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
||||
@@ -25,6 +26,7 @@ const InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
|
||||
const MediaQueryErrorMessage = "The media query is invalid."
|
||||
const MediaFilenameErrorMessage = "Enter a filename."
|
||||
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
|
||||
|
||||
@@ -2,6 +2,7 @@ package authtoken
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -26,8 +27,14 @@ var (
|
||||
ErrInvalid = errors.New("token is invalid")
|
||||
ErrExpired = errors.New("token is expired")
|
||||
ErrNotYetValid = errors.New("token is not yet valid")
|
||||
ErrRevoked = errors.New("token is revoked")
|
||||
)
|
||||
|
||||
type RevocationStore interface {
|
||||
Revoke(id string, expiresAt time.Time) error
|
||||
IsRevoked(id string) (bool, error)
|
||||
}
|
||||
|
||||
type Claims map[string]any
|
||||
|
||||
type header struct {
|
||||
@@ -36,8 +43,9 @@ type header struct {
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
secret []byte
|
||||
clock func() time.Time
|
||||
secret []byte
|
||||
clock func() time.Time
|
||||
revocations RevocationStore
|
||||
}
|
||||
|
||||
type Option func(*Auth)
|
||||
@@ -46,6 +54,10 @@ func WithClock(clock func() time.Time) Option {
|
||||
return func(a *Auth) { a.clock = clock }
|
||||
}
|
||||
|
||||
func WithRevocationStore(store RevocationStore) Option {
|
||||
return func(a *Auth) { a.revocations = store }
|
||||
}
|
||||
|
||||
func New(secret []byte, options ...Option) *Auth {
|
||||
auth := &Auth{secret: secret, clock: time.Now}
|
||||
for _, option := range options {
|
||||
@@ -66,6 +78,9 @@ func (a *Auth) Encode(claims map[string]any) (string, error) {
|
||||
if _, ok := payload["exp"]; !ok {
|
||||
payload["exp"] = now.Add(SessionLifetime).Unix()
|
||||
}
|
||||
if _, ok := payload["jti"]; !ok {
|
||||
payload["jti"] = rand.Text()
|
||||
}
|
||||
|
||||
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
|
||||
if err != nil {
|
||||
@@ -111,9 +126,46 @@ func (a *Auth) Decode(token string) (Claims, error) {
|
||||
if err := a.validateTimes(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.checkRevocation(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (a *Auth) Revoke(claims Claims) error {
|
||||
id, ok := claims["jti"].(string)
|
||||
if !ok || a.revocations == nil {
|
||||
return nil
|
||||
}
|
||||
expiry, present, err := numericDate(claims, "exp")
|
||||
if err != nil || !present {
|
||||
return ErrInvalid
|
||||
}
|
||||
return a.revocations.Revoke(id, expiry)
|
||||
}
|
||||
|
||||
func (a *Auth) checkRevocation(claims Claims) error {
|
||||
raw, present := claims["jti"]
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
id, ok := raw.(string)
|
||||
if !ok {
|
||||
return ErrInvalid
|
||||
}
|
||||
if a.revocations == nil {
|
||||
return nil
|
||||
}
|
||||
revoked, err := a.revocations.IsRevoked(id)
|
||||
if err != nil {
|
||||
return errors.Join(ErrInvalid, err)
|
||||
}
|
||||
if revoked {
|
||||
return ErrRevoked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Auth) validateTimes(claims Claims) error {
|
||||
now := a.clock()
|
||||
|
||||
@@ -121,7 +173,10 @@ func (a *Auth) validateTimes(claims Claims) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if present && !now.Before(expiry) {
|
||||
if !present {
|
||||
return ErrInvalid
|
||||
}
|
||||
if !now.Before(expiry) {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "jwt"
|
||||
CookieName = "jwt"
|
||||
bearerPrefix = "bearer "
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ func tokenFromHeader(r *http.Request) string {
|
||||
}
|
||||
|
||||
func tokenFromCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user