Files
cloud/middleware_abuse.go
hanzo-dev 41b23f124a money: credit is an admin decision, so the automatic grant goes
The starter grant minted $5 into a wallet from middleware, on first credential
contact, with no human in the loop. Credit into an org is an ADMIN decision --
made deliberately, through the admin surface, against an auditable ledger --
so an automatic path that creates money is not a feature to fix but a mechanism
to remove.

DELETED RATHER THAN SWITCHED OFF. A disabled money-mint is one flag away from
an enabled one, and the flag is the kind of thing a later reader flips to
"unblock" something. There is no starter code left to re-enable: the middleware,
its mount in serve.go, the cross-process plane op (finance_starter / StarterIn /
Granted) that let a non-ledger binary ask for it, and their tests are gone.

Note this also removes the shared-signup-org exclusion that lived in the gate.
It was sound anti-abuse for a grant that no longer exists, and keeping half a
mechanism to guard the other half is how dead code survives.

The paywall consequence is deliberate and is NOT taken here: SpendGate stays
behind its kill switch. With no automatic funding, enforcing it 402s every new
account from its first request -- an honest paywall, and a product decision that
deserves its own change rather than arriving as a side effect of this one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:30:05 -07:00

454 lines
19 KiB
Go

package cloud
// AbuseGate — the API plane's lifecycle defense. It watches every authenticated
// and anonymous request, classes its caller into a lane, and — when the shape of
// the traffic warrants it — asks /v1/risk what to do. It does not score. It
// senses, asks, and enforces.
//
// WHAT IT ADDS THAT THE TWO EXISTING LIMITERS DO NOT. EdgeRateLimit caps a
// client IP before identity; ScopeRateLimit caps an authenticated
// (org, project, service). Neither can see a CREDENTIAL. So a key lifted out of
// a CI log, used from a residential address, inside its org's normal ceiling, is
// invisible to both — and that is precisely the shape of token theft, credential
// stuffing, scraping and pay-as-you-go abuse. This gate keys on the credential,
// which is the thing that was stolen.
//
// WHERE IT SITS, and why exactly there (serve.go):
//
// SanitizeIdentity → AuditTrail → ScopeRateLimit → AbuseGate → BillingGate
//
// - AFTER SanitizeIdentity, so the org it scopes to is the validated one and
// the credential class it reads cannot be forged.
// - INSIDE AuditTrail, so a refusal is a 401/403 that the tamper-evident trail
// already records — with the actor, the resource and the outcome. There is no
// second audit write here, because there must not be two records of one event.
// - AFTER ScopeRateLimit, so ordinary over-rate traffic is already 429'd and
// never reaches the scorer: rate limiting is a limiter's job, and asking a
// model what to do about a request that is simply too fast would be paying
// for an answer we already have.
// - BEFORE BillingGate, so an abusive request is refused before it can consume
// a balance.
//
// SHADOW BY DEFAULT, PER ORG. An org's Mode (edge.Policy.Mode) is shadow unless
// an operator sets it live. In shadow the gate observes, asks and RECORDS, and
// then lets the request through whatever the answer was. A statistical judgement
// that quietly started refusing an org's payments or logins because a feature
// shipped is the worst failure available here, so it cannot happen by default —
// only by decision, and the decision is visible at GET /v1/gateway/traffic.
//
// FAIL POLICY. The gate never decides what to do when the scorer is unavailable;
// cloud.Decide does, in one place, for every caller (see risk.go). An ordinary
// request proceeds; a PRIVILEGED GRANT — minting a credential, provisioning an
// identity, reading the key store — does not. That asymmetry is the whole
// posture: an outage in the risk plane must not be able to lock people out of
// the product, and must not be able to hand out standing authority either.
import (
"os"
"strconv"
"strings"
"time"
"github.com/hanzoai/cloud/apps/gateway/edge"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// The shapes that make the gate ask. They are constants rather than per-org
// configuration on purpose: a threshold a tenant can widen is a threshold an
// attacker's target can be talked into widening, and these are properties of the
// protocol rather than preferences of a customer.
//
// They are deliberately narrower than the bot thresholds in agency.go: this is
// "worth a question", that is "already answered".
const (
// screenFailures — 401/403 in the window from one credential. A client with a
// stale token retries a handful of times; ten is a pattern.
screenFailures = 10
// screenPeers — distinct credentials presented from one address. Two is a
// laptop with two projects open; four is a list being worked through.
screenPeers = 4
// screenPaths — distinct paths one credential touched. A real integration
// walks a few endpoints; twenty-four is a survey.
screenPaths = 24
// screenFirst — a credential's first request in a window is always screened,
// so a freshly stolen key is judged on its first use rather than after it has
// done enough damage to trip a counter. This bounds the cost of screening to
// one per credential per minute, not one per request.
screenFirst = 1
)
// holdFor is how long a non-allow verdict is enforced before the scorer is asked
// again. Long enough that an attack costs one screen rather than one per
// request; short enough that a false positive clears itself in a minute without
// anyone being paged.
const holdFor = time.Minute
// screenCentsEnv names the operator knob for what one screen costs. Unset means
// ZERO: screens are COUNTED on the org's own usage ledger from day one, and
// priced when the SKU is priced. A default price here would be a fabricated one.
const screenCentsEnv = "CLOUD_RISK_SCREEN_CENTS"
// exemptPrefixes are the subtrees the gate does not watch. Two kinds, and each
// is a correctness requirement rather than a performance concession:
//
// - THE APPEAL SURFACE. /v1/risk is where a refused caller reads the decision
// that refused it and where an operator releases a hold. Putting the appeal
// behind the thing that refuses makes a false positive unappealable, which is
// not an acceptable property for a plane that refuses payments and logins.
// - the internal money plane. ScopeRateLimit exempts these for the same reason
// (its own config lives behind them), and a gate that could refuse them could
// refuse its own metering.
//
// Deliberately NOT here: /v1/ml and /v1/aml. They are ordinary API surfaces
// served by ordinary apps, and exempting a whole prefix family because the risk
// product happens to own part of it would leave the compliance face and the model
// plane unwatched. The gate calls the scorer as a Go function, not as a route
// (see cloud.Decide), so watching them cannot re-enter anything.
var exemptPrefixes = []string{
"/v1/risk/",
"/v1/billing/",
"/v1/commerce/",
"/_/",
}
// AbuseGate returns the lifecycle-defense middleware. t is the shared edge
// sensor; deps carries the policy store, the logger and the metering client.
//
// It is a no-op passthrough when the sensor is absent, mirroring every other
// gate on this path: an unwired deployment is never blocked.
func AbuseGate(deps Deps, t *edge.Traffic) zip.Handler {
if t == nil {
return func(c *zip.Ctx) error { return c.Next() }
}
g := &abuseGate{
traffic: t,
policy: deps.GatewayPolicy,
meter: NewResourceMeter(deps, "risk"),
log: deps.Logger,
cents: screenCents(),
}
return g.handle
}
func screenCents() int64 {
n, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(screenCentsEnv)), 10, 64)
if err != nil || n < 0 {
return 0
}
return n
}
type abuseGate struct {
traffic *edge.Traffic
policy *edge.Store
meter *ResourceMeter
log interface {
Info(msg string, args ...any)
Warn(msg string, args ...any)
}
cents int64
}
func (g *abuseGate) handle(c *zip.Ctx) error {
// ONE normalization, at the top, and every comparison below is against it —
// the exemptions, the grant test, the path-spread key. c.Path() is the raw
// spelling the client sent; RoutePath is the form the router matches, so a
// difference in case or a trailing slash cannot put a request on one side of a
// security test and the other side of the routing table (see cloud.RoutePath).
path := RoutePath(c.Path())
if Probe(c.Method(), path) {
return c.Next()
}
for _, p := range exemptPrefixes {
if underPrefix(path, p) {
return c.Next()
}
}
// ONE observation, built in ONE place (observation). The tenant in it is the
// identity boundary's OWN answer and so is the caller's identity — see
// verifiedOrg and observation. The org is the sensor's keyspace index and the policy
// row this request is judged under, so a caller that could choose it could
// write into another tenant's state and read another tenant's posture.
now := time.Now()
sig := observation(c, routeFamily(path))
org := sig.Org
// Sense first, once. Every request is counted and lands in its lane whatever
// happens next — including one refused by a held verdict, because a caller
// that keeps knocking after being refused is exactly the caller a report needs
// to show. The lane comes BACK from the sensor: it is derived from the counts
// this observation just produced, so it cannot be stated before they exist.
p := g.traffic.Observe(sig, now)
lane := p.Lane
g.announce(c, sig, p)
// A verdict already in force short-circuits the question. The sensor holds it
// for at most a minute, so this is enforcement WITH a recent judgement behind
// it, never enforcement that outlives its reason.
if h, ok := g.traffic.Held(sig, now); ok {
return g.enforce(c, sig, lane, RiskVerdict{Action: h.Action, ID: h.Decision, Cause: h.Reason})
}
// A LAPSED hold forces the question again. Without it a hold buys a free
// minute at a time: the scorer sees more than the sensor does, so a verdict
// reached from the org's own history leaves no trace in a rolling minute of
// counts, and waiting for the local pattern to re-trip would wait forever.
privileged := Privileged(c.Method(), path)
if !g.screen(p, privileged) && !g.traffic.Lapsed(sig, now) {
return g.watch(c, sig)
}
// LIVE-ONLY QUESTION. In shadow the gate still senses and still reports, but
// it does not ask — because asking has two costs an unarmed org must not pay:
// a metered screen on its ledger, and the fail-CLOSED branch. A privileged
// grant denied by a scorer that was never installed would be an outage, not a
// defense, so arming is a decision an operator makes (and PUT /v1/gateway/config
// refuses to arm an org while no scorer is installed).
if g.mode(org) != edge.ModeLive {
return g.watch(c, sig)
}
v := Decide(c.Context(), org, RiskQuery{
Stage: StageUsage,
Subject: RiskSubject{Kind: "session", ID: subject(sig)},
Agency: lane,
Privileged: privileged,
Signals: Facts(map[string]string{
"credential": sig.Class,
"ip": sig.IP,
"path": sig.Path,
"method": c.Method(),
"requests": strconv.Itoa(p.Requests),
"failures": strconv.Itoa(p.Failures),
"paths": strconv.Itoa(p.Paths),
"peers": strconv.Itoa(p.Peers),
}),
})
if v.Agency != "" {
lane = v.Agency // the scorer's lane is the authoritative one.
}
// The screen is surfaced on BOTH rails, because they answer different
// questions and only one of them is live yet:
//
// o11y — Traffic.Screen counts it for the org from the first request,
// whatever a screen costs, and GET /v1/gateway/traffic reports it.
// billing — ResourceMeter puts it on the org's OWN usage ledger, the same
// rail every other metered resource rides, so a plan's included
// allowance and its overage are one ledger and not a second
// billing path.
//
// Meter is a NO-OP while the screen is unpriced (AmountCents<=0), which is the
// default: a price invented here would be a fabricated one, and the number
// belongs to the pricing catalog. That is exactly why the count above does not
// go through the ledger — an unpriced product must still be measurable.
// Both only in live mode: a shadow screen is not a product the org bought.
//
// AND ONLY A SCORED VERDICT IS BILLED. A screen the scorer never answered —
// absent, stuck, busy, timed out, erroring, silent — is judgement not rendered,
// and charging for it would put an outage of ours on a customer's invoice. The
// COUNT still happens either way, split into answered and unanswered, because
// "the judge stopped answering" is exactly the fact an operator must be able to
// read off the org's own report.
g.traffic.Screen(org, v.Refusal, now)
g.bill(org, principal.Project(c), c.RequestID(), sig.IP, v)
if v.Allowed() {
// The caller has been re-judged and is fine. Drop any lapsed hold, so it
// stops forcing a screen on every subsequent request.
g.traffic.Release(sig)
} else {
g.traffic.Hold(sig, edge.Hold{Action: v.Action, Reason: v.Cause, Decision: v.ID}, holdFor, now)
}
return g.enforce(c, sig, lane, v)
}
// screen reports whether this request's pattern is worth a question. A privileged
// grant always is: the fail-closed branch only protects what it is asked about.
//
// An UNMEASURED caller is not screened on cadence, because there is no cadence.
// Two ways a caller can be unmeasured, and both produce all-zero counts that
// "Requests <= 1" would read as "a caller making its first request" — for every
// request it ever makes, which is one screen each, a bill and a scorer stampede
// rather than a defense:
//
// refuse — the scope's ceiling turned this caller away.
// blind — the request carried no identity at all: no credential the boundary
// validated and no client address, so there is no caller to ask about.
// A subject of "" is not a question.
//
// A privileged grant is still screened either way: that branch protects the
// grant, not the sensor.
func (g *abuseGate) screen(p edge.Pattern, privileged bool) bool {
if privileged {
return true
}
if p.Strain == edge.StrainRefuse || p.Strain == edge.StrainBlind {
return false
}
return p.Requests <= screenFirst ||
p.Failures >= screenFailures ||
p.Peers >= screenPeers ||
p.Paths >= screenPaths
}
// bill puts one screen on the org's own usage ledger, and reports whether it
// did. ONLY a SCORED verdict is billable: a screen the scorer never answered —
// absent, stuck, busy, timed out, erroring, silent — is judgement not rendered,
// and charging for it would put an outage of ours on a customer's invoice.
//
// The rule lives in exactly one function because it is a money rule: the count
// (Traffic.Screen) and the debit answer different questions and only one of them
// is conditional, so the condition is stated once, here, where it can be read
// next to the charge it guards.
func (g *abuseGate) bill(org, project, request, ip string, v RiskVerdict) bool {
if !v.Scored() {
return false
}
g.meter.Meter(org, project, "screen", g.cents, request, ip)
return true
}
// announce logs a scope's sensor degradation ONCE per grade, not once per
// request. The sensor holds no logger and a flood produces one refused
// observation per request, so the grade change is carried back on the pattern and
// surfaced here — the alternative being a control that goes quiet with nothing in
// the log to say it did.
func (g *abuseGate) announce(c *zip.Ctx, sig edge.Signal, p edge.Pattern) {
if p.Rise == "" || p.Rise == edge.StrainClear || g.log == nil {
return
}
g.log.Warn("edge sensor strained",
"strain", p.Rise,
"org", sig.Org,
"lane", p.Lane,
"request", c.RequestID(),
)
}
// subject names WHAT the scorer is being asked about: the validated caller when
// there is one, and the address it came from when there is not. It is the sensor's
// key, so the thing that is judged and the thing that is held are the same thing —
// asking about a string the caller picked would let a refused caller be re-judged
// as somebody else by editing one header.
func subject(s edge.Signal) string {
if s.Cred != "" {
return s.Cred
}
return s.IP
}
// mode is the org's posture. An EMPTY org — the anonymous lane, which is where a
// bad bot calls from — resolves to the PLATFORM row rather than to shadow, or the
// one lane the gate exists for could never be armed. edge.Store.Mode owns that
// resolution; here it is only the nil-store case, which is shadow because an
// unwired deployment never enforces.
func (g *abuseGate) mode(org string) string {
if g.policy == nil {
return edge.ModeShadow
}
return g.policy.Mode(org)
}
// watch runs the request unjudged, and still learns from its outcome. A 401/403
// is the signal that separates a client with a stale token from one guessing
// them, and it only exists after the handler has run.
func (g *abuseGate) watch(c *zip.Ctx, sig edge.Signal) error {
err := c.Next()
g.observeOutcome(c, sig, err)
return err
}
// enforce applies a verdict. In shadow it applies NOTHING: it records what it
// would have done and lets the request through, which is what makes the mode
// switch a real one rather than a label.
func (g *abuseGate) enforce(c *zip.Ctx, sig edge.Signal, lane string, v RiskVerdict) error {
if v.Allowed() {
if v.Action == ActionReview {
g.record(c, sig, lane, v, "review")
}
return g.watch(c, sig)
}
if g.mode(sig.Org) != edge.ModeLive {
g.record(c, sig, lane, v, "shadow")
return g.watch(c, sig)
}
g.record(c, sig, lane, v, "enforced")
g.traffic.Deny(sig.Org, time.Now())
// The refusal is written in the fleet's own nested error contract, the same
// bytes every other Hanzo surface refuses with, so a client has ONE error
// shape to parse. The body names the decision id and never the score, the
// features or the rule: an attacker must not be able to use the refusal as a
// readout of the model that produced it.
switch v.Action {
case ActionChallenge:
c.SetHeader("WWW-Authenticate", `Bearer error="step_up_required"`)
return c.JSON(401, denyBody("step_up_required",
"Additional verification is required for this request. "+ref(v)))
case ActionRestrict:
c.SetHeader("Retry-After", strconv.Itoa(int(holdFor.Seconds())))
return c.JSON(429, denyBody("restricted",
"This credential is temporarily restricted. "+ref(v)))
default:
return c.JSON(403, denyBody("refused",
"This request was refused. "+ref(v)))
}
}
// ref names the decision a refusal came from, so a customer can quote one id to
// support and support can fetch the whole judgement — its rules, its features
// and the model digest — from GET /v1/risk/decisions/{id}. Without an id the
// refusal is unappealable, which is not an acceptable property for a product
// that refuses payments and logins.
func ref(v RiskVerdict) string {
if v.ID == "" {
return "Contact support if this is unexpected."
}
return "Reference " + v.ID + "."
}
// observeOutcome feeds the response status back into the sensor. Only 401/403
// count: those are the outcomes that mean a credential did not work, which is the
// signature of stuffing and of a replayed stolen token.
func (g *abuseGate) observeOutcome(c *zip.Ctx, sig edge.Signal, err error) {
if s := effectiveStatus(c.Fiber().Response().StatusCode(), err); s == 401 || s == 403 {
g.traffic.Fail(sig, time.Now())
}
}
// record surfaces a non-allow verdict to o11y. It is a LOG, not a second audit
// write: an enforced refusal is a 401/403 and AuditTrail already puts that in the
// tamper-evident trail, with the actor and the resource. Recording it twice would
// give one event two records that can disagree.
//
// A shadow verdict is the interesting one — it is the only evidence of what the
// gate WOULD do, and the only way stated-versus-realised can be measured before
// an org is armed.
func (g *abuseGate) record(c *zip.Ctx, sig edge.Signal, lane string, v RiskVerdict, outcome string) {
if g.log == nil {
return
}
g.log.Info("risk decision",
"stage", StageUsage,
"outcome", outcome,
"action", v.Action,
"org", sig.Org,
"cred", subject(sig),
"agency", lane,
"path", sig.Path,
"method", c.Method(),
"score", v.Score,
"decision", v.ID,
"cause", v.Cause,
// refusal is empty on a scored verdict and names the failure otherwise, so
// an allow that happened because nobody was listening is never read as a
// clean result.
"refusal", v.Refusal,
"request", c.RequestID(),
)
}