mirror of
https://github.com/ClusterCockpit/cc-metric-store.git
synced 2026-07-14 13:40:37 +02:00
Bound JWT token cache and document auth model
The per-handler JWT validation cache was never pruned, so a long-running process accumulated one map entry per distinct rotating token, growing without bound (a slow memory leak / DoS vector). Evict stale entries on the cache-miss path and cap the cache at maxTokenCacheSize, clearing it if eviction of expired tokens cannot free space. Also document in the README that auth verifies only the token signature and expiry (claims/roles are not enforced) and that an empty jwt-public-key disables authentication entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,16 @@ All endpoints support both trailing-slash and non-trailing-slash variants:
|
|||||||
If `jwt-public-key` is set in `config.json`, all endpoints require JWT
|
If `jwt-public-key` is set in `config.json`, all endpoints require JWT
|
||||||
authentication using an Ed25519 key (`Authorization: Bearer <token>`).
|
authentication using an Ed25519 key (`Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
> **Security note:** Authentication only verifies the token's Ed25519
|
||||||
|
> signature and its expiry — the claims (roles, user) are **not** checked.
|
||||||
|
> Any validly-signed, unexpired token therefore has full access to every
|
||||||
|
> endpoint, including the destructive `POST /api/free/` (drops buffered data)
|
||||||
|
> and the state-dumping `GET /api/debug/`. If the same key is shared with a
|
||||||
|
> cc-backend that issues lower-privilege user tokens, those tokens also unlock
|
||||||
|
> cc-metric-store. If `jwt-public-key` is left empty, **no authentication is
|
||||||
|
> performed on any endpoint** — only run in this mode on a trusted, isolated
|
||||||
|
> network.
|
||||||
|
|
||||||
## Run tests
|
## Run tests
|
||||||
|
|
||||||
Some benchmarks concurrently access the `MemoryStore`, so enabling the
|
Some benchmarks concurrently access the `MemoryStore`, so enabling the
|
||||||
@@ -135,7 +145,7 @@ The config file is a JSON document with four top-level sections.
|
|||||||
|
|
||||||
- `addr`: Address and port to listen on (default: `0.0.0.0:8082`)
|
- `addr`: Address and port to listen on (default: `0.0.0.0:8082`)
|
||||||
- `https-cert-file` / `https-key-file`: Paths to TLS certificate/key for HTTPS
|
- `https-cert-file` / `https-key-file`: Paths to TLS certificate/key for HTTPS
|
||||||
- `jwt-public-key`: Base64-encoded Ed25519 public key for JWT authentication. If empty, no auth is required.
|
- `jwt-public-key`: Base64-encoded Ed25519 public key for JWT authentication. Only the signature and expiry are verified; token claims/roles are not enforced, so any valid token has full access (including `/api/free/` and `/api/debug/`). If empty, no auth is required on any endpoint — use only on a trusted network.
|
||||||
- `user` / `group`: Drop privileges to this user/group after startup
|
- `user` / `group`: Drop privileges to this user/group after startup
|
||||||
- `backend-url`: Optional URL of a cc-backend instance used as node provider
|
- `backend-url`: Optional URL of a cc-backend instance used as node provider
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxTokenCacheSize bounds the number of validated tokens kept in memory.
|
||||||
|
// Reaching it triggers eviction of expired entries (see authHandler).
|
||||||
|
const maxTokenCacheSize = 1024
|
||||||
|
|
||||||
func authHandler(next http.Handler, publicKey ed25519.PublicKey) http.Handler {
|
func authHandler(next http.Handler, publicKey ed25519.PublicKey) http.Handler {
|
||||||
cacheLock := sync.RWMutex{}
|
cacheLock := sync.RWMutex{}
|
||||||
cache := map[string]*jwt.Token{}
|
cache := map[string]*jwt.Token{}
|
||||||
@@ -34,6 +38,13 @@ func authHandler(next http.Handler, publicKey ed25519.PublicKey) http.Handler {
|
|||||||
next.ServeHTTP(rw, r)
|
next.ServeHTTP(rw, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
|
// Cached token has since expired (or become otherwise invalid);
|
||||||
|
// drop it so the cache does not accumulate stale entries.
|
||||||
|
cacheLock.Lock()
|
||||||
|
delete(cache, rawtoken)
|
||||||
|
cacheLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// The actual token is ignored for now.
|
// The actual token is ignored for now.
|
||||||
// In case expiration and so on are specified, the Parse function
|
// In case expiration and so on are specified, the Parse function
|
||||||
@@ -52,6 +63,21 @@ func authHandler(next http.Handler, publicKey ed25519.PublicKey) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cacheLock.Lock()
|
cacheLock.Lock()
|
||||||
|
// Bound the cache: cc-backend mints short-lived, rotating tokens, so
|
||||||
|
// without an upper limit the map would grow unbounded over the
|
||||||
|
// lifetime of the process. When the cap is reached, evict any entries
|
||||||
|
// that have expired; if none have, clear the cache entirely rather
|
||||||
|
// than letting it grow without bound.
|
||||||
|
if len(cache) >= maxTokenCacheSize {
|
||||||
|
for k, t := range cache {
|
||||||
|
if t.Claims.Valid() != nil {
|
||||||
|
delete(cache, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cache) >= maxTokenCacheSize {
|
||||||
|
clear(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
cache[rawtoken] = token
|
cache[rawtoken] = token
|
||||||
cacheLock.Unlock()
|
cacheLock.Unlock()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user