resolve could spend the whole process on one authenticated request. Two
amplifiers composed. A key had a count bound and no BYTE bound, and the domain
matcher split a host into labels and re-joined every tail, so an L-label host
allocated a copy of each of its L suffixes: one 8 KB dotted key materialised
16.8 MB and 100 of them 1.7 GB, measured over the router. And `sets` had no
bound and no dedupe, so naming one set N times ran N times the answers.
- maxKey bounds one key in bytes at every door it crosses — looked up,
written, removed — and REFUSES rather than truncating, because a shortened
key is a different key.
- a domain suffix is now a slice of the host, not a join of its labels: the
same answers in O(L) headers over one backing array.
- a call may name each published set once, and a set named twice is consulted
once.
The override write had the same hole from the other side: maxOverrides bounded
rows and nothing bounded a row, so 10,000 entries x 11 sets was gigabytes of
attacker-chosen bytes on the one volume every other organisation's store lives
on. The same maxKey closes it; an over-long note is refused rather than trimmed;
and what one organisation may occupy on that volume is now a figure the code
computes and a test pins, so raising rows, a key, a note or the catalog is an act
with its consequence next to it.
An override is a record, so it is now shipped before it is acknowledged, the way
apps/research and apps/books do it — this deployment is one replica with a
recreate rollout, and an unshipped write is a control an operator believes is in
force and is not.
prune spared ONE version: the call site passed the current version for both of
the statement's two placeholders, deleting the rows behind every citation taken
in the window before a refresh. What a take supersedes is now decided by
sweepOld over what the plane held before it, and proved against a warehouse
rather than against the text of the statement.
The publisher's end of the same amplifier is closed with the same door. A take
is refused whole if it carries a member longer than maxKey — one no lookup could
ever reach, so it is only weight in the warehouse, in every hydrate and in the
snapshot every request reads — or more members than a published set holds: swing
measures GROWTH against the version a take replaces, and a first take has nothing
to measure against, which after a cold start is every take. maxBody comes down to
six times the largest source in the catalog (measured: 2.6 MB), because the parse
allocates before any later gate can look at what it made. A publisher's redirect
must keep the two properties its origin already had, TLS and a destination
outside this network: this process runs in the cluster, where "wherever the
publisher says" reaches the pod network and the metadata address.
Also: a take whose size swings past 4x is refused and the previous version
stands (force is the operator lever); the disposable list is refused whole if it
names a mailbox provider, which is the one-row attack the size gate cannot see
on the one unpinned source; an attest receipt with no version or no designations
is recorded as a refusal instead of a current, fresh list; the plane sweeps at
cold start instead of refusing every set for six hours after a deploy; the one
cross-fleet aggregation states its own memory and time budget; every source
states a typed redistribution Basis from a closed vocabulary, on the wire, so
the licence position is an audit rather than a sentence; refresh reads the ONE
SuperAdmin predicate rather than restating it, and an admin of their own org is
refused there, because this route writes the baseline every org reads; and the
bridge sits on this app's own leaf, not on the /v1/ml parent two other products
answer under.
65 tests green under -race (one skipped: it dials the real publishers). Every fix
has a regression test that fails when that fix alone is reverted: 24 mutants, 24
killed.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
180 lines
6.7 KiB
Go
180 lines
6.7 KiB
Go
package reference
|
|
|
|
// fetch.go takes one publisher's bytes. It is the same shape luxfi/aml
|
|
// pkg/screen arrived at for the sanctions lists, for the same reasons, and the
|
|
// reasons are worth restating because each one is a measured failure:
|
|
//
|
|
// - RETRY TRANSPORT, NEVER A PARSE. A publisher that just refused a TLS
|
|
// handshake will serve the file correctly seconds later; a publisher whose
|
|
// schema changed will fail identically on every attempt, so retrying it only
|
|
// delays the refusal.
|
|
// - A TRUNCATED LIST IS THE DANGEROUS FAILURE. It parses. It yields a shorter
|
|
// list of members, every one of them correct, and nothing anywhere reports a
|
|
// problem — so a response that reaches the read limit is an error rather
|
|
// than a shorter set.
|
|
// - THE DIGEST IS NOT DECORATION. It is what tells a refresh that changed
|
|
// nothing from a refresh that did not run, and it is what makes ingest
|
|
// idempotent: the version IS the content.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// maxBody bounds one download, and it is the bound that decides how much the
|
|
// PARSE may cost — a parser turns bytes into entries one at a time, so
|
|
// whatever arrives here is allocated before any later gate can look at it.
|
|
//
|
|
// Measured: the largest source in the catalog is AWS's ip-ranges.json at 2.6
|
|
// MB, and the next is under a tenth of that. Sixteen is six times the largest
|
|
// and leaves room for years of growth; the previous sixty-four was twenty-five
|
|
// times it, and twenty-five times a few megabytes of adversarial one-token
|
|
// lines is a heap this one-replica deployment does not have. A publisher that
|
|
// outgrows this refuses on its own row and ages out visibly, which is a
|
|
// condition an operator can see and raise — unlike an OOM.
|
|
maxBody = 16 << 20
|
|
// attempts is how many times a publisher is asked before its source is
|
|
// recorded failed.
|
|
attempts = 3
|
|
// backoff grows per attempt: a publisher that just failed under load is not
|
|
// helped by being asked again immediately.
|
|
backoff = 5 * time.Second
|
|
// fetchTimeout bounds one attempt end to end.
|
|
fetchTimeout = 2 * time.Minute
|
|
)
|
|
|
|
// download takes one URL and returns its bytes. It is a value rather than a
|
|
// direct call so the retry behaviour — which decides whether a blip costs a day
|
|
// of freshness — is testable without a network.
|
|
type download func(ctx context.Context, url string) ([]byte, error)
|
|
|
|
// wire is the real downloader.
|
|
func wire(ctx context.Context, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "hanzo-reference/1")
|
|
client := &http.Client{Timeout: fetchTimeout, CheckRedirect: hop}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) == maxBody {
|
|
return nil, fmt.Errorf("%s reached the %d byte limit and may be truncated", url, maxBody)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// hop decides whether a publisher's redirect may be followed.
|
|
//
|
|
// Every Origin in the catalog is an HTTPS constant, so the ONE thing about the
|
|
// address this process cannot state in code is where a redirect goes. This runs
|
|
// inside the cluster, so "wherever the publisher says" reaches the pod network
|
|
// and the instance metadata address; and a hop to http:// hands the whole
|
|
// baseline every tenant's decisions read to anyone on the path.
|
|
//
|
|
// So a hop keeps the two properties the origin already had — TLS, and a
|
|
// destination outside this network — and is refused otherwise. The literal
|
|
// address forms are what a redirect can carry without a lookup; a name that
|
|
// RESOLVES inward is not caught here and does not need to be, because it is the
|
|
// publisher's own DNS and the same trust as the bytes themselves.
|
|
func hop(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return fmt.Errorf("%s redirected %d times", via[0].URL, len(via))
|
|
}
|
|
if req.URL.Scheme != "https" {
|
|
return fmt.Errorf("%s redirected to %s, and a reference source is fetched over TLS or not at all", via[0].URL, req.URL.Scheme)
|
|
}
|
|
if inward(req.URL.Hostname()) {
|
|
return fmt.Errorf("%s redirected to %s, which is inside this network", via[0].URL, req.URL.Host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// inward reports whether a host is a literal address this process should never
|
|
// be sent to: private, loopback, link-local (the instance metadata address is
|
|
// one), unspecified or multicast. A name is not judged here — resolving it is the
|
|
// publisher's own DNS.
|
|
func inward(host string) bool {
|
|
a, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
a = a.Unmap()
|
|
return a.IsPrivate() || !a.IsGlobalUnicast()
|
|
}
|
|
|
|
// pull downloads one source and parses it, retrying transport failures.
|
|
func pull(ctx context.Context, get download, src Source, wait func(context.Context, time.Duration) error) ([]Entry, error) {
|
|
var last error
|
|
for attempt := 1; attempt <= attempts; attempt++ {
|
|
body, err := get(ctx, src.Origin)
|
|
if err != nil {
|
|
last = fmt.Errorf("attempt %d of %d: %w", attempt, attempts, err)
|
|
// The context ending is the deployment shutting down or giving up, not the
|
|
// publisher failing, so there is nothing to retry into.
|
|
if ctx.Err() != nil {
|
|
return nil, last
|
|
}
|
|
if attempt < attempts {
|
|
if werr := wait(ctx, time.Duration(attempt)*backoff); werr != nil {
|
|
return nil, last
|
|
}
|
|
continue
|
|
}
|
|
return nil, last
|
|
}
|
|
return src.parse(body)
|
|
}
|
|
return nil, last
|
|
}
|
|
|
|
// pause waits, or returns early when the context ends.
|
|
func pause(ctx context.Context, d time.Duration) error {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-t.C:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// digest is the content address of a parsed source: the version.
|
|
//
|
|
// It is computed over the SORTED, canonical rendering of the entries rather
|
|
// than over the downloaded bytes, and that is the whole point. A publisher who
|
|
// reorders their file, or adds a timestamp comment, has not changed the set —
|
|
// digesting the bytes would mint a new version and re-ingest every row for a
|
|
// change that is not one. Digesting the meaning makes the version an identity:
|
|
// the same set is the same version, whoever fetched it and whenever.
|
|
func digest(entries []Entry) string {
|
|
h := sha256.New()
|
|
for _, e := range order(entries) {
|
|
fmt.Fprintf(h, "%s\x00", e.Key)
|
|
for _, k := range keys(e.Value) {
|
|
fmt.Fprintf(h, "%s\x01%s\x02", k, e.Value[k])
|
|
}
|
|
fmt.Fprintf(h, "%g\x03%d\x04%d\x1e", e.Score, e.Orgs, e.N)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|