GitHub Security Advisory: GHSA-fpw6-hrg5-q5x5
Package: github.com/lin-snow/Ech0 (GO)
Severity: HIGH (CVSS 7.4)
CWE: CWE-613: Insufficient Session Expiration, CWE-755: Improper Handling of Exceptional Conditions
Vulnerable versions: < 1.4.8-0[PII_REDACTED]1146-eab62379c795
Patched in: 1.4.8-0[PII_REDACTED]1146-eab62379c795

Summary: ech0's acess tokens with expiry=never cannot be revoked: logout panics, delete does not blacklist JTI

Details:
## Summary

Access tokens created with the "never expire" option have no `exp` JWT claim. Three independent revocation mechanisms fail for this token type. Logout at `internal/handler/auth/auth.go:154` and `:163` dereferences `claims.ExpiresAt.Time`, panicking on the nil field so the token never hits the blacklist. `RevokeToken` at `internal/repository/auth/auth.go:45-50` skips when `remainTTL <= 0`. The admin's "Delete token" panel action at `internal/service/setting/access_token_service.go:183-185` removes the database record but does not call `RevokeToken` to blacklist the JTI. Once a never-expire token leaks, the JWT stays cryptographically valid until the admin rotates the signing key across the entire instance.

## Details

Creation path at `internal/util/jwt/jwt.go:103-105`:

```go
// expiry = 0 表示永不过期
if expiry > 0 {
    claims.ExpiresAt = jwt.NewNumericDate(time.Now().UTC().Add(time.Duration(expiry) * time.Second))
}
```

For `NEVER_EXPIRY`, `expiry = 0` and the conditional skips. The resulting JWT has no `exp` claim. The middleware at `internal/middleware/auth.go` accepts it; the `jwt/v5` parser does not require `exp` by default.

Failure mode 1, logout panic at `internal/handler/auth/auth.go:163`:

```go
// Refresh-token revocation at line 154 (safe in practice: refresh tokens always have exp).
// Access-token revocation, same pattern, at line 163 (the bug):
if claims, err := jwtUtil.ParseToken(authHeader[7:]); err == nil && claims.ID != "" {
    remaining := time.Until(claims.ExpiresAt.Time)  // nil deref when ExpiresAt is nil
    h.authService.RevokeToken(claims.ID, remaining)
}
```

For a never-expire access token, `claims.ExpiresAt` is nil. `claims.ExpiresAt.Time` panics. Gin's Recovery middleware catches it and returns HTTP 500; the JTI never reaches `RevokeToken`. Line 154 shares the same pattern against refresh tokens, but refresh tokens are always issued with an expiry so the nil dereference does not fire there in practice.

Failure mode 2, `RevokeToken` skip at `internal/repository/auth/auth.go:45-50`:

```go
func (authRepository *AuthRepository) RevokeToken(jti string, remainTTL time.Duration) {
    if jti == "" || remainTTL <= 0 {
        return
    }
    authRepository.cache.SetWithTTL(fmt.Sprintf("%s%s", blacklistPrefix, jti), true, 1, remainTTL)
}
```

Even if the logout path were patched to handle nil `ExpiresAt`, a caller computing `remainTTL = 0` would still skip the blacklist write.

Failure mode 3, admin delete at `internal/service/setting/access_token_service.go:183-185`:

```go
return settingService.transactor.Run(ctx, func(txCtx context.Context) error {
    return settingService.settingRepository.DeleteAccessTokenByID(txCtx, id)
})
```

Deletion removes the token's metadata row from the database. No call to `RevokeToken`, no write to the JTI blacklist. The JWT continues to validate because the signature is still authentic and the middleware does not consult the metadata table.

The only way to invalidate a comprom