Skip to content

Security

/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 403 until 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.

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.

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.

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.

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).