D write-only lockdown (DEC-654): public HTTP server needs the caller's own Verae key/OAuth bearer for stamp (any scope), signup, create_user, session; login refused; no host-login fallback for writes; anonymous verify/hash_report kept, rate limited 30/min per client (burst 15), 600/min overall
This commit is contained in:
parent
a2495649ba
commit
10c8634a53
1 changed files with 427 additions and 5 deletions
174
internal/tools/public.go
Normal file
174
internal/tools/public.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package tools
|
||||
|
||||
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) every write needs the caller's own credential - a Verae API
|
||||
// key or a VeraeTime login/OAuth bearer, checked at api.veraetime.net - and never falls back to this host's stored
|
||||
// service login. Writes: stamp (any scope; global stamps are billed to the key's customer), signup, create_user,
|
||||
// session; login (it would replace the host credential) is refused. Read-only verify and hash_report stay
|
||||
// anonymous for the public pages (receipt links, explorer); only those use the host login, rate limited per client.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// ErrWriteNeedsKey is returned for an anonymous write on the public server.
|
||||
var ErrWriteNeedsKey = errors.New("this tool needs your own Verae credential: send Authorization: Bearer <Verae API key> " +
|
||||
"(create one at https://zap.verae.ai/portal/ or run `verae login`)")
|
||||
|
||||
// ErrLoginDisabled is returned by the login tool on the public server.
|
||||
var ErrLoginDisabled = errors.New("login is not available on the public server; sign in at https://zap.verae.ai/portal/ " +
|
||||
"(or `verae login`) and send the Verae API key as a bearer token")
|
||||
|
||||
// ErrRateLimited is returned when anonymous reads exceed the per-client budget.
|
||||
var ErrRateLimited = errors.New("too many anonymous lookups; retry in a minute or send a Verae API key")
|
||||
|
||||
// CredentialChecker validates a caller bearer (implemented by veraetime.Client.CheckCredential).
|
||||
type CredentialChecker interface {
|
||||
CheckCredential(ctx context.Context, bearer string) error
|
||||
}
|
||||
|
||||
// writeBearer returns the caller's own validated bearer; on the public server there is no host fallback.
|
||||
func (d *Deps) writeBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||
if !d.Public {
|
||||
return d.resolveBearer(ctx, req)
|
||||
}
|
||||
tok := BearerFromRequest(req)
|
||||
if tok == "" {
|
||||
return "", ErrWriteNeedsKey
|
||||
}
|
||||
if err := d.checkCred(ctx, tok); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// readBearer is the caller's bearer, or (anonymous, rate limited) the host login for read-only lookups.
|
||||
func (d *Deps) readBearer(ctx context.Context, req *mcp.CallToolRequest) (string, error) {
|
||||
if d.Public && BearerFromRequest(req) == "" {
|
||||
if d.Limiter != nil && !d.Limiter.Allow(clientKey(req)) {
|
||||
return "", ErrRateLimited
|
||||
}
|
||||
}
|
||||
return d.resolveBearer(ctx, req)
|
||||
}
|
||||
|
||||
type credEntry struct {
|
||||
ok bool
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
credMu sync.Mutex
|
||||
credCache = map[[32]byte]credEntry{}
|
||||
)
|
||||
|
||||
func (d *Deps) checkCred(ctx context.Context, tok string) error {
|
||||
if d.Checker == nil {
|
||||
return ErrWriteNeedsKey // fail closed
|
||||
}
|
||||
h := sha256.Sum256([]byte(tok))
|
||||
credMu.Lock()
|
||||
e, hit := credCache[h]
|
||||
credMu.Unlock()
|
||||
if hit && time.Now().Before(e.exp) {
|
||||
if e.ok {
|
||||
return nil
|
||||
}
|
||||
return errors.New("the Verae credential was not accepted")
|
||||
}
|
||||
err := d.Checker.CheckCredential(ctx, tok)
|
||||
var bad *veraetime.CredentialRejected
|
||||
if err != nil && !errors.As(err, &bad) {
|
||||
return err // upstream unavailable: not cached, fail closed
|
||||
}
|
||||
credMu.Lock()
|
||||
if len(credCache) > 10000 {
|
||||
credCache = map[[32]byte]credEntry{}
|
||||
}
|
||||
credCache[h] = credEntry{ok: err == nil, exp: time.Now().Add(60 * time.Second)}
|
||||
credMu.Unlock()
|
||||
if err != nil {
|
||||
return errors.New("the Verae credential was not accepted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientKey is the client address Caddy put last in X-Forwarded-For (Caddy overwrites untrusted values).
|
||||
func clientKey(req *mcp.CallToolRequest) string {
|
||||
if req == nil || req.Extra == nil || req.Extra.Header == nil {
|
||||
return "unknown"
|
||||
}
|
||||
hd := http.Header(req.Extra.Header)
|
||||
if xff := hd.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
if ip := strings.TrimSpace(hd.Get("X-Real-IP")); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Limiter is a per-client token bucket plus a global cap for anonymous reads.
|
||||
type Limiter struct {
|
||||
PerMinute float64 // per client
|
||||
Burst float64
|
||||
GlobalPerMin float64
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
global bucket
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// NewLimiter: 30/min per client (burst 15), 600/min overall.
|
||||
func NewLimiter() *Limiter {
|
||||
return &Limiter{PerMinute: 30, Burst: 15, GlobalPerMin: 600, buckets: map[string]*bucket{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (l *Limiter) take(b *bucket, perMin, burst float64, now time.Time) bool {
|
||||
if b.last.IsZero() {
|
||||
b.tokens, b.last = burst, now
|
||||
}
|
||||
b.tokens += now.Sub(b.last).Minutes() * perMin
|
||||
if b.tokens > burst {
|
||||
b.tokens = burst
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow reports whether client may make one more anonymous read now.
|
||||
func (l *Limiter) Allow(client string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
if len(l.buckets) > 50000 {
|
||||
l.buckets = map[string]*bucket{}
|
||||
}
|
||||
b := l.buckets[client]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
l.buckets[client] = b
|
||||
}
|
||||
if !l.take(b, l.PerMinute, l.Burst, now) {
|
||||
return false
|
||||
}
|
||||
return l.take(&l.global, l.GlobalPerMin, l.GlobalPerMin, now)
|
||||
}
|
||||
Loading…
Reference in a new issue