HIP-1042: Avatar
Abstract
/v1/account/avatar is a person's profile photo: one upload that stores the
bytes and records their address on the user's IAM row, and one credential-free
read that streams them back. It is a facet of the account capability
(HIP-1200), served by apps/account in hanzoai/cloud
(apps/account/avatar.go); the router still serves it at the bare root today,
a pair hanzoai/cloud openapi/misfiled.txt carries.
The photo is CONTENT-ADDRESSED — the key ends in the SHA-256 of the bytes — and the read takes no credentials, because the address IS the capability. Both are deliberate, and this HIP is mostly the argument for the second.
Motivation
IAM has always carried an avatar field on every user row and every surface
renders it, but the only writers were federation (a provider's picture claim) and
directory sync. Someone who signed up with a password therefore had a monogram
and no way to replace it, and the console's profile card sent them to an identity
service that could not do it either.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
1. The subject is the caller
Org and user come from the VALIDATED identity, never from a request value, and
the digest is computed server-side. Every component of the key is therefore
server-chosen (apps/account/avatar.go:147). There is no way to name a different
subject, so this always sets your own photo.
2. The format is decided by the bytes
The stored type is sniffed from the CONTENT. A filename and a part's declared
content type are the client's to choose, so neither may decide what this origin
later serves. Exactly four raster formats are accepted — PNG, JPEG, GIF, WebP —
and everything else is refused (apps/account/avatar.go:191). An SVG MUST NOT be
storable as a picture and later served as a program.
The check is repeated on the READ (apps/account/avatar.go:274). It can only fire
on an object some other path wrote, and serving that under a guessed type is
precisely the injection the allow-list exists to prevent.
One upload is bounded at 8 MiB (apps/account/avatar.go:56) — generous for a
phone original, tight enough that this is not free object storage.
3. Identity components are refused, not sanitized
A path component that cannot be used verbatim in a key is REJECTED
(apps/account/avatar.go:117). It MUST NOT be folded into a safe form: folding
maps a/b and a_b onto one string, and a fold in a key is two tenants sharing
one address. These values arrive from validated claims, so a rejection means
something upstream is wrong and failing closed is the answer.
4. The address is the content
The key ends in the SHA-256 of the bytes. Two consequences are load-bearing:
- Replacing a photo yields a NEW address rather than a stale cache of the old
face at a mutable one. This is the bug that cannot be fixed from the server
once the address is
…/me.png. - The response caches for a year, immutable and
public(apps/account/avatar.go:283), because an address that IS its content can never go stale.
A replaced photo MUST NOT be deleted. The previous address is already inside issued tokens and rendered pages; an object store costs bytes where a broken face costs a person their profile. Orphans are a collection concern, not a correctness one.
5. The read is unauthenticated, and must be
The URL's whole job is to be an <img src> on a different origin from the API
host — which sends no cookies and cannot carry an Authorization header. So the
64 hex characters of the digest are the capability, producible only by someone who
already holds the image.
What the read MUST NOT become is a way to fetch anything else. The digest is verified to BE a digest before the store is touched, the org and user are refused unless they are plain identifiers, and the response is served only if the stored bytes are one of the four formats. Every denial — malformed path, miss, an object that is not an image — is the SAME 404, so nothing is disclosed about what exists.
6. Storage and record are two writes, and the failure is reported
The bytes go to the shared blob seam under this subsystem's own prefix; the
address goes to the IAM row, which is the system of record every surface already
reads. If the bytes land and the record does not, the request MUST report that —
the photo is stored and not shown — rather than answering with a success the user
cannot see (apps/account/avatar.go:147).
7. Why these two operations are untyped
The upload's request is a multipart form and the read's response is raw bytes
under a content type derived from those bytes. Neither is a shape a typed
input/output can carry, and apps/account/typed_wire_test.go holds them as a
CLOSED list with that wire fact recorded — so every other route in the package is
typed by default and dropping one out takes a deliberate edit.
Rationale
The alternative to content addressing is a mutable per-user URL, which is simpler until the day a stale face is cached in a network nobody controls. The alternative to the credential-free read is a signed URL with an expiry, which puts an expiring value inside IAM rows and rendered pages and breaks them later; the digest never expires because the content never changes.
The write reuses the existing blob seam and the existing IAM row. No new store, no second blob path, no schema change.
Security Considerations
The read is a public endpoint by design, so its safety is entirely in what it can address: a verified digest shape, refused-not-folded identity components, and a content-type allow-list applied twice. Remove any one of them and the route becomes a general reader of the blob bucket.
Serving user-supplied bytes from an API origin is the classic stored-injection
path. The four-format allow-list is enforced on the way in AND on the way out,
and nosniff accompanies the true type, so a browser cannot be talked into
executing a stored object in this origin.
The write shares the whole-row re-submit against IAM with HIP-1040, and inherits its rule: refuse to write a row you could not read.
References
- HIP-0026 — Identity & Access Management Standard
- HIP-1040 — Appearance
- HIP-0106 — The Hanzo Plugin Contract
- HIP-1200 — Account — The Caller's Own Surface
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.