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
199
internal/server/public_test.go
Normal file
199
internal/server/public_test.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
|
||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// fakeVT records the bearer of every upstream call. "good-key" is a valid credential; the host login is "host-jwt".
|
||||
type fakeVT struct {
|
||||
srv *httptest.Server
|
||||
stamps atomic.Int32
|
||||
hostUse atomic.Int32
|
||||
users atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeVT(t *testing.T) *fakeVT {
|
||||
f := &fakeVT{}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "Bearer host-jwt" {
|
||||
f.hostUse.Add(1)
|
||||
}
|
||||
ok := auth == "Bearer good-key" || auth == "Bearer host-jwt"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/auth/login"):
|
||||
json.NewEncoder(w).Encode(map[string]any{"token": "host-jwt", "username": "svc", "role": "admin", "expires_at": "2099-01-01T00:00:00Z"})
|
||||
case !ok:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"})
|
||||
case r.URL.Path == "/api/status/mcp-credential-check":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "job not found"})
|
||||
case r.URL.Path == "/api/timestamp":
|
||||
f.stamps.Add(1)
|
||||
json.NewEncoder(w).Encode(map[string]any{"job_id": "j1", "status": "completed", "certificate": "QmTestCid", "merkle_root": "ab", "created_at": "2026-10-10T00:00:00Z"})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/status/"):
|
||||
json.NewEncoder(w).Encode(map[string]any{"job_id": "j1", "status": "completed", "certificate": "QmTestCid", "merkle_root": "ab"})
|
||||
case r.URL.Path == "/api/verify":
|
||||
json.NewEncoder(w).Encode(map[string]any{"valid": true, "digest": strings.Repeat("a", 64)})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/hash/"):
|
||||
json.NewEncoder(w).Encode(map[string]any{"receipts": []any{}})
|
||||
case r.URL.Path == "/auth/validate":
|
||||
json.NewEncoder(w).Encode(map[string]any{"valid": true, "username": "who", "role": "user"})
|
||||
case r.URL.Path == "/auth/users", r.URL.Path == "/auth/signup":
|
||||
f.users.Add(1)
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": "u1", "username": "x", "role": "user"})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func publicSession(t *testing.T, vt *fakeVT, bearer, xff string) *mcp.ClientSession {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
led, err := ledger.Open(filepath.Join(dir, "ledger.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authPath := filepath.Join(dir, "auth.properties")
|
||||
if err := os.WriteFile(authPath, []byte("username=svc\npassword=pw\ntoken=host-jwt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VERAE_GLOBAL_TOKEN", "host-jwt")
|
||||
srv, err := server.New(server.Options{Ledger: led, AuthPath: authPath, GlobalURL: vt.srv.URL, JSONResponse: true, Public: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := srv.Handler()
|
||||
hs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bearer != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", xff)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(hs.Close)
|
||||
c := mcp.NewClient(&mcp.Implementation{Name: "t", Version: "0"}, nil)
|
||||
s, err := c.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) (bool, string) {
|
||||
t.Helper()
|
||||
res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
return !res.IsError, string(b)
|
||||
}
|
||||
|
||||
var dig = strings.Repeat("a", 64)
|
||||
|
||||
func TestPublicAnonymousWritesRefusedNoHostFallback(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "", "203.0.113.5")
|
||||
for _, c := range []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{"stamp", map[string]any{"sha256": dig, "scope": "global"}},
|
||||
{"stamp", map[string]any{"sha256": dig}}, // local ledger on the server is a write too
|
||||
{"create_user", map[string]any{"username": "evil", "password": "pw123456", "role": "admin"}},
|
||||
{"signup", map[string]any{"username": "spam", "password": "pw123456"}},
|
||||
{"login", map[string]any{"username": "admin", "password": "x"}},
|
||||
} {
|
||||
if ok, out := call(t, s, c.tool, c.args); ok {
|
||||
t.Fatalf("%s anonymous succeeded: %s", c.tool, out)
|
||||
}
|
||||
}
|
||||
ok, out := call(t, s, "session", map[string]any{})
|
||||
if !ok || strings.Contains(out, `"valid":true`) {
|
||||
t.Fatalf("anonymous session must not report the host login: %v %s", ok, out)
|
||||
}
|
||||
if vt.stamps.Load() != 0 || vt.users.Load() != 0 || vt.hostUse.Load() != 0 {
|
||||
t.Fatalf("upstream writes or host-token use: stamps=%d users=%d host=%d", vt.stamps.Load(), vt.users.Load(), vt.hostUse.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBadKeyRefused(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "xVerae-wrong", "203.0.113.6")
|
||||
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig, "scope": "global"}); ok {
|
||||
t.Fatalf("bad key stamped: %s", out)
|
||||
}
|
||||
if ok, _ := call(t, s, "stamp", map[string]any{"sha256": dig}); ok {
|
||||
t.Fatal("bad key wrote the local ledger")
|
||||
}
|
||||
if vt.stamps.Load() != 0 || vt.hostUse.Load() != 0 {
|
||||
t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyedWritesUseCallerCredential(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "good-key", "203.0.113.7")
|
||||
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig, "scope": "global"}); !ok {
|
||||
t.Fatalf("keyed global stamp: %s", out)
|
||||
}
|
||||
if ok, out := call(t, s, "stamp", map[string]any{"sha256": dig}); !ok {
|
||||
t.Fatalf("keyed local stamp: %s", out)
|
||||
}
|
||||
if ok, out := call(t, s, "session", map[string]any{}); !ok || !strings.Contains(out, `"valid":true`) {
|
||||
t.Fatalf("keyed session: %s", out)
|
||||
}
|
||||
if ok, _ := call(t, s, "login", map[string]any{"username": "a", "password": "b"}); ok {
|
||||
t.Fatal("login must stay disabled on the public server")
|
||||
}
|
||||
if vt.stamps.Load() != 1 || vt.hostUse.Load() != 0 {
|
||||
t.Fatalf("stamps=%d host=%d", vt.stamps.Load(), vt.hostUse.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAnonymousReadsAllowedAndRateLimited(t *testing.T) {
|
||||
vt := newFakeVT(t)
|
||||
s := publicSession(t, vt, "", "198.51.100.9")
|
||||
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||
t.Fatalf("anonymous verify: %s", out)
|
||||
}
|
||||
if ok, out := call(t, s, "hash_report", map[string]any{"sha256": dig, "want_scope": "global"}); !ok {
|
||||
t.Fatalf("anonymous hash_report: %s", out)
|
||||
}
|
||||
limited := false
|
||||
for i := 0; i < 40; i++ {
|
||||
if ok, out := call(t, s, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok && strings.Contains(out, "too many") {
|
||||
limited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("anonymous reads were not rate limited")
|
||||
}
|
||||
// another client is unaffected
|
||||
s2 := publicSession(t, vt, "", "198.51.100.10")
|
||||
if ok, out := call(t, s2, "verify", map[string]any{"receipt_id": "QmTestCid", "want_scope": "global"}); !ok {
|
||||
t.Fatalf("other client limited: %s", out)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue