From caa3c3db3bd7cbd5b2edfab8850167b2b8321984 Mon Sep 17 00:00:00 2001 From: Steve <263163432+mptfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:47 +0200 Subject: [PATCH] Restore budget, request-principal and scoped-token fixes missing from main Reapply #39/#42/#43 together after their feature-branch merges failed to propagate past the squash of #37. Restore the security regressions and correct the integration record. --- ai/accounting_regression_test.go | 79 ++++++++++++++++++ ai/ai.go | 46 ++++++++++- ai/ai_test.go | 8 +- ai/budget.go | 103 +++++++++++++++++++----- ai/digest.go | 6 +- ai/digest_cluster_test.go | 5 +- ai/embeddings.go | 8 +- ai/enrich.go | 2 + ai/openai.go | 27 +++++-- ai/stream.go | 20 ++++- cmd/token.go | 2 +- docs/agents.md | 15 ++-- docs/security-audit.md | 18 +++++ server/server.go | 27 ++++--- server/server_account.go | 68 ++++++++-------- server/server_ai.go | 62 +++++++------- server/server_ai_accounting_test.go | 33 ++++++++ server/server_ai_test.go | 6 +- server/server_auth.go | 45 ++++++++++- server/server_auth_isolation_test.go | 116 +++++++++++++++++++++++++++ server/server_device.go | 10 +-- server/server_middleware.go | 13 +-- server/server_payments.go | 10 +-- server/server_scoped_token_test.go | 52 ++++++++++++ server/server_webpush.go | 4 +- user/types.go | 5 +- 26 files changed, 641 insertions(+), 149 deletions(-) create mode 100644 ai/accounting_regression_test.go create mode 100644 server/server_ai_accounting_test.go create mode 100644 server/server_auth_isolation_test.go create mode 100644 server/server_scoped_token_test.go diff --git a/ai/accounting_regression_test.go b/ai/accounting_regression_test.go new file mode 100644 index 000000000..40ba46faa --- /dev/null +++ b/ai/accounting_regression_test.go @@ -0,0 +1,79 @@ +package ai + +import ( + "context" + "fmt" + "math" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClient_BudgetReservesInFlightCalls(t *testing.T) { + c := newTestClient(t, &Config{Provider: "mock", VisitorDailyTokenBudget: 10, GlobalDailyTokenBudget: 10}) + started := make(chan struct{}, 2) + release := make(chan struct{}) + c.Mock().SetHandler(func(req *Request) (*Response, error) { + started <- struct{}{} + <-release + return &Response{Text: "answer", OutputTokens: 10}, nil + }) + first := make(chan error, 1) + go func() { + _, err := c.Complete(context.Background(), &Request{Feature: FeatureChat, MaxTokens: 10, UserKey: "one"}) + first <- err + }() + <-started + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err := c.Complete(ctx, &Request{Feature: FeatureDigest, MaxTokens: 10, UserKey: "two"}) + close(release) + require.NoError(t, <-first) + require.ErrorIs(t, err, ErrBudgetExceeded) + require.Equal(t, int64(10), c.Report("one").Global.Total()) +} + +func TestEmbedder_CallsCannotBypassBudget(t *testing.T) { + c := newTestClient(t, &Config{Provider: "mock", GlobalDailyTokenBudget: 1}) + c.budget.Record("user:one", 1, 0) + _, err := c.Embedder("model").Embed(context.Background(), []string{"uncached"}) + require.ErrorIs(t, err, ErrBudgetExceeded) + require.Equal(t, 0, c.Mock().EmbedCalls()) +} + +func TestBudget_NegativeAndOverflowUsageCannotReopenBudget(t *testing.T) { + b := NewBudget(10, 10) + b.Record("one", math.MaxInt64, math.MaxInt64) + b.Record("one", -100, -100) + require.ErrorIs(t, b.Allow("one"), ErrBudgetExceeded) + require.GreaterOrEqual(t, b.Report("one").Global.Total(), int64(10)) +} + +func TestBudget_ReservationSurvivesMidnightAndFinishesOnce(t *testing.T) { + now := time.Date(2026, 10, 13, 23, 59, 59, 0, time.UTC) + b := NewBudget(10, 10) + b.now = func() time.Time { return now } + first, err := b.Reserve("one", 8) + require.NoError(t, err) + now = now.Add(2 * time.Second) + _, err = b.Reserve("two", 3) + require.ErrorIs(t, err, ErrBudgetExceeded, "a pending call must not disappear at midnight") + first.Finish(3, 2) + first.Finish(3, 2) + second, err := b.Reserve("two", 5) + require.NoError(t, err) + second.Finish(1, 0) + require.Equal(t, int64(6), b.Report("one").Global.Total()) +} + +func TestEmbedder_ChargesProviderReportedUsage(t *testing.T) { + c, srv := streamingClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"index":0,"embedding":[1,2]}],"usage":{"total_tokens":27}}`) + }, time.Second) + defer srv.Close() + _, err := c.Embedder("model").Embed(context.Background(), []string{"one"}, "user:one") + require.NoError(t, err) + require.Equal(t, int64(27), c.Report("user:one").Visitor.InputTokens) +} diff --git a/ai/ai.go b/ai/ai.go index b9dd64cd9..d788ba48f 100644 --- a/ai/ai.go +++ b/ai/ai.go @@ -112,6 +112,11 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) if !c.Enabled() { return nil, ErrDisabled } + request := *req + req = &request + if req.MaxTokens <= 0 { + req.MaxTokens = 1024 + } if req.Model == "" { req.Model = c.modelFor(req.Feature) } @@ -120,10 +125,13 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) metrics.AICacheHits.WithLabelValues(string(req.Feature)).Inc() return cached, nil } - if err := c.budget.Allow(req.UserKey); err != nil { + reservation, err := c.budget.Reserve(req.UserKey, requestInputTokens(req)+int64(req.MaxTokens)) + if err != nil { metrics.AIRequestsFailure.WithLabelValues(string(req.Feature)).Inc() return nil, err } + input, output := requestInputTokens(req), int64(0) + defer func() { reservation.Finish(input, output) }() callCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() start := time.Now() @@ -133,10 +141,19 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) metrics.AIRequestsFailure.WithLabelValues(string(req.Feature)).Inc() return nil, err } + if response == nil || response.InputTokens < 0 || response.OutputTokens < 0 { + return nil, ErrInvalidResponse + } + input, output = response.InputTokens, response.OutputTokens + if input == 0 && output == 0 { + input = requestInputTokens(req) + output = int64((len(response.Text) + 3) / 4) + response.InputTokens = input + response.OutputTokens = output + } metrics.AIRequestsSuccess.WithLabelValues(string(req.Feature)).Inc() metrics.AITokensInput.WithLabelValues(string(req.Feature)).Add(float64(response.InputTokens)) metrics.AITokensOutput.WithLabelValues(string(req.Feature)).Add(float64(response.OutputTokens)) - c.budget.Record(req.UserKey, response.InputTokens, response.OutputTokens) c.cache.Store(req, response) return response, nil } @@ -173,12 +190,33 @@ func (c *Client) Mock() *MockProvider { // embedTexts computes embeddings via the provider's embeddings endpoint. Only // OpenAI-compatible providers implement it; others return ErrStreamingNotSupported-style // capability errors which callers map to keyword-only fallbacks. -func (c *Client) embedTexts(ctx context.Context, model string, texts []string) ([][]float32, error) { +func (c *Client) embedTexts(ctx context.Context, model string, texts []string, userKey string) ([][]float32, error) { te, ok := c.provider.(TextEmbedder) if !ok { return nil, ErrEmbeddingsNotSupported } - return te.EmbedTexts(ctx, model, texts) + var bytes int64 + for _, text := range texts { + bytes += int64(len(text)) + } + input := (bytes + 3) / 4 + reservation, err := c.budget.Reserve(userKey, input) + if err != nil { + return nil, err + } + defer func() { reservation.Finish(input, 0) }() + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + if metered, ok := te.(interface { + embedTextsWithUsage(context.Context, string, []string) ([][]float32, int64, error) + }); ok { + vectors, tokens, err := metered.embedTextsWithUsage(callCtx, model, texts) + if tokens > 0 { + input = tokens + } + return vectors, err + } + return te.EmbedTexts(callCtx, model, texts) } // modelFor resolves the per-feature model override, falling back to the default model. diff --git a/ai/ai_test.go b/ai/ai_test.go index aeb5f64db..b60423e1b 100644 --- a/ai/ai_test.go +++ b/ai/ai_test.go @@ -61,15 +61,15 @@ func TestClient_BudgetEnforced(t *testing.T) { mock.SetHandler(func(req *Request) (*Response, error) { return &Response{Text: "ok", InputTokens: 60, OutputTokens: 50}, nil }) - _, err := client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "1", UserKey: "user:phil"}) + _, err := client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "1", UserKey: "user:phil", MaxTokens: 1}) require.Nil(t, err) // 110 tokens, over visitor budget of 100 — charged after the fact - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "2", UserKey: "user:phil"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "2", UserKey: "user:phil", MaxTokens: 1}) require.ErrorIs(t, err, ErrBudgetExceeded) // Other visitors can still spend until the global budget is hit (110/150 used) - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "3", UserKey: "user:ben"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "3", UserKey: "user:ben", MaxTokens: 1}) require.Nil(t, err) // global now at 220 - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "4", UserKey: "user:ben"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "4", UserKey: "user:ben", MaxTokens: 1}) require.ErrorIs(t, err, ErrBudgetExceeded) report := client.Report("user:phil") diff --git a/ai/budget.go b/ai/budget.go index a1afeac32..16a757122 100644 --- a/ai/budget.go +++ b/ai/budget.go @@ -2,6 +2,7 @@ package ai import ( "fmt" + "math" "sync" "time" ) @@ -13,11 +14,13 @@ type Budget struct { visitorDaily int64 // Max tokens per visitor per day; 0 = unlimited globalDaily int64 // Max tokens server-wide per day; 0 = unlimited - mu sync.Mutex - day string // UTC day the counters belong to, e.g. "2026-09-24" - global Usage // Global usage for today - perVisitor map[string]*Usage // Per-visitor usage for today - now func() time.Time // Injectable clock for tests + mu sync.Mutex + day string // UTC day the counters belong to, e.g. "2026-09-24" + global Usage // Global usage for today + reserved int64 + visitorReserved map[string]int64 + perVisitor map[string]*Usage // Per-visitor usage for today + now func() time.Time // Injectable clock for tests } // Usage is a token/request counter for one day. @@ -28,7 +31,7 @@ type Usage struct { } // Total returns input+output tokens. -func (u *Usage) Total() int64 { return u.InputTokens + u.OutputTokens } +func (u *Usage) Total() int64 { return saturatingAdd(u.InputTokens, u.OutputTokens) } // UsageReport is the API-facing view of today's usage. type UsageReport struct { @@ -42,17 +45,18 @@ type UsageReport struct { // NewBudget creates a Budget. Budget values of 0 mean unlimited. func NewBudget(visitorDaily, globalDaily int64) *Budget { return &Budget{ - visitorDaily: visitorDaily, - globalDaily: globalDaily, - perVisitor: make(map[string]*Usage), - now: time.Now, + visitorDaily: visitorDaily, + globalDaily: globalDaily, + perVisitor: make(map[string]*Usage), + visitorReserved: make(map[string]int64), + now: time.Now, } } // Allow returns ErrBudgetExceeded if the visitor or the global daily budget is already // exhausted. It does not reserve tokens; Record charges actual usage afterwards, so a -// single in-flight request may overshoot its budget. This is deliberate: estimation -// would either throttle legitimate traffic or be gameable. +// callers performing provider work use Reserve to include in-flight cost. Actual +// provider usage can exceed the input-token estimate; subsequent calls are blocked. func (b *Budget) Allow(userKey string) error { b.mu.Lock() defer b.mu.Unlock() @@ -73,9 +77,13 @@ func (b *Budget) Record(userKey string, input, output int64) { b.mu.Lock() defer b.mu.Unlock() b.rollover() - b.global.Requests++ - b.global.InputTokens += input - b.global.OutputTokens += output + b.record(userKey, input, output) +} + +func (b *Budget) record(userKey string, input, output int64) { + b.global.Requests = saturatingAdd(b.global.Requests, 1) + b.global.InputTokens = saturatingAdd(b.global.InputTokens, input) + b.global.OutputTokens = saturatingAdd(b.global.OutputTokens, output) if userKey == "" { return } @@ -84,9 +92,9 @@ func (b *Budget) Record(userKey string, input, output int64) { u = &Usage{} b.perVisitor[userKey] = u } - u.Requests++ - u.InputTokens += input - u.OutputTokens += output + u.Requests = saturatingAdd(u.Requests, 1) + u.InputTokens = saturatingAdd(u.InputTokens, input) + u.OutputTokens = saturatingAdd(u.OutputTokens, output) } // Report returns today's usage for a visitor key and the configured budgets. @@ -118,3 +126,62 @@ func (b *Budget) rollover() { b.global = Usage{} b.perVisitor = make(map[string]*Usage) } + +// Reservation holds an estimated input + maximum output charge while a provider +// call is in flight. Finish releases it and records actual (or estimated) usage. +type Reservation struct { + budget *Budget + key string + tokens int64 + done bool +} + +// Reserve checks completed and in-flight usage atomically. It never calls a provider. +func (b *Budget) Reserve(key string, tokens int64) (*Reservation, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.rollover() + tokens = max(0, tokens) + if b.globalDaily > 0 && (b.global.Total() >= b.globalDaily || b.reserved > b.globalDaily-b.global.Total() || tokens > b.globalDaily-b.global.Total()-b.reserved) { + return nil, fmt.Errorf("%w: global daily budget", ErrBudgetExceeded) + } + if key != "" && b.visitorDaily > 0 { + used := int64(0) + if u := b.perVisitor[key]; u != nil { + used = u.Total() + } + pending := b.visitorReserved[key] + if used >= b.visitorDaily || pending > b.visitorDaily-used || tokens > b.visitorDaily-used-pending { + return nil, fmt.Errorf("%w: visitor daily budget", ErrBudgetExceeded) + } + } + b.reserved = saturatingAdd(b.reserved, tokens) + b.visitorReserved[key] = saturatingAdd(b.visitorReserved[key], tokens) + return &Reservation{budget: b, key: key, tokens: tokens}, nil +} + +// Finish releases a reservation once, including when the UTC day rolled over. +func (r *Reservation) Finish(input, output int64) { + b := r.budget + b.mu.Lock() + defer b.mu.Unlock() + if r.done { + return + } + r.done = true + b.rollover() + b.reserved -= r.tokens + b.visitorReserved[r.key] -= r.tokens + if b.visitorReserved[r.key] == 0 { + delete(b.visitorReserved, r.key) + } + b.record(r.key, input, output) +} + +func saturatingAdd(a, b int64) int64 { + a, b = max(0, a), max(0, b) + if a > math.MaxInt64-b { + return math.MaxInt64 + } + return a + b +} diff --git a/ai/digest.go b/ai/digest.go index 688fcc142..868ef96ed 100644 --- a/ai/digest.go +++ b/ai/digest.go @@ -193,7 +193,7 @@ func (d *Digester) Digest(ctx context.Context, userKey string, in *DigestInput) } fmt.Fprintf(&prompt, "):\n\n") if d.embedder != nil && len(messages) >= 3 { - prompt.WriteString(buildClusterHints(d.embedder, messages)) + prompt.WriteString(buildClusterHints(ctx, d.embedder, messages, userKey)) } for _, m := range messages { timestamp := time.Unix(m.Time, 0).UTC().Format(time.RFC3339) @@ -276,12 +276,12 @@ func parseDigest(topic string, messageCount int, text string) (*DigestResult, er // buildClusterHints computes semantic clusters and renders a short hint block for the // prompt. Embedding failure degrades silently to no hints (plain summarization). -func buildClusterHints(e *Embedder, messages []DigestMessage) string { +func buildClusterHints(ctx context.Context, e *Embedder, messages []DigestMessage, userKey string) string { texts := make([]string, len(messages)) for i, m := range messages { texts[i] = m.Title + "\n" + m.Message } - vectors, err := e.Embed(context.Background(), texts) + vectors, err := e.Embed(ctx, texts, userKey) if err != nil { return "" } diff --git a/ai/digest_cluster_test.go b/ai/digest_cluster_test.go index 41b03981a..f6a18178d 100644 --- a/ai/digest_cluster_test.go +++ b/ai/digest_cluster_test.go @@ -1,6 +1,7 @@ package ai import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -34,7 +35,7 @@ func TestBuildClusterHints(t *testing.T) { {ID: "m4", Message: "totally different subject entirely"}, {ID: "m5", Message: "another unrelated subject here"}, } - hints := buildClusterHints(embedder, messages) + hints := buildClusterHints(context.Background(), embedder, messages, "") t.Logf("hints: %q", hints) // The deterministic mock vectors may or may not split these into 2+ groups; // the function must never panic and must never fabricate message numbers. @@ -47,6 +48,6 @@ func TestBuildClusterHints_EmptyOnError(t *testing.T) { return nil, ErrInvalidResponse }) embedder := client.Embedder("embed-model") - hints := buildClusterHints(embedder, []DigestMessage{{Message: "x"}, {Message: "y"}}) + hints := buildClusterHints(context.Background(), embedder, []DigestMessage{{Message: "x"}, {Message: "y"}}, "") require.Equal(t, "", hints) } diff --git a/ai/embeddings.go b/ai/embeddings.go index a1cf0ca98..3db0170b1 100644 --- a/ai/embeddings.go +++ b/ai/embeddings.go @@ -46,7 +46,11 @@ func (c *Client) Embedder(model string) *Embedder { // Embed returns one vector per input text, in input order. Uncached texts are sent to // the provider in batches of EmbedBatchSize; repeated identical texts are served from // the cache. -func (e *Embedder) Embed(ctx context.Context, texts []string) ([][]float32, error) { +func (e *Embedder) Embed(ctx context.Context, texts []string, userKeys ...string) ([][]float32, error) { + userKey := "" + if len(userKeys) > 0 { + userKey = userKeys[0] + } out := make([][]float32, len(texts)) var missingIdx []int var missing []string @@ -66,7 +70,7 @@ func (e *Embedder) Embed(ctx context.Context, texts []string) ([][]float32, erro if end > len(missing) { end = len(missing) } - part, err := e.client.embedTexts(ctx, e.model, missing[start:end]) + part, err := e.client.embedTexts(ctx, e.model, missing[start:end], userKey) if err != nil { return nil, err } diff --git a/ai/enrich.go b/ai/enrich.go index 6aed089a7..68254c418 100644 --- a/ai/enrich.go +++ b/ai/enrich.go @@ -43,6 +43,7 @@ type Enrichment struct { // EnrichRequest is the input for Enrich. TranslateTo (a language name or code, e.g. // "de" or "German") optionally requests a translation of the message body. type EnrichRequest struct { + UserKey string // Publisher attribution for token budgets Topic string Title string Message string @@ -90,6 +91,7 @@ func (e *Enricher) Enrich(ctx context.Context, req *EnrichRequest) (*Enrichment, } request := &Request{ Feature: FeatureEnrich, + UserKey: req.UserKey, System: fmt.Sprintf(system, EnrichmentMaxSummaryChars), Prompt: user.String(), JSONSchema: EnrichmentSchema, diff --git a/ai/openai.go b/ai/openai.go index c56eea881..939008e24 100644 --- a/ai/openai.go +++ b/ai/openai.go @@ -287,19 +287,24 @@ func (p *openAIProvider) Stream(ctx context.Context, req *Request) (<-chan Strea // EmbedTexts computes embeddings via the OpenAI-compatible /embeddings endpoint // (OpenAI, Ollama with embedding models, Poolside, ...). func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []string) ([][]float32, error) { + vectors, _, err := p.embedTextsWithUsage(ctx, model, texts) + return vectors, err +} + +func (p *openAIProvider) embedTextsWithUsage(ctx context.Context, model string, texts []string) ([][]float32, int64, error) { if len(texts) == 0 { - return nil, nil + return nil, 0, nil } body, err := json.Marshal(struct { Model string `json:"model"` Input []string `json:"input"` }{Model: model, Input: texts}) if err != nil { - return nil, err + return nil, 0, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/embeddings", bytes.NewReader(body)) if err != nil { - return nil, err + return nil, 0, err } httpReq.Header.Set("Content-Type", "application/json") if p.apiKey != "" { @@ -307,21 +312,24 @@ func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []s } httpResp, err := p.httpClient.Do(httpReq) if err != nil { - return nil, err + return nil, 0, err } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096)) - return nil, &ProviderError{Provider: p.name, Status: httpResp.StatusCode, Message: httpResp.Status} + return nil, 0, &ProviderError{Provider: p.name, Status: httpResp.StatusCode, Message: httpResp.Status} } var embResp struct { + Usage struct { + Total int64 `json:"total_tokens"` + } `json:"usage"` Data []struct { Index int `json:"index"` Embedding []float32 `json:"embedding"` } `json:"data"` } - if err := json.NewDecoder(httpResp.Body).Decode(&embResp); err != nil { - return nil, fmt.Errorf("invalid embeddings response: %w", err) + if err := json.NewDecoder(io.LimitReader(httpResp.Body, 32*1024*1024)).Decode(&embResp); err != nil { + return nil, 0, fmt.Errorf("invalid embeddings response: %w", err) } out := make([][]float32, len(texts)) for _, item := range embResp.Data { @@ -329,7 +337,10 @@ func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []s out[item.Index] = item.Embedding } } - return out, nil + if embResp.Usage.Total < 0 { + return nil, 0, ErrInvalidResponse + } + return out, embResp.Usage.Total, nil } func (p *openAIProvider) Ping(ctx context.Context) error { diff --git a/ai/stream.go b/ai/stream.go index 83a89baf0..ac16ec377 100644 --- a/ai/stream.go +++ b/ai/stream.go @@ -2,6 +2,7 @@ package ai import ( "context" + "encoding/json" "fmt" "heckel.io/ntfy/v2/metrics" @@ -31,6 +32,9 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea return nil, ErrDisabled } request := *req // Callers may reuse requests concurrently. + if request.MaxTokens <= 0 { + request.MaxTokens = 1024 + } if request.Model == "" { request.Model = c.modelFor(request.Feature) } @@ -38,13 +42,15 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea if !ok { return nil, ErrStreamingNotSupported } - if err := c.budget.Allow(request.UserKey); err != nil { + reservation, err := c.budget.Reserve(request.UserKey, requestInputTokens(&request)+int64(request.MaxTokens)) + if err != nil { return nil, err } callCtx, cancel := context.WithTimeout(ctx, c.timeout) events, err := streamer.Stream(callCtx, &request) if err != nil { cancel() + reservation.Finish(0, 0) metrics.AIRequestsFailure.WithLabelValues(string(request.Feature)).Inc() return nil, err } @@ -58,10 +64,10 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea success := false defer func() { output := (outputBytes + 3) / 4 - if reported != nil { + if reported != nil && reported.Total() > 0 { input, output = reported.InputTokens, reported.OutputTokens } - c.budget.Record(request.UserKey, input, output) + reservation.Finish(input, output) metrics.AITokensInput.WithLabelValues(string(request.Feature)).Add(float64(max(0, input))) metrics.AITokensOutput.WithLabelValues(string(request.Feature)).Add(float64(max(0, output))) if success { @@ -103,6 +109,10 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea return } if event.Usage != nil { + if event.Usage.InputTokens < 0 || event.Usage.OutputTokens < 0 { + fail(ErrInvalidResponse) + return + } reported = event.Usage } if event.Err != nil { @@ -135,6 +145,10 @@ func requestInputTokens(req *Request) int64 { } else { bytes += len(req.Prompt) } + if req.JSONSchema != nil { + schema, _ := json.Marshal(req.JSONSchema.Schema) + bytes += len(schema) + } return int64((bytes + 3) / 4) } diff --git a/cmd/token.go b/cmd/token.go index fdd01d56b..64365259f 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -52,7 +52,7 @@ Examples: ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday ntfy token add -l backups phil # Create token for user phil with label "backups" - ntfy token add --scope=ai phil # axon: token restricted to AI features only`, + ntfy token add --scope=ai phil # axon: allow AI; block account/admin management`, }, { Name: "remove", diff --git a/docs/agents.md b/docs/agents.md index 51bb7f2b4..337259ff5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -89,13 +89,16 @@ webhooks, no public agent endpoint, no polling. - Create a dedicated access token for your agent (`ntfy token add`) and treat it as the agent's identity; revoke it to cut the agent off. Server-side AI token budgets - (`ai-visitor-daily-token-budget`) apply per token. + (`ai-visitor-daily-token-budget`) apply per account, including free accounts. - **Scoped tokens** (axon): give an agent only what it needs with - `ntfy token add --scope=ai phil`. A token created with any `--scope` list can *only* - perform the listed capabilities — `--scope=ai` allows the AI endpoints (plan, tune, - digest, chat) but denies nothing else by itself, while a token *without* the `ai` - scope can publish/subscribe but never invoke AI features. Tokens created without - `--scope` remain fully unrestricted (backwards compatible). + `ntfy token add --scope=ai phil`. The `ai` scope grants access to AI endpoints + (plan, tune, digest, chat and briefing); a non-empty scope list without `ai` + cannot invoke them. Normal publishing and subscribing still use topic ACLs: + `read` and `publish` are currently labels, not independent capability gates. + Scoped agents cannot manage accounts, tokens, devices, billing or admin settings, + and account responses omit token values. Tokens created without `--scope` remain + fully unrestricted (backwards compatible). Device tokens use their separate + device sandbox described below. - `plan_subscription` only *proposes* plans — subscribing, and everything else the agent does, should still be reviewed by a human (see the [fork plan](ai-plan/index.md), Principle 2). diff --git a/docs/security-audit.md b/docs/security-audit.md index 0ecd3db01..0980e9a84 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -283,3 +283,21 @@ Open items: issue #27 (AI consent/disclosure policy — product decision), two moderate react-router advisories (major migration deferred), upstream sync (178 commits; shutdown/Postgres/cluster fixes listed in the audit report), no down-migrations (rely on the daily backups before upgrades). + +## Addendum 6 — stacked PR integration gap + +A follow-up check of main at `7831282b` found that #39, #42 and #43 had been +merged into their feature-branch bases after #37 was squashed into main. +Their merged status did not mean the budget, request-principal and scoped-token +fixes were present on main. The Addendum 5 claim that all fixes were integrated +was therefore incorrect. + +The restoration change reapplies the reviewed patches together and restores +their regression tests. Before restoration, the same-IP account and token-scope +interleavings, unrestricted-token harvesting/minting, embedding-budget bypass +and publisher-attribution tests fail on main. They pass after restoration. + +For future stacks, after merging a parent into main, retarget the next child PR +to main and reconcile its patch with the merged result. Verify the resulting +main tree and run the security regressions; a merged feature-branch PR alone is +not evidence of integration. diff --git a/server/server.go b/server/server.go index 4e7997bab..302a4dc5f 100644 --- a/server/server.go +++ b/server/server.go @@ -640,7 +640,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) } if isRateLimiting && s.config.StripeSecretKey != "" { - u := v.User() + u := requestUser(r, v) if u == nil || u.Tier == nil { httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL) } @@ -663,7 +663,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit // management — tokens, passwords, reservations, billing, admin — and must // never be reachable with a device token (not even for admins, so an // admin's paired phone cannot mint unrestricted tokens). - if u := v.User(); u != nil && isDeviceScopedToken(u) && !deviceScopeAllowedPath(r.URL.Path) { + if u := requestUser(r, v); u != nil && isDeviceScopedToken(u) && !deviceScopeAllowedPath(r.URL.Path) { + return errHTTPForbidden + } + if !agentScopeAllowedRequest(requestUser(r, v), r) { return errHTTPForbidden } if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { @@ -1033,7 +1036,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess } if email != "" { var httpErr *errHTTP - email, httpErr = s.convertEmailAddress(v.User(), email) + email, httpErr = s.convertEmailAddress(requestUser(r, v), email) if httpErr != nil { return nil, httpErr.With(t) } else if !vrate.EmailAllowed() { @@ -1042,7 +1045,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess } if call != "" { var httpErr *errHTTP - call, httpErr = s.convertPhoneNumber(v.User(), call) + call, httpErr = s.convertPhoneNumber(requestUser(r, v), call) if httpErr != nil { return nil, httpErr.With(t) } else if !vrate.CallAllowed() { @@ -1053,7 +1056,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess m = model.NewPollRequestMessage(t.ID, m.PollID) } m.Sender = v.IP() - m.User = v.MaybeUserID() + m.User = requestUserID(r, v) if cache { m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } @@ -1064,7 +1067,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess m.Message = emptyMessageBody } m.SanitizeUTF8() - s.maybeEnrichMessage(m) // axon: inline AI enrichment for opted-in topics (pass-through on breach) + s.maybeEnrichMessage(r.Context(), v, m) // axon: inline AI enrichment for opted-in topics (pass-through on breach) delayed := m.Time > time.Now().Unix() ev := logvrm(v, r, m). Tag(tagPublish). @@ -1115,7 +1118,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, err } } - u := v.User() + u := requestUser(r, v) if s.userManager != nil && u != nil && u.Tier != nil { go s.userManager.EnqueueUserStats(u.ID, v.Stats()) } @@ -1191,7 +1194,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * // Create an action message with the given event type m := model.NewActionMessage(event, t.ID, sequenceID) m.Sender = v.IP() - m.User = v.MaybeUserID() + m.User = requestUserID(r, v) m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() // Publish to subscribers, Firebase (for Android clients), and web push endpoints if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil { @@ -1644,7 +1647,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * defer cancel() subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, requestUserID(r, v), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { @@ -1793,7 +1796,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, requestUserID(r, v), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { @@ -1875,10 +1878,10 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top return err } if ownerUserID == "" { - if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil { + if err := s.userManager.Authorize(requestUser(r, v), t.ID, user.PermissionWrite); err == nil { writableRateTopics = append(writableRateTopics, t) } - } else if ownerUserID == v.MaybeUserID() { + } else if ownerUserID == requestUserID(r, v) { writableRateTopics = append(writableRateTopics, t) } } diff --git a/server/server_account.go b/server/server_account.go index a13522392..2d8ac1d07 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -23,7 +23,7 @@ const ( ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if !u.IsAdmin() { // u may be nil, but that's fine if !s.config.EnableSignup { return errHTTPBadRequestSignupNotEnabled @@ -70,10 +70,9 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis if err != nil { return err } - // axon: a device-scoped token (pairing flow) must never see the account's - // token values — that would let a paired phone escalate to unrestricted - // credentials. The app only needs prefs/limits here. - stripTokens := isDeviceScopedToken(v.User()) + // Scoped credentials must never see token values that can upgrade their scopes. + u := requestUser(r, v) + stripTokens := u != nil && len(u.TokenScopes) > 0 logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats") limits, stats := info.Limits, info.Stats response := &apiAccountResponse{ @@ -102,7 +101,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, }, } - u := v.User() if u != nil { response.Username = u.Name response.Role = string(u.Role) @@ -224,7 +222,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * } else if req.Password == "" { return errHTTPBadRequest } - u := v.User() + u := requestUser(r, v) if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { return errHTTPBadRequestIncorrectPasswordConfirmation } @@ -262,7 +260,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ } else if req.Password == "" || req.NewPassword == "" { return errHTTPBadRequest } - u := v.User() + u := requestUser(r, v) if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { return errHTTPBadRequestIncorrectPasswordConfirmation } @@ -281,7 +279,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ // the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a // user in, so it also reports who they are (the identifier they typed may be a primary email). func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name) token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false, "") if err != nil { @@ -307,7 +305,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request if req.Expires != nil { expires = time.Unix(*req.Expires, 0) } - u := v.User() + u := requestUser(r, v) logvr(v, r). Tag(tagAccount). Fields(log.Context{ @@ -330,7 +328,10 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) + if u == nil { + return errHTTPUnauthorized + } req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! if err != nil { return err @@ -371,7 +372,7 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path if token == "" { token = u.Token @@ -397,7 +398,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ if err != nil { return err } - u := v.User() + u := requestUser(r, v) if u.Prefs == nil { u.Prefs = &user.Prefs{} } @@ -475,7 +476,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req if err != nil { return err } - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil { prefs = &user.Prefs{} @@ -498,7 +499,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http. if err != nil { return err } - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil || prefs.Subscriptions == nil { return errHTTPNotFound @@ -525,7 +526,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // DELETEs cannot have a body, and we don't want it in the path deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL") deleteTopic := readParam(r, "X-Topic", "Topic") - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil || prefs.Subscriptions == nil { return nil @@ -551,7 +552,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless // it is already reserved by someone else. func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -606,7 +607,7 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R if !topicRegex.MatchString(topic) { return errHTTPBadRequestTopicInvalid } - u := v.User() + u := requestUser(r, v) authorized, err := s.userManager.HasReservation(u.Name, topic) if err != nil { return err @@ -653,7 +654,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi } func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -684,7 +685,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R } func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -706,7 +707,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -725,7 +726,7 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R // magic-link token, stores a pending verification, and emails the link. The address is NOT // added to the verified list until the user clicks the link (handleAccountEmailVerify). func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -774,8 +775,8 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return err } logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified") - // Refresh the verified user's other sessions. The request is unauthenticated (v.User() is - // usually nil), so resolve the user from the token row and publish to their sync topic. + // Refresh the verified user's other sessions. The request is usually unauthenticated, + // so resolve the user from the token row and publish to their sync topic. s.publishSyncEventForUserIDAsync(v, m.UserID) return s.writeJSON(w, newSuccessResponse()) } @@ -783,7 +784,7 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request // handleAccountEmailDelete removes an email address, whether verified or still pending // (DELETE /v1/account/email). Removing the primary leaves the account with no primary. func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -804,7 +805,7 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request // handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery) // email (POST /v1/account/email/primary). func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -825,7 +826,7 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req // handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend). func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -985,19 +986,18 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT } // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic -func (s *Server) publishSyncEventAsync(v *visitor) { +func (s *Server) publishSyncEventAsync(v *visitor, principals ...*user.User) { + u := v.User() + if len(principals) > 0 { + u = principals[0] + } go func() { - if err := s.publishSyncEvent(v); err != nil { + if err := s.publishSyncEventForUser(v, u); err != nil { logv(v).Err(err).Trace("Error publishing to user's sync topic") } }() } -// publishSyncEvent publishes a sync message to the authenticated user's sync topic -func (s *Server) publishSyncEvent(v *visitor) error { - return s.publishSyncEventForUser(v, v.User()) -} - // publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the // given ID, resolving the user first. Used by the unauthenticated email-verify handler, where // the request visitor has no associated user but the token identifies the account to refresh. diff --git a/server/server_ai.go b/server/server_ai.go index 4f6aebeb9..004438bbb 100644 --- a/server/server_ai.go +++ b/server/server_ai.go @@ -47,7 +47,7 @@ func (s *Server) ensureAIEnabled(next handleFunc) handleFunc { // (plan, tune, digest, chat), not to status/usage. func (s *Server) ensureAIScope(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if u := v.User(); u != nil && !u.HasTokenScope(user.TokenScopeAI) { + if u := requestUser(r, v); u != nil && !u.HasTokenScope(user.TokenScopeAI) { log.Tag(tagAI).With(v).Warn("Token does not carry the ai scope, rejecting request") return errHTTPForbidden } @@ -88,7 +88,7 @@ func (s *Server) handleAIStatus(w http.ResponseWriter, r *http.Request, v *visit // It is intentionally available to anonymous visitors: AI features are rate-limited per // visitor, so unauthenticated clients can have usage too. func (s *Server) handleAIUsage(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.writeJSON(w, s.ai.Report(visitorID(v.ip, v.user, v.config))) + return s.writeJSON(w, s.ai.Report(aiVisitorID(r.Context(), v))) } const ( @@ -103,7 +103,7 @@ const ( // passes through unchanged (instant delivery is ntfy's core promise), and // - the enriched message is what gets cached, so every subscriber and poller sees // the same enrichment and the provider is paid once per message. -func (s *Server) maybeEnrichMessage(m *model.Message) { +func (s *Server) maybeEnrichMessage(parent context.Context, v *visitor, m *model.Message) { if !s.ai.Enabled() || !s.config.AIEnrichmentEnabled { return } @@ -117,9 +117,9 @@ func (s *Server) maybeEnrichMessage(m *model.Message) { if slices.Contains(s.config.AITranslateTopics, m.Topic) { translateTo = s.config.AITranslateLang } - ctx, cancel := context.WithTimeout(context.Background(), s.config.AIInlineTimeout) + ctx, cancel := context.WithTimeout(parent, s.config.AIInlineTimeout) defer cancel() - enrichment, err := ai.NewEnricher(s.ai).Enrich(ctx, &ai.EnrichRequest{Topic: m.Topic, Title: m.Title, Message: m.Message, TranslateTo: translateTo}) + enrichment, err := ai.NewEnricher(s.ai).Enrich(ctx, &ai.EnrichRequest{UserKey: aiVisitorID(parent, v), Topic: m.Topic, Title: m.Title, Message: m.Message, TranslateTo: translateTo}) if err != nil { log.Tag(tagAI).Err(err).Field("topic", m.Topic).Field("message_id", m.ID).Debug("AI enrichment skipped, passing through") metrics.AIEnrichmentPassThrough.Inc() @@ -170,9 +170,9 @@ func (s *Server) handleAIPlan(w http.ResponseWriter, r *http.Request, v *visitor if !v.aiQuotaAllowed() { return errHTTPTooManyRequestsLimitAIRequests } - existingTopics := visitorTopics(v) + existingTopics := visitorTopics(requestUser(r, v)) planner := ai.NewPlanner(s.ai) - plan, err := planner.Plan(r.Context(), visitorID(v.ip, v.user, v.config), s.config.BaseURL, body.Prompt, body.Locale, existingTopics, sanitizePlanContext(body.Context)) + plan, err := planner.Plan(r.Context(), aiVisitorID(r.Context(), v), s.config.BaseURL, body.Prompt, body.Locale, existingTopics, sanitizePlanContext(body.Context)) if err != nil { return s.mapAIError(err) } @@ -207,7 +207,7 @@ func (s *Server) handleAITune(w http.ResponseWriter, r *http.Request, v *visitor return errHTTPTooManyRequestsLimitAIRequests } planner := ai.NewPlanner(s.ai) - tune, err := planner.Tune(r.Context(), visitorID(v.ip, v.user, v.config), &ai.TuneInput{ + tune, err := planner.Tune(r.Context(), aiVisitorID(r.Context(), v), &ai.TuneInput{ Topic: body.Topic, DisplayName: body.DisplayName, Search: body.Search, @@ -274,7 +274,7 @@ func (s *Server) handleAIDigest(w http.ResponseWriter, r *http.Request, v *visit if !topicRegex.MatchString(body.Topic) { return errHTTPBadRequestAIRequest } - u := v.User() + u := requestUser(r, v) if !userSubscribesTo(u, s.config.BaseURL, body.Topic) || !s.canReadTopic(u, body.Topic) { return errHTTPForbidden // Only your own subscriptions, keeps the quota honest } @@ -317,7 +317,7 @@ func (s *Server) handleAIDigest(w http.ResponseWriter, r *http.Request, v *visit if s.embedder != nil { digester = ai.NewDigesterWithEmbedder(s.ai, s.embedder) // axon: semantic pre-clustering } - digest, err := digester.Digest(r.Context(), visitorID(v.ip, v.user, v.config), &ai.DigestInput{ + digest, err := digester.Digest(r.Context(), aiVisitorID(r.Context(), v), &ai.DigestInput{ Topic: body.Topic, Messages: digestMessages, }) if err != nil { @@ -358,14 +358,14 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request, v *visitor if err != nil { return err } - question, context, messageTopics, err := s.chatContext(v, body) + question, context, messageTopics, err := s.chatContext(r, v, body) if err != nil { return err } if len(context) == 0 { return s.writeJSON(w, s.emptyChatResponse(body, question)) } - context = selectChatContext(r.Context(), question, context, s.embedder) + context = selectChatContext(r.Context(), question, context, s.embedder, aiVisitorID(r.Context(), v)) log.Tag(tagAI).With(v).Fields(log.Context{ "ai_feature": string(ai.FeatureChat), "ai_chat_topic": body.Topic, @@ -376,7 +376,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request, v *visitor for _, m := range context { chatMessages = append(chatMessages, ai.DigestMessage{ID: m.ID, Topic: messageTopics[m.ID], Title: m.Title, Message: m.Message, Priority: m.Priority, Time: m.Time}) } - answer, err := ai.NewChatter(s.ai).Chat(r.Context(), visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + answer, err := ai.NewChatter(s.ai).Chat(r.Context(), aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { return s.mapAIError(err) } @@ -407,7 +407,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v if err != nil { return err } - question, context, messageTopics, err := s.chatContext(v, body) + question, context, messageTopics, err := s.chatContext(r, v, body) if err != nil { return err } @@ -433,7 +433,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v _ = writeEvent(map[string]any{"type": "delta", "text": "There are no messages in this topic for the selected period."}) return writeEvent(map[string]any{"type": "done"}) } - context = selectChatContext(ctx, question, context, s.embedder) + context = selectChatContext(ctx, question, context, s.embedder, aiVisitorID(r.Context(), v)) chatMessages := make([]ai.DigestMessage, 0, len(context)) for _, m := range context { chatMessages = append(chatMessages, ai.DigestMessage{ID: m.ID, Topic: messageTopics[m.ID], Title: m.Title, Message: m.Message, Priority: m.Priority, Time: m.Time}) @@ -452,13 +452,13 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v } return writeEvent(map[string]any{"type": "citations", "text": answerText, "citations": citations}) } - events, err := ai.NewChatter(s.ai).ChatStream(ctx, visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + events, err := ai.NewChatter(s.ai).ChatStream(ctx, aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { if !errors.Is(err, ai.ErrStreamingNotSupported) { return s.mapAIError(err) } // Graceful degradation: provider cannot stream, answer in one shot - answer, err := ai.NewChatter(s.ai).Chat(ctx, visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + answer, err := ai.NewChatter(s.ai).Chat(ctx, aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { return s.mapAIError(err) } @@ -505,7 +505,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v // enforced by the route decorators; this enforces the request shape and the // subscription rule, and gathers the context window (single topic or cross-topic). // A nil context return means "no messages in the window" (not an error). -func (s *Server) chatContext(v *visitor, body *apiAIChatRequest) (string, []*model.Message, map[string]string, error) { +func (s *Server) chatContext(r *http.Request, v *visitor, body *apiAIChatRequest) (string, []*model.Message, map[string]string, error) { question := strings.TrimSpace(body.Question) if question == "" || len(question) > 1000 || (body.All && body.Topic != "") { return "", nil, nil, errHTTPBadRequestAIRequest @@ -513,7 +513,7 @@ func (s *Server) chatContext(v *visitor, body *apiAIChatRequest) (string, []*mod if !body.All && !topicRegex.MatchString(body.Topic) { return "", nil, nil, errHTTPBadRequestAIRequest } - u := v.User() + u := requestUser(r, v) if !body.All && (!userSubscribesTo(u, s.config.BaseURL, body.Topic) || !s.canReadTopic(u, body.Topic)) { return "", nil, nil, errHTTPForbidden } @@ -587,7 +587,7 @@ func toDigestMessages(messages []*model.Message) []ai.DigestMessage { // embedder, retrieval is hybrid: 0.6 * cosine(question, message) + 0.4 * normalized // term overlap. Without one, it falls back to term overlap and recency only. // Deterministic and cheap; embeddings are batched and cached per text. -func selectChatContext(ctx context.Context, question string, messages []*model.Message, embedder *ai.Embedder) []*model.Message { +func selectChatContext(ctx context.Context, question string, messages []*model.Message, embedder *ai.Embedder, userKeys ...string) []*model.Message { const k = 60 tokens := questionTokens(question) scores := make(map[string]float64, len(messages)) // id -> score @@ -620,12 +620,12 @@ func selectChatContext(ctx context.Context, question string, messages []*model.M sort.SliceStable(messages, func(i, j int) bool { return messages[i].Time < messages[j].Time }) return messages } - return hybridReorder(ctx, question, messages, scores, embedder) + return hybridReorder(ctx, question, messages, scores, embedder, userKeys...) } // hybridReorder re-ranks the top candidates by blending cosine(question, message) with // the term-overlap score. Embedding failures degrade silently to the term order. -func hybridReorder(ctx context.Context, question string, messages []*model.Message, scores map[string]float64, embedder *ai.Embedder) []*model.Message { +func hybridReorder(ctx context.Context, question string, messages []*model.Message, scores map[string]float64, embedder *ai.Embedder, userKeys ...string) []*model.Message { texts := make([]string, 0, len(messages)+1) maxTerm := 0.0 for _, m := range messages { @@ -635,7 +635,7 @@ func hybridReorder(ctx context.Context, question string, messages []*model.Messa } } texts = append(texts, question) - vectors, err := embedder.Embed(ctx, texts) + vectors, err := embedder.Embed(ctx, texts, userKeys...) if err != nil { log.Tag(tagAI).Err(err).Debug("Embedding failed, keeping keyword order") return messages @@ -701,7 +701,7 @@ func (s *Server) handleAIBriefing(w http.ResponseWriter, r *http.Request, v *vis sinceDuration = parsed } sinceMarker := model.NewSinceTime(time.Now().Add(-1 * sinceDuration).Unix()) - u := v.User() + u := requestUser(r, v) totalMessages := 0 topics := make([]ai.BriefingTopicMessages, 0) if u.Prefs != nil { @@ -738,7 +738,7 @@ func (s *Server) handleAIBriefing(w http.ResponseWriter, r *http.Request, v *vis Disclaimer: ai.BriefingDisclaimer, }) } - briefing, err := ai.NewBriefinger(s.ai).Briefing(r.Context(), visitorID(v.ip, v.user, v.config), &ai.BriefingInput{Topics: topics}) + briefing, err := ai.NewBriefinger(s.ai).Briefing(r.Context(), aiVisitorID(r.Context(), v), &ai.BriefingInput{Topics: topics}) if err != nil { return s.mapAIError(err) } @@ -789,8 +789,7 @@ func userSubscribesTo(u *user.User, baseURL, topic string) bool { // visitorTopics returns the topics of the visitor's synced subscriptions (empty for // anonymous visitors), used as context for the planner. -func visitorTopics(v *visitor) []string { - u := v.User() +func visitorTopics(u *user.User) []string { if u == nil || u.Prefs == nil { return nil } @@ -802,3 +801,12 @@ func visitorTopics(v *visitor) []string { } return topics } + +// AI accounting is account-based even for users without a paid tier. Anonymous +// publishers retain the normal IP-prefix attribution. This matches the scheduler. +func aiVisitorID(ctx context.Context, v *visitor) string { + if u := contextUser(ctx, v); u != nil { + return "user:" + u.ID + } + return visitorID(v.ip, nil, v.config) +} diff --git a/server/server_ai_accounting_test.go b/server/server_ai_accounting_test.go new file mode 100644 index 000000000..c7c53cd33 --- /dev/null +++ b/server/server_ai_accounting_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/ai" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func TestServer_AI_Enrichment_AttributesPublisherUsage(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled = true + c.AIProvider = "mock" + c.AIEnrichmentEnabled = true + c.AIEnrichTopics = []string{"alerts"} + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + auth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} + s.ai.Mock().Enqueue(ai.MockResult{Response: &ai.Response{Text: `{"summary":"Alert"}`, InputTokens: 3, OutputTokens: 2}}) + rr := request(t, s, "PUT", "/alerts", strings.Repeat("message data ", 30), auth) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "GET", "/v1/ai/usage", "", auth) + require.Equal(t, 200, rr.Code) + var usage ai.UsageReport + require.NoError(t, json.NewDecoder(rr.Body).Decode(&usage)) + require.Equal(t, int64(1), usage.Visitor.Requests) + require.Equal(t, int64(5), usage.Visitor.Total()) +} diff --git a/server/server_ai_test.go b/server/server_ai_test.go index 5e3af306f..0253db44d 100644 --- a/server/server_ai_test.go +++ b/server/server_ai_test.go @@ -223,12 +223,12 @@ func TestServer_AI_Plan_BudgetExceededIs429(t *testing.T) { c.AIEnabled = true c.AIProvider = "mock" c.BaseURL = "http://axon.example.com" - c.AIGlobalDailyTokenBudget = 10 + c.AIGlobalDailyTokenBudget = 4000 s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) defer s.closeDatabases() s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { - return &ai.Response{Text: `{"subscriptions": [{"topic": "t"}]}`, InputTokens: 100, OutputTokens: 100}, nil + return &ai.Response{Text: `{"subscriptions": [{"topic": "t"}]}`, InputTokens: 2100, OutputTokens: 2100}, nil }) phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} rr := request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, phil) @@ -245,7 +245,7 @@ func TestServer_AI_Plan_BudgetExceededIs429(t *testing.T) { var report ai.UsageReport require.Nil(t, json.NewDecoder(rr.Body).Decode(&report)) require.Equal(t, int64(1), report.Visitor.Requests) - require.Equal(t, int64(200), report.Global.Total()) + require.Equal(t, int64(4200), report.Global.Total()) }) } diff --git a/server/server_auth.go b/server/server_auth.go index 02690dc76..ba778c345 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/base64" "errors" "net/http" @@ -27,7 +28,7 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er // Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in // handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor -- // whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP. - r = withContext(r, map[contextKey]any{contextVisitorIP: ip}) + r = withContext(r, map[contextKey]any{contextVisitorIP: ip, contextRequestUser: (*user.User)(nil)}) vip := s.visitor(ip, nil) if s.userManager == nil { return r, vip, nil @@ -48,7 +49,9 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er logr(r).Err(err).Debug("Authentication failed") return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs! } - // Authentication with user was successful + // Authentication with user was successful. The visitor is a shared rate-limit + // bucket; its mutable user must never be used as this request's principal. + r = withContext(r, map[contextKey]any{contextRequestUser: u}) return r, s.visitor(ip, u), nil } @@ -109,3 +112,41 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us }) return u, nil } + +// contextUser returns the per-request authentication snapshot. Calls without an +// HTTP authentication context (background jobs and direct handlers) use v.User. +func contextUser(ctx context.Context, v *visitor) *user.User { + if u, ok := ctx.Value(contextRequestUser).(*user.User); ok { + return u + } + return v.User() +} + +func requestUser(r *http.Request, v *visitor) *user.User { return contextUser(r.Context(), v) } + +func requestUserID(r *http.Request, v *visitor) string { + if u := requestUser(r, v); u != nil { + return u.ID + } + return "" +} + +// agentScopeAllowedRequest prevents a scoped agent from upgrading its credentials +// through account, device, billing or admin APIs. Device credentials have their +// own sandbox; AI routes additionally enforce the ai scope and topic ACLs. +func agentScopeAllowedRequest(u *user.User, r *http.Request) bool { + if u == nil || len(u.TokenScopes) == 0 || isDeviceScopedToken(u) { + return true + } + if !strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/v1/ai/") { + return true // Normal topic APIs/MCP, or AI APIs with their own scope check. + } + if r.Method != http.MethodGet { + return false + } + switch r.URL.Path { + case apiAccountPath, apiHealthPath, apiConfigPath, apiStatsPath, apiTiersPath: + return true // Account GET strips credentials for every scoped token. + } + return false +} diff --git a/server/server_auth_isolation_test.go b/server/server_auth_isolation_test.go new file mode 100644 index 000000000..c5607c153 --- /dev/null +++ b/server/server_auth_isolation_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func TestServer_Authentication_RequestKeepsItsAccount(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + require.NoError(t, s.userManager.AddUser("bob", "bob", user.RoleUser, false)) + alice, _ := http.NewRequest("GET", "/v1/account", nil) + alice.RemoteAddr = "192.0.2.1:1234" + alice.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + alice, v, err := s.maybeAuthenticate(alice) + require.NoError(t, err) + bob, _ := http.NewRequest("GET", "/v1/account", nil) + bob.RemoteAddr = alice.RemoteAddr + bob.Header.Set("Authorization", util.BasicAuth("bob", "bob")) + _, _, err = s.maybeAuthenticate(bob) // deterministic interleaving from the same IP + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, alice, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, "alice", account.Username) +} + +func TestServer_Authentication_DeviceScopeSurvivesOtherRequest(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + owner, err := s.userManager.User("alice") + require.NoError(t, err) + token, err := s.userManager.CreateToken(owner.ID, "device", time.Unix(0, 0), netip.IPv4Unspecified(), false, user.TokenScopeDevice) + require.NoError(t, err) + device, _ := http.NewRequest("GET", "/v1/users", nil) + device.RemoteAddr = "192.0.2.1:1234" + device.Header.Set("Authorization", "Bearer "+token.Value) + device, v, err := s.maybeAuthenticate(device) + require.NoError(t, err) + admin, _ := http.NewRequest("GET", "/v1/account", nil) + admin.RemoteAddr = device.RemoteAddr + admin.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(admin) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.Equal(t, errHTTPForbidden, s.handleInternal(rr, device, v)) +} + +func TestServer_Authentication_AnonymousRequestStaysAnonymous(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + anon, _ := http.NewRequest("GET", "/v1/account", nil) + anon.RemoteAddr = "192.0.2.1:1234" + anon, v, err := s.maybeAuthenticate(anon) + require.NoError(t, err) + auth, _ := http.NewRequest("GET", "/v1/account", nil) + auth.RemoteAddr = anon.RemoteAddr + auth.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(auth) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, anon, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, user.Everyone, account.Username) + require.Empty(t, account.Tokens) +} + +func TestServer_Authentication_AIScopeSurvivesOtherRequest(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + owner, err := s.userManager.User("alice") + require.NoError(t, err) + token, err := s.userManager.CreateToken(owner.ID, "read only", time.Unix(0, 0), netip.IPv4Unspecified(), false, user.TokenScopeRead) + require.NoError(t, err) + limited, _ := http.NewRequest("POST", "/v1/ai/plan", nil) + limited.RemoteAddr = "192.0.2.1:1234" + limited.Header.Set("Authorization", "Bearer "+token.Value) + limited, v, err := s.maybeAuthenticate(limited) + require.NoError(t, err) + unrestricted, _ := http.NewRequest("GET", "/v1/account", nil) + unrestricted.RemoteAddr = limited.RemoteAddr + unrestricted.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(unrestricted) + require.NoError(t, err) + handler := s.ensureAIScope(func(http.ResponseWriter, *http.Request, *visitor) error { return nil }) + require.Equal(t, errHTTPForbidden, handler(httptest.NewRecorder(), limited, v)) +} + +func TestServer_Authentication_AnonymousTokenUpdate(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + r, _ := http.NewRequest("PATCH", "/v1/account/token", nil) + r.RemoteAddr = "192.0.2.1:1234" + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + require.Equal(t, errHTTPUnauthorized, s.handleAccountTokenUpdate(httptest.NewRecorder(), r, v)) +} diff --git a/server/server_device.go b/server/server_device.go index bc7f9df2c..baddd2c5a 100644 --- a/server/server_device.go +++ b/server/server_device.go @@ -80,7 +80,7 @@ type apiDeviceResponse struct { // then handed to the human, who taps axon://pair/ on the phone. The // human tap is the consent step — minting alone grants nothing. func (s *Server) handleDevicePairingCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { return errHTTPForbidden // A paired device must never grant further tokens } @@ -198,7 +198,7 @@ func (s *Server) handleDeviceAppliedAck(w http.ResponseWriter, r *http.Request, // handleDevicesList lists the user's paired devices. Tokens are never included. func (s *Server) handleDevicesList(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { return errHTTPForbidden // Devices know their own ID; the fleet view is the owner's } @@ -228,7 +228,7 @@ func (s *Server) handleDeviceDelete(w http.ResponseWriter, r *http.Request, v *v if len(matches) != 2 || !deviceIDRegex.MatchString(matches[1]) { return errHTTPInternalErrorInvalidPath } - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { // A device may unpair itself (self-revocation), never a sibling dev, err := s.userManager.DeviceByToken(u.Token) @@ -252,7 +252,7 @@ func (s *Server) handleDeviceDelete(w http.ResponseWriter, r *http.Request, v *v // access rule: an unrestricted (or admin) token of the owning user may address // any of their devices; a device-scoped token may only address itself. func (s *Server) deviceFromRequest(r *http.Request, v *visitor, deviceID string) (*user.Device, error) { - u := v.User() + u := requestUser(r, v) if !deviceIDRegex.MatchString(deviceID) { return nil, errHTTPInternalErrorInvalidPath } @@ -288,7 +288,7 @@ func (s *Server) handleDeviceConfigGet(w http.ResponseWriter, r *http.Request, v } w.Header().Set("Content-Type", "application/json") _, err = w.Write([]byte(dev.Config)) - if err == nil && isDeviceScopedToken(v.User()) { + if err == nil && isDeviceScopedToken(requestUser(r, v)) { _ = s.userManager.TouchDevice(dev.ID) // device synced; status only, failure harmless } return err diff --git a/server/server_middleware.go b/server/server_middleware.go index 117be70d4..d52483c8e 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -13,7 +13,8 @@ const ( contextRateVisitor contextKey = iota + 2586 contextTopic contextMatrixPushKey - contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record) + contextRequestUser // Immutable authentication result for this HTTP request (axon). + contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record) ) func (s *Server) limitRequests(next handleFunc) handleFunc { @@ -80,7 +81,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc { func (s *Server) ensureUser(next handleFunc) handleFunc { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if v.User() == nil { + if requestUser(r, v) == nil { return errHTTPUnauthorized } return next(w, r, v) @@ -89,7 +90,7 @@ func (s *Server) ensureUser(next handleFunc) handleFunc { func (s *Server) ensureAdmin(next handleFunc) handleFunc { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if !v.User().IsAdmin() { + if !requestUser(r, v).IsAdmin() { return errHTTPUnauthorized } return next(w, r, v) @@ -125,7 +126,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc { return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if v.User().Billing.StripeCustomerID == "" { + if requestUser(r, v).Billing.StripeCustomerID == "" { return errHTTPBadRequestNotAPaidUser } return next(w, r, v) @@ -136,7 +137,7 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { err := next(w, r, v) if err == nil { - s.publishSyncEventAsync(v) + s.publishSyncEventAsync(v, requestUser(r, v)) } return err } @@ -159,7 +160,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun if err != nil { return err } - u := v.User() + u := requestUser(r, v) for _, t := range topics { if err := s.userManager.Authorize(u, t.ID, perm); err != nil { logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) diff --git a/server/server_payments.go b/server/server_payments.go index 39007028a..f5aa0495a 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -115,7 +115,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ // handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier // will be updated by a subsequent webhook from Stripe, once the subscription becomes active. func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } @@ -223,7 +223,7 @@ func checkoutSuccessAllowed(ip string) bool { // the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first // and only time we can map the local username with the Stripe customer ID. func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error { - // We don't have v.User() in this endpoint, only a userManager! + // This endpoint resolves its user through the userManager. if !checkoutSuccessAllowed(v.ip.String()) { return errHTTPTooManyRequestsLimitRequests } @@ -318,7 +318,7 @@ func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visito // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } @@ -376,7 +376,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r // That is done by a webhook at the period end (in X days). func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription") - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID != "" { params := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(true), @@ -393,7 +393,7 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r // redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription. func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session") - u := v.User() + u := requestUser(r, v) if u.Billing.StripeCustomerID == "" { return errHTTPBadRequestNotAPaidUser } diff --git a/server/server_scoped_token_test.go b/server/server_scoped_token_test.go new file mode 100644 index 000000000..81ccbd4d0 --- /dev/null +++ b/server/server_scoped_token_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" +) + +func TestServer_ScopedToken_CannotEscalate(t *testing.T) { + for _, scope := range []string{user.TokenScopeRead, user.TokenScopePublish, user.TokenScopeAI} { + t.Run(scope, func(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + u, err := s.userManager.User("alice") + require.NoError(t, err) + _, err = s.userManager.CreateToken(u.ID, "unrestricted", time.Unix(0, 0), netip.IPv4Unspecified(), false, "") + require.NoError(t, err) + token, err := s.userManager.CreateToken(u.ID, "agent", time.Unix(0, 0), netip.IPv4Unspecified(), false, scope) + require.NoError(t, err) + for _, path := range []string{"/v1/account/token", "/v1/account/login", "/v1/device/pairing", "/v1/users"} { + t.Run(path, func(t *testing.T) { + r, _ := http.NewRequest("POST", path, strings.NewReader("{}")) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Authorization", "Bearer "+token.Value) + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + require.Equal(t, errHTTPForbidden, s.handleInternal(httptest.NewRecorder(), r, v), path) + }) + } + r, _ := http.NewRequest("GET", "/v1/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Authorization", "Bearer "+token.Value) + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, r, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, "alice", account.Username) + require.Empty(t, account.Tokens) + }) + } +} diff --git a/server/server_webpush.go b/server/server_webpush.go index 3b7f0f552..555a91163 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -61,7 +61,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * return err } if s.userManager != nil { - u := v.User() + u := requestUser(r, v) for _, t := range topics { if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil { logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) @@ -69,7 +69,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } } } - if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil { + if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, requestUserID(r, v), v.IP(), req.Topics); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/user/types.go b/user/types.go index 7797e9474..bd172a1ab 100644 --- a/user/types.go +++ b/user/types.go @@ -71,8 +71,9 @@ type Token struct { } // axon: token scopes. An empty scopes list means the token is unrestricted (backwards -// compatible with all pre-existing tokens). Non-empty scopes restrict what the token can -// do; currently only TokenScopeAI is enforced (LLM-costing endpoints). +// compatible with all pre-existing tokens). Scoped agents cannot manage credentials +// or accounts; ai gates LLM endpoints, and device has its own sandbox. Read and +// publish currently remain labels; topic ACLs still control normal message access. const ( TokenScopePublish = "publish" TokenScopeRead = "read"