Skip to content

Security

In the admin: General, then Security. See the Settings reference.

Read this before exposing Stackyard beyond your own network.

Stackyard serves plain HTTP and does not terminate TLS.

Run it on a trusted network, or behind a reverse proxy that terminates TLS and adds its own access control. See Use a reverse proxy.

  • Passwords are hashed with scrypt and a per-password salt, stored in PHC string format.
  • Session tokens are HMAC-signed and verified with a constant-time comparison.
  • Sessions expire after an idle lifetime, 12 hours by default. A session in use is reissued past the halfway mark.
  • Login is rate-limited to 5 attempts per IP per 15 minutes. Counters are in memory, so a restart clears them and they are not shared across replicas. Run a single instance behind any proxy.
  • Changing the password rotates the session secret, signing out every other device. Sign out all devices does the same without changing the password.

Authentication is only in force once a password is stored. Until then, the endpoint that sets one accepts the first caller. See First setup.

Stored secrets are stripped from the config before it reaches the browser. A populated field reports as set without returning its value, in config responses and in exports alike.

A secret is restored only for the request it was stored for. If a save changes where the credential would be sent, by editing a badge URL, its non-secret headers, or any non-secret field of a widget’s config, the stored value is not restored and Stackyard names what must be re-entered. Matching on an item’s id alone would let an imported config point an existing credential somewhere new.

Secrets are stored in plain text in apps.json on the data volume. Protect that volume with filesystem permissions and backups.

The server blocks outbound requests to private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges, in IPv4 and IPv6. It resolves the host, checks the address, then pins the resolved IP so the connection cannot be re-pointed after the check.

localhost is refused by name. Dotless hostnames such as Docker container names are trusted, as is the host IP set in General. Only http and https URLs are fetched.

RangeWhy
0.0.0.0/8this network (RFC 1122)
10.0.0.0/8private (RFC 1918)
100.64.0.0/10carrier-grade NAT (RFC 6598)
127.0.0.0/8loopback (RFC 1122)
169.254.0.0/16link-local, includes cloud metadata (RFC 3927)
172.16.0.0/12private (RFC 1918)
192.0.0.0/24IETF protocol assignments (RFC 6890)
192.168.0.0/16private (RFC 1918)
198.18.0.0/15benchmarking (RFC 2544)
224.0.0.0/4multicast (RFC 5771)
240.0.0.0/4reserved, includes broadcast (RFC 1112)
::1, ::IPv6 loopback and unspecified
fc00::/7IPv6 unique local
fe80::/10IPv6 link-local
ff00::/8IPv6 multicast

An IPv4 address inside an IPv6 literal is checked against the same table.

ALLOW_PRIVATE_IPS=true disables the guard entirely. Most homelab installs need it.

The guard limits what a compromised widget can reach. It does not protect against an admin, who can already point widgets anywhere.

HostWhy
cdn.jsdelivr.netIcon files, and three of the four icon catalogue indexes.
raw.githubusercontent.comThe dashboard-icons index and its files, when jsdelivr does not answer.
UnsplashWallpaper, only when the wallpaper source is Unsplash.
api.github.comThe update check, when About is opened, at most once an hour. Also two icon catalogue listings, while you type in the icon picker.

Nothing else leaves your network, and no usage data is collected.

Icons load through the server, which caches each one for 24 hours, so the CDN does not learn which services your dashboard shows. A catalogue index is read only while you type in the icon picker, and is cached for 24 hours. A dashboard load fetches none of them. Set the wallpaper to an image or a solid colour and Unsplash is never contacted.

The provided Compose file drops all capabilities, adds back only what is needed, sets no-new-privileges, and bounds memory, process count and log size.

Inside, supervisord runs as root to bind port 80 and start both processes. It drops the API to the unprivileged node user, and nginx drops its workers to nginx. The API, which parses untrusted input, never runs as root.

Every released image is signed with cosign using keyless signing, bound to the GitHub Actions workflow that built it and recorded in Sigstore’s transparency log.

cosign verify ghcr.io/sandobserver/stackyard:1.5.0 \
--certificate-identity-regexp '^https://github.com/SandObserver/Stackyard/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

Both flags matter. Without them cosign accepts a signature from any identity, which proves only that something signed the image.

Each release build also scans the image with Trivy and fails on a HIGH or CRITICAL finding that has a fix, so a flagged image never reaches a registry. A scheduled job rescans the published latest image every week, on both platforms, which covers a vulnerability disclosed after a release.

The build produces an SPDX SBOM listing what is inside the image. It is attached to the run as an artifact, and attested to the registry beside the signature, so it stays available after the artifact expires:

cosign verify-attestation ghcr.io/sandobserver/stackyard:1.5.0 \
--type spdxjson \
--certificate-identity-regexp '^https://github.com/SandObserver/Stackyard/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com

See Security policy.