If you discover a security vulnerability in Yuvomi, please report it responsibly. Do not open a public issue.
Instead, use GitHub Private Vulnerability Reporting to submit your report. This creates a private advisory visible only to you and the maintainers.
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if you have one)
What you can expect, in days rather than "soon", because a report that sits unanswered is the one failure a solo-maintained project cannot see from the inside:
- an acknowledgment within 7 days;
- a classification within 14 days - confirmed, duplicate of an existing advisory, or outside the scope below - with the reasoning, and a correction where the report's severity does not match the code;
- for a confirmed finding of high severity, a fix within 30 days, shipped as its own patch release cut from the last tag rather than from the head of
main, so it carries the fix, its tests and its documentation and nothing else. The steps are in docs/RELEASING.md.
Every advisory that is published gets a CVE requested through GitHub and names the reporter and the fixed version. These numbers were set on 4 September 2026, the day one report was answered after 99 days; they are commitments, not estimates, and the advisory list under Security shows whether they hold.
Yuvomi is designed for self-hosted deployment on a private network behind a reverse proxy with SSL. The security model assumes:
- The server is not directly exposed to the public internet without Nginx + TLS
- The admin controls all user accounts (no public registration)
- The host machine itself is reasonably secured
Vulnerabilities that require physical access to the host or root on the server are generally out of scope.
- Session-based auth with
httpOnly,SameSite=Laxcookies;Secureis opt-in throughSESSION_SECURE=trueand off by default, because a self-hosted instance on plain HTTP is a normal case and aSecurecookie is silently discarded there (Lax instead of Strict because Safari Intelligent Tracking Prevention blocks Strict cookies on reverse-proxy navigations and direct URL entry, which would cause 401 errors on login. CSRF risk is mitigated by the Double Submit Cookie pattern listed below and theSecureflag.) - CSRF protection via Double Submit Cookie on all state-changing requests
- Passwords hashed with bcrypt v6 (cost factor 12). Passwords are Unicode-normalized to NFC before hashing and verification, so non-ASCII characters (umlauts, accents) authenticate identically regardless of how the browser normalizes the input. Hashes created before this normalization are still accepted and are silently re-hashed to NFC on the next successful login
- Optional two-factor authentication (TOTP, RFC 6238) with ten single-use recovery codes, opt-in per account and enforceable household-wide by an admin. A correct password alone creates no session: the pending state is held under a session key deliberately distinct from
userId, so the guard that protects every route is blind to it, and it expires after five minutes. The session itself is regenerated once the second factor is accepted. A redeemed time step is recorded and refused a second time, as RFC 6238 section 5.2 requires - without it an intercepted code stays usable for the full tolerance window. Every route that accepts a code is rate-limited and counts all responses, not just rejected ones: with a stolen password each attempt would otherwise be a successful login. The shared secret is stored as-is because verification needs it (a hash cannot produce the next code) and is covered byDB_ENCRYPTION_KEYlike every other secret here; recovery codes are stored as SHA-256, deliberately not bcrypt, because they are ~49-bit random values from Yuvomi's own generator rather than passwords, and ten bcrypt runs per sign-in attempt would make the sign-in the target. Turning it off requires a valid second factor rather than the password: against a hijacked session only the factor helps, and SSO accounts have no password to prove anything with. SSO does not bypass it -/oidc/callbackruns the same check before creating a session, otherwise the household-wide requirement would bind only those who sign in with a password. That requirement blocks turning the factor off and flags accounts without one, but never rejects an existing session: in a household where nobody has set one up, that would lock everyone out including the admin - Invite links store only a SHA-256 hash of the token, never the token itself, so a leaked database cannot be turned into working invitations. They expire after 7 days, are single-use, and can be revoked at any time. Redemption happens in the same transaction that creates the user, so one token can never produce two accounts. Role and family role are taken from the invitation the admin created and are ignored in the redeeming request, so an invited member cannot make themselves an admin. The two public invite routes are rate-limited
- SSO identities are matched on the validated
subor a verified email address, never on a matching username - otherwise an account namedadminat the identity provider would take over the local admin account. Linking an SSO identity to an existing account therefore happens from inside a signed-in session: the start route is a CSRF-protected POST rather than a link, because as a plain GET a forged request could attach an attacker-owned identity to someone else's session. Asubthat already belongs to another account is refused, and unlinking is blocked while the link is the account's only way in. Who gets an account at all is a separate decision (#654): by default a first SSO sign-in provisions one, which suits a provider run for this household alone but hands the whole directory a way in when it is not.OIDC_ALLOW_SIGNUP=falsedrops the provisioning step only - matching a knownsuband linking by verified email both stay, so an admin-created account still binds on first sign-in while an unknown identity is turned away. The default remainstrue, since a security switch that flips during an update is a lockout rather than a switch, and only the literalfalsedisables it, so a typo cannot shut a household out of its own app - An account can be held without a password at all: it carries the
$oidc$placeholder instead of a hash, which no bcrypt comparison can ever match, so "this account has no password" is a state of the column itself rather than a flag that has to be honoured somewhere. Password reset knows the placeholder and refuses to overwrite it - both by username and by email, and for tokens issued before an account was converted, answering exactly like an invalid token so the difference reveals nothing. AUTH_ALLOW_PASSWORD_LOGIN=falsemakes SSO the only way in: login form, password login and password reset are switched off together, with the check on the route rather than only on the page. It is ignored - and the server says so on startup - unless OIDC is fully configured and an administrator is actually linked to the provider, so neither a typo nor a fresh installation can lock a household out of its own app. While it is in effect, the last linked administrator cannot be unlinked, demoted or deleted, because any of those would silently reopen every retained password. Guests of shared expenses keep their own password and reset: they are external accounts an admin creates, not members of the household's directory. Since #962 that exemption is also only shown where it exists - a household without such guests no longer gets a guest sign-in button it cannot use, and the endpoint that answers the question returns one bit rather than anything about the guests themselves.- The placeholder
SESSION_SECRETfrom.env.exampleis refused at startup. It is printed in this repository, so an instance running with it can be entered by anyone who can reach it - a forged session cookie is enough. Unlike the database-key guard this one also stops an existing installation rather than only warning: there, aborting would cost more than the mistake (a key change makes the database unreadable), while replacing a session secret only signs everyone out once - Login rate limiting (5 attempts/min per IP)
- API rate limiting (300 requests/min per IP)
- Content Security Policy via Helmet (
self-only) - Optional SQLCipher AES-256 database encryption, enabled by setting
DB_ENCRYPTION_KEY. The cipher ships inside thebetter-sqlite3-multiple-ciphersbinding, so Docker and bare-metal installs are covered alike and no system SQLCipher is required. If the key is set but encryption is unavailable, or the file on disk is still plaintext, the app refuses to start instead of silently storing data unencrypted. The same applies to the placeholder from.env.example: that value is published here, so a fresh installation refuses to start with it rather than encrypt against a known constant (an installation already running on it keeps working and is warned on every start, with the rotation steps). An existing unencrypted database is migrated once on startup, leaving a*.plaintext-backupcopy behind that you should delete after verifying the migration - Existing WebDAV documents protect their connection configuration: changing the URL, username, password, or base path requires explicit admin confirmation and a successful read test against an existing object; required connection data cannot be removed while WebDAV documents exist
- UI-managed WebDAV document-storage URLs are protected against SSRF: private, loopback, link-local, internal-DNS, and DNS-rebinding targets are rejected before persistence and during socket lookup. Trusted private-network targets require the deployment-controlled
DOCUMENT_STORAGE_WEBDAV_URLoverride - Google Drive document storage requests only
drive.file, creates no public permissions, and uses a Drive-specific redirect URI, session OAuth state anddocument_storage_google_drive_*token namespace. Calendar token and state records are never reused or broadened - Drive OAuth tokens, codes, folder IDs and raw Google responses are never returned by the API or intentionally logged. Disconnect deletes local Drive state without calling Google's revocation endpoint, so shared Calendar credentials are not revoked
- Reconnection validates the candidate account and access to an existing Drive-backed file before atomically replacing working tokens. Disconnect is blocked while Drive is selected or referenced by documents; connecting Drive never activates it for uploads
- The Outlook push requests only
Calendars.ReadWrite,User.Readandoffline_accessas delegated scopes, against Microsoft's/consumersendpoint, so the grant covers personal Microsoft accounts and no mail, contacts or files. The OAuth handshake carries a 32-byte random state held in the session, and the callback is refused if it does not match. Every account-management route is admin-only; the sync-target list that all members read returns display names and calendar keys only, never credentials - Outlook access and refresh tokens live per account row and are never returned by the API or intentionally logged - the account listing selects an explicit column set that excludes them. As with Drive, disconnecting deletes the local tokens without calling a revocation endpoint, so the grant survives at Microsoft until it is withdrawn under
account.live.com/consent/Manage. Already-pushed events are deliberately left in the remote calendar rather than deleted by a disconnect - The Outlook push is one-way by construction: no code path writes Graph event content into local events, and pushed events keep
external_source='local'. Yuvomi reads back only calendar metadata and theid/changeKeyof the events it created itself, so a compromised or edited remote calendar cannot inject content into the household's data - Every integration that follows a redirect does so under two rules the DNS-level check cannot cover, because neither is a property of the address: the target protocol must stay
http/httpsand may not step down fromhttpstohttp, andAuthorization,CookieandProxy-Authorizationare dropped when the origin changes. The second one matters most for CalDAV, WebDAV and DMS accounts, whose headers carry a plaintext password: without it a hostile or compromised server could hand itself a household's credentials with a single 302 to a foreign host. Same-origin redirects - a server sending/calto/cal/- keep their headers, so ordinary sync is unaffected. A third rule closes the gap the address check itself had (GHSA-9jh6-phj9-m6qr): Node consults the per-connection lookup hook only for host names, so a redirect to a literal IP such ashttp://169.254.169.254/used to be connected to unchecked. The client now asks the hook for an IP literal itself, on every hop including the first - Uploaded files are checked against their own content, not only against the type their data URL declares, which comes from the sender's browser and can say anything: a PDF, PNG, JPEG, WebP, GIF, SVG, or Word or Excel file must carry that format's signature (for SVG, the opening of an SVG document). Plain text and CSV have no signature and pass unchecked. Which types an upload accepts at all is still decided by the allow-list of each route
- Subscription logo discovery is SSRF-protected: only public HTTPS targets are fetched, every redirect is re-validated, and remote image responses are size/type constrained
- Recipe provider mirrors (Mealie, Tandoor) use the same SSRF-hardened client as ICS subscriptions and WebDAV storage, with the same DNS-rebinding check at socket lookup. A private or internal target requires the deployment-controlled
RECIPE_PROVIDER_ALLOW_PRIVATE_NETWORKopt-in. Tandoor names the host of a recipe image in its own API response; that URL is pinned to the configured account's origin before the account's bearer token is attached to it, so a mirrored server cannot direct the token elsewhere - Notification channels (Webhook, Gotify, ntfy) deliver over that same hardened client with the same DNS-rebinding check, and the channel form refuses a private, loopback, link-local or internal-DNS address on save (GHSA-f4w5-ggcc-7m5c). Until then the three providers called the bare global
fetch(), the one outbound path without the guard. A Gotify or ntfy server in the same Docker network or LAN requires the deployment-controlledNOTIFICATION_ALLOW_PRIVATE_NETWORKopt-in; the switch lives in the deployment rather than in the app because the boundary it draws is the one between an app admin and the host operator - Document management connections (Paperless-ngx, Papra) validate the configured
base_urlbefore every outbound request, and every adapter method routes through that check - including Papra's connection test, which builds its own request.DMS_ALLOW_PRIVATE_NETWORKgoverns it and is the only switch of its kind that defaults to allowing private targets, because a DMS is self-hosted by definition and normally shares the LAN or Docker network; set it tofalseto enforce the check. Note the weaker guarantee compared to the entries above: this is a pre-flight validation of the configured URL, not the per-connection DNS-rebinding check, because these adapters needFormDataand JSON parsing that the hardened client does not provide. A target that resolves public at validation and private at connect time is therefore still reachable - The Immich screensaver keeps its API key server-side: the browser receives asset ids and caption metadata only, and thumbnails are proxied. Both routes reject an id that is not a UUID before building an outgoing request, and the proxy rejects a response that is not an image. Configuring the connection is admin-only
- No API endpoint is accessible without session auth, apart from the entry points that are unauthenticated by design: login and first-run setup, the second step of a two-factor sign-in (
/2fa/verify, which by definition runs before a session exists and instead requires the pending state described above), the OIDC handshake (/oidc/config,/oidc/start,/oidc/callback), self-service password reset (/forgot-password,/reset-password), invitation preview and acceptance, and the per-user ICS export feed, which authenticates with its own secret token instead of a session. Every one of them except the feed carries a dedicated rate limiter on top of the global API limit; the feed is polled by calendar clients on a schedule and is covered by the global limit alone SESSION_SECRETis mandatory - server refuses to start if unset- The container image is signed at build time (cosign, keyless, under the publish workflow's OIDC identity, by digest rather than by tag) and carries a build provenance attestation and an SBOM. The verify command is in docs/installation.md; a passing check proves that the image was built by this repository's workflow from a release tag, which is the question a store operator can answer without reading the code. It says nothing about the code being free of bugs
Yuvomi is a shared family planner, not a multi-tenant application: a household is one trust boundary, and Yuvomi will never gain tenant separation. Within that boundary there are three axes.
- Role. Admin can create, edit, and delete all user accounts and all shared data, and bypasses the two axes below entirely (so nobody can lock themselves out). Member can read and write shared data but cannot manage user accounts.
- Module permissions (per family role, overridable per member). A module can be set to
write(the default),readornonefor a member; dashboard widgets can be blocked the same way and inherit their module's lock. Storage is sparse - only deviations from the default are recorded, so an installation that never configures anything behaves exactly as it did before this existed. An invitation carries its own starting permissions, resolved when it is sent and applied in the same transaction that creates the account, so the rights are in force before the first login rather than being taken away afterwards. The stored default is untouched by that: what starts narrow is the preselected value of the invite form, not the fallback, because turning the fallback around would lock out the installations the sparse default was built to protect. - Per-row visibility. Tasks, calendar events and documents each carry their own visibility (all members / assignees only / private, and a named member list for documents). This one has no admin bypass: a private task stays hidden from a parent too, because the intended use is preparing a surprise. Health entries carry the same axis per row, and each person can set what their new entries start as, per vital metric and per area; the shipped value stays
private, so the opening is always a decision somebody made. A caregiver recording for somebody else writes the owner's choice, not their own. The rule binds the write paths as much as the read paths: updating or deleting a task or calendar event applies the same visibility clause and answers404rather than403, so a guessed id neither changes the row nor confirms that it exists. The calendar'sPUTandDELETElacked that clause until GHSA-fmrw-mmjw-5v9c, while the tasks routes had it from the start. The global search applies the same visibility clauses to calendar events as the calendar's own search; its calendar results checked only module access until GHSA-gjpr-85rg-587c.
The two lower axes are enforced server-side, on every surface that hands out household data - the REST API, the aggregating endpoints that no path-based guard can cover (dashboard, search, the kitchen bar, the reminder centre), and the MCP endpoint. The client-side maps exist to hide navigation entries, never to decide access. The module rule in particular lives in exactly one function that all of these call; when it was spelled out inline in the REST middleware alone, the MCP tools - which run in-process and never pass through that middleware - answered requests the REST API denied for the same person (#823). That function resolves a path to its module regardless of letter case, because Express routes /api/v1/Notes to the same handler as /api/v1/notes; while it compared letter for letter, a capitalised path matched no module and the member restriction let it through (GHSA-cvwj-hx37-3r7m). The reminder centre is the same shape from the other direction: its path resolves to a single module while the rows it returns name six, so a token scoped to the calendar could read a subscription's or an inventory item's title through a due reminder until the route began sorting by origin itself (#811).
Mail is the one surface that hands household data to an address rather than to a client, so the boundary is drawn twice there. A notification channel of type email and the shopping-list send both take a member id, never an address: accepting one from a request body would make the instance an open relay for anyone with a login, sending arbitrary content over the household's SMTP server and against its reputation. The server resolves the address from that member's contact, and a contact field holding a list rather than a single address makes the member unreachable instead of reaching everyone on it. For the shopping list the recipient must additionally be a household member in the strict sense - housekeeping staff and split-expense guests both have accounts and contacts with addresses, and the predicate that excludes them is written once and used by both the picker and the route, because a boundary drawn only in the interface is not one.
Two rules that a create path enforced and its edit path had lost were restored under GHSA-4p5w-5346-8598, and they name a pattern worth checking in any new module: whatever the POST validates, the PUT on the same resource validates too. A shared expense's payer and participants must be members of its group on update as on creation, otherwise a debt can be attributed to someone who cannot see it. And a housekeeping visit that has been paid is settled: changing, re-paying or deleting it requires an admin from then on, the same boundary that creating the worker has, while an unpaid visit stays a member's business. The visit's payment task is part of that boundary: reopening it used to mark the visit unpaid again, so moving the payment task of a paid visit out of done needs an admin as well (GHSA-82jf-c39w-vh8c).
An API token authenticates as a family member rather than as a credential of its own. Only an admin can create one, and an admin picks which member it acts as; that member supplies the role, the ownership of anything the token writes, and the module permissions resolved on every request. The creating admin stays recorded separately for the audit trail and grants nothing. A subject can therefore only narrow what a token reaches, never widen it: a non-admin subject cannot use admin-only routes, and optional token scopes remain an allow-list on top of the subject's own permissions. That allow-list binds on every surface, the account-management routes under /auth included: they are mounted ahead of the global scope gate so that login, setup and the OIDC handshake stay reachable without a session, and therefore re-check scope at their own entry. Without that a scoped token whose subject is an admin could mint an unscoped token or create an admin and escape the very boundary it was confined by (GHSA-xcv5-6w6x-x5q2). The aggregating endpoints are bound the same way: search:read opens the search and dashboard:read the dashboard, but every result or tile from a module the token cannot read is left out, so each module behind them needs its own scope (GHSA-g4f2-x2jf-4mwx). Split-expense guests cannot be selected as a subject, and deleting either the creator or the subject removes the token.
Only the latest release receives security updates, and a fix reaches it as a patch release cut from that release's tag, so that an installation updating for the fix gets nothing else (see the release cadence). There are no LTS branches, and there will not be while there is one maintainer; section 4 of docs/SCOPE.md says why and what would change it.