Security
Rate limits
Section titled “Rate limits”/api allows RATE_LIMIT requests a minute (1200 by default) per client
address, API keys included. Past it, a 429 with
Retry-After. Signing in has its own, stricter limits. A share link’s password
allows ten wrong guesses in ten minutes, then waits, whoever is guessing. A
portal’s password allows ten from one address and two hundred in all in ten
minutes; only wrong ones count.
The counts live in each process’s memory. Behind several instances each counts
on its own; for a hard limit, put one in your reverse proxy too. Asset URLs
(/a/) are not limited: put a CDN in front of them on a public install, which
also caches renditions. What a rendition URL can cost is bounded instead: sizes
and quality snap to a few steps, a few renditions are made at once per server
with a short line behind them (past it, a 429), and one that takes longer
than 30 seconds stops (a 413). Uploads being filed take turns too, by size,
since each is read whole.
URLs imported into the library are fetched a few at a time
in a line of their own, so a slow server at the other end never holds up
anyone’s upload.
A rendition your team asks for counts toward its organization’s storage. One asked by someone a public asset or a signed URL alone let in is kept without counting, since the organization doesn’t choose what they ask, and bounded instead:
- What pages draw, a width in WebP (
w_800,f_webp), and the presets (the app’s and portals’ downloads) are always made. - Anything else, up to 16 per file while the bucket keeps them; past that,
a
403until some expire. Renditions already made are always served, and a member who opens the URL signed in makes what they like. - AVIF and drawn SVG, whose encoding and drawing don’t stop at the time limit, at most 4096 px a side.
Deleting an asset stops its renditions counting at once.
Client addresses
Section titled “Client addresses”Rate limits and the audit log go by the client’s address, which the server
only knows from its reverse proxies’ X-Forwarded-For. Each proxy appends the
address it saw, after whatever the client sent, so only the right end of the
list can be believed. TRUSTED_PROXIES names the proxies: addresses and
ranges, comma-separated, or private for every private range (10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, fc00::/7, loopback, link-local and
carrier-grade NAT). The client is the last entry that isn’t one of them;
without the header, X-Real-IP.
Unset, the server believes neither header: reached directly, it would take whatever a client wrote there. Every client then shares one rate limit, sign-in included, and the audit log records no address. The log says so once.
| In front of the server | TRUSTED_PROXIES |
|---|---|
| Nothing (the port is public) | unset |
| One reverse proxy: Caddy, nginx, Traefik (Coolify), an ingress, Fly’s proxy | private |
| Cloudflare, straight to the server | private |
| Cloudflare, then a proxy of your own | private and Cloudflare’s ranges (cloudflare.com/ips), with the proxy keeping the chain (nginx’s $proxy_add_x_forwarded_for; Caddy’s trusted_proxies set to Cloudflare’s ranges) |
| A proxy with a public address of its own | its address, or its ranges |
With it set, don’t expose the server’s port directly: a client reaching it past the proxies chooses its own address. To check, sign in and open the audit log: the sign-in shows your address. One of your proxies’ instead means a hop is missing from the list.
An organization sends as many emails a day as its emails limit allows
(LIMIT_EMAILS, or its own limits row; unlimited until set): invitations,
share links and tests. With open sign-up, set it, so no one account can spend
your sender’s reputation. Past it, links are still made to copy, and sending
one by email answers 429. Sign-up codes, password resets and portal requests
to an organization’s admins never count, so one organization can’t stop them.
A test email goes only to whoever sends it, and an organization has at most
200 invitations waiting at once. See Limits.
Headers
Section titled “Headers”Every page carries a Content-Security-Policy (only scripts carrying that
response’s nonce run, frames from Figma and Google’s embeds only, uploads to
your storage only, fonts from this server only; with TURNSTILE_*, Cloudflare
Turnstile’s script and frame too),
X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a strict referrer
policy, a Permissions-Policy that turns off camera, microphone and location,
and Cross-Origin-Opener-Policy: same-origin. With an https APP_URL, HSTS
too. The library and a brand’s screens in the app also let in Google Fonts’
hosts, for the previews of the font picker and a brand’s setup; no portal or
BrandHub page does.
Uploaded files
Section titled “Uploaded files”Anyone with upload rights can upload an SVG or an HTML file, and /a/{id}
serves it from the app’s own origin. It is served with a sandboxing policy, so
opened directly it runs no script and reaches nothing. PDFs are the exception,
since browsers show them in a viewer the sandbox would stop.
Writes signed by a session cookie are taken only from APP_URL’s origin.
Fetches from a URL (ingest_asset, POST /api/v1/assets with url) refuse
loopback, private and link-local addresses, checked on every redirect.
Previews of files the server can’t show as they are (PDF, PSD, HEIC, Office documents, video) are made by decoders that read whatever an upload holds, each in a process of its own with none of the server’s secrets: two at once, killed after 30 seconds (two minutes for LibreOffice and ffmpeg), and capped in memory. A file that is too slow or too big gets no preview; the upload stands.
With open sign-up, anyone can publish a portal, share links and public files
from your server. To take an organization down, suspend it: one key in its
limits row, "suspended": "reason", and its portals, links, BrandHub listings
and public files answer 451 on every host, while its people keep the app,
read-only. Only the operator can set it, in the database. See
Suspending an organization.
To take only one brand off BrandHub, delist it instead: set its
brands.hub_delisted to the reason.
Telemetry
Section titled “Telemetry”None. Artbucket sends nothing anywhere, and nothing a portal or BrandHub
visitor opens loads from anywhere else: a brand’s faces from Google Fonts are
imported into the library as files and served from /a/ like any font (see
Fonts). The one call a browser makes to Google is in
the app, signed in: picking a Google font previews the families in the
picker. The Docker image sets
NEXT_TELEMETRY_DISABLED=1, and better-auth’s telemetry is off. For a build
from source, set NEXT_TELEMETRY_DISABLED=1 too (the systemd unit in
Bare VPS does).