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
+176
-12
@@ -126,7 +126,9 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
|
||||
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
|
||||
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
|
||||
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
|
||||
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
|
||||
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
|
||||
"without exp": {signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`), authtoken.ErrInvalid},
|
||||
"jti not a string": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"jti":7}`), authtoken.ErrInvalid},
|
||||
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
|
||||
}
|
||||
|
||||
@@ -143,18 +145,8 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
|
||||
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
|
||||
|
||||
claims, err := auth.Decode(legacy)
|
||||
|
||||
if err != nil || claims["user_id"] != float64(4) {
|
||||
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
|
||||
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
|
||||
token := signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
|
||||
|
||||
if _, err := auth.Decode(token); err != nil {
|
||||
t.Errorf("expected acceptance, got %v", err)
|
||||
@@ -179,3 +171,175 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
|
||||
func authTokenUnix(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
type memoryRevocationStore struct {
|
||||
revoked map[string]time.Time
|
||||
failure error
|
||||
}
|
||||
|
||||
func newMemoryRevocationStore() *memoryRevocationStore {
|
||||
return &memoryRevocationStore{revoked: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (m *memoryRevocationStore) Revoke(id string, expiresAt time.Time) error {
|
||||
if m.failure != nil {
|
||||
return m.failure
|
||||
}
|
||||
m.revoked[id] = expiresAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryRevocationStore) IsRevoked(id string) (bool, error) {
|
||||
if m.failure != nil {
|
||||
return false, m.failure
|
||||
}
|
||||
_, ok := m.revoked[id]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func TestAuthTokenRevocation(t *testing.T) {
|
||||
newAuth := func(store *memoryRevocationStore) *authtoken.Auth {
|
||||
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return authTokenEpoch }), authtoken.WithRevocationStore(store))
|
||||
}
|
||||
|
||||
t.Run("Every encoded token carries a distinct jti", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
first, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
second, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
|
||||
firstClaims, _ := auth.Decode(first)
|
||||
secondClaims, _ := auth.Decode(second)
|
||||
|
||||
id, ok := firstClaims["jti"].(string)
|
||||
if !ok || id == "" {
|
||||
t.Fatalf("expected a jti, got %v", firstClaims["jti"])
|
||||
}
|
||||
if id == secondClaims["jti"] {
|
||||
t.Errorf("expected distinct jti values, both were %q", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An explicit jti is kept", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
encoded, _ := auth.Encode(map[string]any{"jti": "fixed-id"})
|
||||
|
||||
claims, _ := auth.Decode(encoded)
|
||||
|
||||
if claims["jti"] != "fixed-id" {
|
||||
t.Errorf("expected jti fixed-id, got %v", claims["jti"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A revoked token is rejected until it would have expired anyway", func(t *testing.T) {
|
||||
store := newMemoryRevocationStore()
|
||||
auth := newAuth(store)
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
claims, err := auth.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := auth.Revoke(claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrRevoked) {
|
||||
t.Errorf("expected ErrRevoked, got %v", err)
|
||||
}
|
||||
if got := store.revoked[claims["jti"].(string)]; !got.Equal(authTokenEpoch.Add(authtoken.SessionLifetime)) {
|
||||
t.Errorf("expected the revocation to last until the token's exp, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Revoking one token leaves the others valid", func(t *testing.T) {
|
||||
auth := newAuth(newMemoryRevocationStore())
|
||||
revoked, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
kept, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
claims, _ := auth.Decode(revoked)
|
||||
|
||||
_ = auth.Revoke(claims)
|
||||
|
||||
if _, err := auth.Decode(kept); err != nil {
|
||||
t.Errorf("expected the other token to stay valid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A token is rejected when the revocation store cannot answer", func(t *testing.T) {
|
||||
store := newMemoryRevocationStore()
|
||||
auth := newAuth(store)
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
store.failure = errors.New("database unavailable")
|
||||
|
||||
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrInvalid) {
|
||||
t.Errorf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Revoking surfaces a store failure", func(t *testing.T) {
|
||||
store := newMemoryRevocationStore()
|
||||
auth := newAuth(store)
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
claims, _ := auth.Decode(encoded)
|
||||
store.failure = errors.New("database unavailable")
|
||||
|
||||
if err := auth.Revoke(claims); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Revoking claims without a jti is a no-op", func(t *testing.T) {
|
||||
store := newMemoryRevocationStore()
|
||||
|
||||
if err := newAuth(store).Revoke(authtoken.Claims{"user_id": float64(1), "exp": float64(authTokenEpoch.Unix())}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.revoked) != 0 {
|
||||
t.Errorf("expected nothing revoked, got %v", store.revoked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Revoking claims without an exp is refused", func(t *testing.T) {
|
||||
if err := newAuth(newMemoryRevocationStore()).Revoke(authtoken.Claims{"jti": "abc"}); !errors.Is(err, authtoken.ErrInvalid) {
|
||||
t.Errorf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Without a store, revoking is a no-op and tokens stay valid", func(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
||||
claims, _ := auth.Decode(encoded)
|
||||
|
||||
if err := auth.Revoke(claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auth.Decode(encoded); err != nil {
|
||||
t.Errorf("expected the token to stay valid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthTokenSizeBudget(t *testing.T) {
|
||||
auth := authTokenAt(authTokenEpoch)
|
||||
sized := func(target int) string {
|
||||
for padding := 0; padding < 4096; padding++ {
|
||||
encoded, _ := auth.Encode(map[string]any{"jti": "fixed", "pad": strings.Repeat("a", padding)})
|
||||
if len(encoded) == target {
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
t.Fatalf("no token of exactly %d bytes", target)
|
||||
return ""
|
||||
}
|
||||
|
||||
t.Run("A token of exactly 4096 bytes is accepted", func(t *testing.T) {
|
||||
if _, err := auth.Decode(sized(4096)); err != nil {
|
||||
t.Errorf("expected acceptance, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A token of 4097 bytes is refused before it is parsed", func(t *testing.T) {
|
||||
if _, err := auth.Decode(sized(4097)); !errors.Is(err, authtoken.ErrInvalid) {
|
||||
t.Errorf("expected ErrInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user