openGym is a self-hosted app: you run the server, you hold the data. This file says which versions get fixes, how to report something privately, and — the part most people actually need — what the app protects you from and what it doesn't.
Only the latest release. Releases are semver tags (v1.0.0 → v1.2.3, see
CHANGELOG.md); there is no LTS or maintenance branch and older tags are never
patched. A fix ships in the next release and in the latest images on ghcr.io.
Updating a self-hosted instance:
git pull && docker compose pull && docker compose up -dUse GitHub's private vulnerability reporting — repo Security tab → Report a vulnerability:
https://github.com/DuarteSantos8/openGym/security/advisories/new
Private reporting has to be switched on in the repository settings for that link to work (Settings → Advanced Security → Private vulnerability reporting). If it 404s, open a normal issue saying only "I need a private channel for a security report" — no details, no repro — and it will be enabled.
Please don't put a working exploit in a public issue if it can be used against other people's instances. Everything else (a crash you can only trigger on your own box, a scanner warning) is fine as a normal issue.
Useful in a report: the version or commit, whether you're running the prebuilt images or a
source build, your RP_ID/ORIGIN and what sits in front of the app, steps to reproduce, and
what an attacker gets out of it.
On response times: this is a hobby project maintained by one person alongside school. There is no SLA and no bounty. Expect days rather than hours, and longer during exam periods. If a week goes by with no reply, comment on the advisory thread — it's more likely to be a missed notification than a decision. If a report goes unfixed and you want to disclose publicly, say so in the thread; there's no objection, and no request to sit on it indefinitely.
api/server.js— forging or replaying a session cookie, bypassing passkey verification, reading or writing another user's data through/api/data, reaching/api/admin/*without being an admin, or creating a profile without a valid code whileINVITE_ONLY=1.- Frontend — XSS in the React app, or anything that lets a page on another origin read or change a signed-in user's data.
- Shipped deployment config —
docker-compose.yml,web/nginx.conf, the two Dockerfiles: a default that exposes something a self-hoster wouldn't expect to be exposed. - The published images
ghcr.io/duartesantos8/opengym-apiand-web.
- Anything that already assumes access to the host, to
./data, or to the Docker socket. The operator is trusted by design — see the security model below. - Admins reading their users' workout history. That is the documented purpose of the admin dashboard, not a leak.
- Missing rate limiting, brute force, or "I sent 100k requests and it got slow". The app has no rate limiting at all and doesn't pretend to; that belongs in the reverse proxy you put in front of it. Genuine amplification (one small request causing unbounded work) is in scope.
- Missing security headers (CSP, HSTS, X-Frame-Options) —
web/nginx.confsets none; TLS and headers are the reverse proxy's job. A concrete attack that headers would have stopped is still worth reporting. - Instances served over plain
http://on a LAN IP. Unsupported: passkeys don't work there and the session cookie isn't markedSecure. - Scanner output with no working exploit, and
npm auditfindings in build-time devDependencies (Vite, Vitest, Capacitor CLI) that never reach a running instance. - The GitHub Pages demo build — it has no backend at all, everything stays in that browser.
- Third-party content: the exercise image/GIF dataset and the CDN it's fetched from.
Read this before hosting openGym for anyone other than yourself.
- Passkeys only. No passwords, no email addresses, no reset flow. Registration and login are
verified server-side by
@simplewebauthn/serveragainstexpectedOrigin: ORIGINandexpectedRPID: RP_ID, and the authenticator's signature counter is stored and updated on every login (api/server.js:292-318,api/server.js:338-358). - Sessions are a signed cookie.
gymsidcarries<uid>:<expiry>:<version>plus an HMAC-SHA256 tag over it, compared in constant time (api/server.js:148-161). The key is 32 random bytes generated on first run and written to./data/secretwith mode0600(api/server.js:34-36). The cookie isHttpOnlyandSameSite=Lax, and getsSecureonly whenORIGINstarts withhttps:(api/server.js:29,api/server.js:198-201). - Any user can end every session they have.
POST /api/logout/allincrements that account's session version, and every authenticated request checks the version in the cookie against the one on the user record (api/server.js:167,api/server.js:187-188), so every cookie ever issued for the account — on every device, including a copy someone walked off with — stops verifying at once. Passkeys are untouched; signing back in works immediately. - Data is isolated per user by the session's uid.
GET/PUT /api/dataonly ever touchstate-<uid>.jsonfor the caller (api/server.js:375-392); no route lets a normal user name another user. - Disabling an account takes effect immediately. Every authenticated request and every login
is rejected for a disabled user (
api/server.js:184,api/server.js:357).
- Nothing in
./datais encrypted. It holdsdb.json(users, passkey public keys, push subscriptions, invite codes), onestate-<uid>.jsonper user with their complete workout history and body-weight log,secret, andvapid.json. Anyone who can read that folder — you, whoever holds the backups, whoever gets into the host — can read every user's data, and withsecretcan mint a valid session cookie for any account. If you host openGym for other people, they are trusting you exactly as much as they'd trust any server operator. - Admins can read everything. A user listed in
ADMIN_UIDS(or flaggedadmin: trueindb.json) gets every user's full history and body weight, can disable accounts, and can create or revoke invite codes (api/server.js:460-540). Off by default — a fresh instance has no admin. - Sessions can't be revoked one device at a time. Revocation is per account, not per
session:
POST /api/logout/allkills all of them at once and there is no device list to pick from.POST /api/logouton its own only clears the cookie in that one browser (api/server.js:361) — a copy taken beforehand keeps working. Sessions last 90 days by default, settable withSESSION_DAYS(api/server.js:26); each cookie carries the lifetime it was issued with, so changing the setting doesn't reach cookies that are already out. Deleting./data/secretand restarting still works as the instance-wide reset, and disabling an account still locks out one user completely. - CSRF protection is
SameSite=Laxand nothing else. There are no CSRF tokens. - User verification is preferred, not required. Both handshakes pass
requireUserVerification: false(api/server.js:297,api/server.js:343), so a passkey released without a biometric or PIN is still accepted. In practice: unlocked device ≈ account access. - One passkey per profile, and no recovery. Every successful registration creates a new
profile (
api/server.js:309-319); there is no route to attach a second passkey to an existing one, and no email or reset path. Lose the passkey and that profile is unreachable — only direct surgery on./datagets it back. - Disabling someone isn't a ban. They can still register a fresh profile with a new passkey
unless
INVITE_ONLY=1is set. - HTTPS is required and the app doesn't provide it. The API container speaks plain HTTP and
nginx listens on
:80(web/nginx.conf); TLS is your reverse proxy's job. Without it, browsers won't do passkeys at all (except onhttp://localhost) and the session cookie is sent in the clear. - No rate limiting anywhere. Nothing throttles logins, registrations or writes, and
POST /api/register/optionsstill answers whether an invite code is valid (api/server.js:272), so an invite-only instance on the open internet should have a rate limit in front of it. New invite codes are 16 hex characters — 64 bits (api/server.js:525) — which makes guessing one impractical even unthrottled; codes generated by earlier versions are 8 characters / 32 bits and still work, so revoke and reissue any that are still unused. The only hard limit in the app is a 5 MB request body (api/server.js:27). - A few endpoints answer without a session:
/api/health(which includes the total user count),/api/config(whether invite-only is on),/api/push/public-key, and the register/login handshakes. - Changing
RP_IDinvalidates every existing passkey. They were bound to the old hostname and will fail verification against the new one. The data stays on disk but is unreachable until each user registers again — as a new profile. Choose your hostname before anyone registers. - Guest mode never reaches the backend. That data lives unencrypted in the browser's
localStorageand is gone when the browser storage is cleared.