Skip to content

v1.9.0

Choose a tag to compare

@mstgnz mstgnz released this 26 Jul 18:49
· 15 commits to main since this release

Bucket isolation gets its remaining pieces, the image drops from 1.88 GB to 205 MB, and the auth path becomes observable.

Upgrade note: this release refuses to start with a weak TOKEN. See the bottom of these notes before deploying.

Security

  • The general TOKEN is now checked for strength at boot. It was previously only checked for emptiness, so a one-character token or a copied placeholder started the service and was accepted on every authenticated endpoint, operator routes included. It must now be at least 32 characters, the same floor bucket-scoped tokens already had, and must not be a template placeholder. See the upgrade note.

  • Rejected credentials and denied bucket access are now logged. Previously invisible: a credential-stuffing run, a misconfigured client and a scoped token probing other tenants all produced an error response and no record. Three events at warn, each with the method, path and Cloudflare-aware trusted client IP:

    • auth.failure when a credential is rejected, with the reason
    • auth.scoped_token_on_operator_route when a valid bucket-scoped token reaches /aws/*, /minio/*, /monitor or /metrics, recorded separately because that is almost always a misconfigured client rather than an attack
    • auth.bucket_access_denied when a scoped token names another bucket, with both bucket names

    No credential material is logged, in any form. Successful authentication is deliberately not logged: on a CDN that is the hot path and the access log already covers it.

  • The production image no longer contains a build toolchain. No compilers, no -dev headers, no source tree, no repository checkout.

Added

  • Optional expiry for bucket-scoped tokens. An entry may carry an expires_at RFC 3339 timestamp; omitting it means the token never expires, so existing files are unaffected. Expiry is evaluated per request rather than at load time, which matters under the read-once-at-boot model: a token stops working the moment it expires instead of lasting until the next restart. An expired token is rejected exactly like an unknown one. A mistyped timestamp fails the boot rather than being read as "no expiry", and the boot log warns about entries that have already expired or expire within a week.
  • Bucket-scoped tokens can be deployed on Kubernetes. k8s/secrets.yaml gained a tokens_json key, projected by k8s/deployment.yaml into the pod at /app/config/tokens.json from a read-only, optional secret volume. It holds credentials, so it is a Secret and not a ConfigMap. While wiring it, TOKEN itself turned out never to have been wired into the deployment at all, which meant the manifests could not boot; it now comes from cdn-secrets.app_token.

Changed

  • The image is built in two stages and is 9x smaller: 1.88 GB → 205 MB. The single-stage build shipped everything used to produce the binary, including 251 MB of Go toolchain. The runtime stage now starts from debian:bullseye-slim and receives only the binary, public/, the ImageMagick shared libraries and modules, and the runtime library closure, which was derived with ldd over the binary and every coder module and mapped to packages with dpkg -S. ca-certificates is installed explicitly: it is absent from the slim base, and without it every outbound HTTPS call would fail x509 verification.

    The hardened ImageMagick policy carries over. Because losing it would disable the hardening silently, CI now asserts the policy is live in the image that ships and that PNG, JPEG, WebP, GIF and TIFF still decode there while the PDF coder stays blocked.

  • ImageMagick is pinned instead of tracking the newest upstream release. Two builds of the same commit could previously ship different versions, and an upstream publish could break CI and deploys at once with no change here. The version and a SHA-256 checksum are now build arguments, and the tarball comes from the GitHub release assets, which are immutable. Pinned at 7.1.2-27.

    Worth knowing: the old archive host was serving a beta-labelled build of that version, so the image had been shipping a beta all along. The release asset is the final build.

  • CI is roughly 3x faster and, for the first time, passes. The workflow had never had a successful run: it ran go build on a bare runner with no ImageMagick, which the cgo binding requires. It now builds and tests inside the project image, with the layers cached in the GitHub Actions cache. A cached run takes about 157s.

Fixed

  • Per-IP rate limiting could be bypassed entirely. The rate-limit key was built from the raw, unverified Authorization header, so a client sending a different random token on every request minted a fresh counter each time and was never limited. The key is now derived from the verified identity of the credential (<ip>|general, <ip>|bucket:<name>, or the plain <ip> for anything that does not authenticate). This also removes token material from the Redis keyspace.

Upgrade notes

The service will refuse to start if TOKEN is shorter than 32 characters or is a placeholder value. Check it before deploying:

echo -n "$TOKEN" | wc -c     # must be >= 32

If it is too short, generate a replacement and roll it out to your clients:

openssl rand -hex 32

Everything else is backward compatible:

  • Deployments with no config/tokens.json behave exactly as before, using the general token alone.
  • Token files written before this release stay valid; expires_at is optional.
  • Rate-limit counters in flight at deploy time are orphaned by the new key format and expire on their own within the rate-limit window.

Kubernetes users: the manifests now wire TOKEN from cdn-secrets.app_token, which was missing before. Note that MINIO_ROOT_USER, MINIO_ROOT_PASSWORD and the AWS keys are still not wired into the deployment.

Full changelog: v1.8.0...v1.9.0