Skip to content

Security: ulsklyc/yuvomi

SECURITY.md

Security Policy

Reporting a Vulnerability

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.

Scope

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.

Security Features

  • Session-based auth with httpOnly, SameSite=Lax cookies; Secure is opt-in through SESSION_SECURE=true and off by default, because a self-hosted instance on plain HTTP is a normal case and a Secure cookie 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 the Secure flag.)
  • 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 by DB_ENCRYPTION_KEY like 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/callback runs 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 sub or a verified email address, never on a matching username - otherwise an account named admin at the identity provider would take over the local admin account. A verified email address links only an account whose address nobody but an admin can have set (an account without a password, or an admin account); members maintain their own address, so a member account with a password is refused with a pointer to linking from inside, and an address on more than one account is refused as ambiguous rather than answered with a new account. Split-expense guests never take part, and a member cannot give their own profile, contact or a guest an address that already belongs to another 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. A sub that 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=false drops the provisioning step only - matching a known sub and linking by verified email both stay, so an account an admin prepared without a password still binds on first sign-in while an unknown identity is turned away. The default remains true, since a security switch that flips during an update is a lockout rather than a switch, and only the literal false disables 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=false makes 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_SECRET from .env.example is 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 the better-sqlite3-multiple-ciphers binding, 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-backup copy 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_URL override
  • Google Drive document storage requests only drive.file, creates no public permissions, and uses a Drive-specific redirect URI, session OAuth state and document_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.Read and offline_access as delegated scopes, against Microsoft's /consumers endpoint, 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 the id/changeKey of 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/https and may not step down from https to http, and Authorization, Cookie and Proxy-Authorization are 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 /cal to /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 as http://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
  • The Gravatar import is off unless the operator sets GRAVATAR_BASE_URL: while it is unset or empty nothing is sent, the route answers 404 gravatar_disabled, and the settings page does not render the button (GET /api/v1/auth/me reports gravatarAvailable and, for the hint that names the service, gravatarHost - the host name of the validated base URL only, never the URL, its path or credentials in it). Once set, it is one fetch that a household member triggers from Settings → Account, through the same SSRF-hardened client: https only, every redirect re-validated, an 8 second timeout, a 512 KiB cap, and the answer must be a PNG, JPEG or WebP by content signature, not only by header. A split-expense guest is refused with 403 not_a_household_member before anything is fetched. Only a SHA-256 hash of the member's own email address and the server's IP reach the service, never the address; the picture is stored like an upload and never fetched again, and no admin can trigger it for someone else. The fetch can take seconds, so the route snapshots the picture and the address before it and re-checks both, and that the account is still an active member, inside the write transaction: if anything changed, nothing is stored and the answer is 409 gravatar_stale
  • 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_NETWORK opt-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-controlled NOTIFICATION_ALLOW_PRIVATE_NETWORK opt-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_url before every outbound request, and every adapter method routes through that check - including Papra's connection test, which builds its own request. DMS_ALLOW_PRIVATE_NETWORK governs 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 to false to 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 need FormData and 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_SECRET is 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

Authorization Model

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), read or none for 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, deleting, ticking off or archiving a task, adding a subtask to it, and updating, deleting or resetting a calendar event apply the same visibility clause and answer 404 rather than 403, so a guessed id neither changes the row nor confirms that it exists. An event imported from a calendar subscription additionally follows its subscription: it exists for other members only while the subscription is shared. Everything that names a row follows the same rule: a reminder can only be set on, and only names, an entry its recipient can see; the points history names a task only to the person the points belong to and to those who can see the task; and a notification channel shared by the household receives only reminders for entries every member can see, never health or shift reminders. The calendar's PUT and DELETE lacked the clause until GHSA-fmrw-mmjw-5v9c; the task status route, subtask creation, reminders, the points history, events of unshared subscriptions addressed by id and household notification channels lacked it until GHSA-r3q8-gh3w-5qvw. 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.

Third-party modules sit outside all three axes, and the boundary around them is the act of installing one. A module is same-origin JavaScript: it runs in the browser of every member who opens it, with that member's session, so it can read and change whatever that member can, and the module permissions above only decide who opens it, not what its code does once open. Installing a module from Settings -> Modules -> Add custom module is therefore an admin act equivalent to copying files onto the server, and is treated as such. It is off by default: the operator switches it on with MODULES_ALLOW_WEB_INSTALL=true, and without that the install and delete routes refuse before reading a body. The reason is what the page changes, not how it is written: until now, putting same-origin script into modules/ needed filesystem access; with the page, a foothold in an admin's browser session is enough (an XSS, or a module that is already enabled), and what it writes stays after the session ends, after a password change and after a token is revoked. The session requirement cannot close that case, because that case runs inside the session, so the risk is one the operator chooses where modules/ is mounted rather than one every installation receives with an update (docs/DECISIONS.md, entry 12). Where it is on, it needs an admin signed in in the browser (API tokens, an admin's included, and the MCP endpoint are refused, so a leaked token cannot plant code in front of the household), it is CSRF-protected and rate-limited, and a new module starts disabled until an admin approves it, again in a browser session, so that the step which makes installed code live is no weaker than the step that placed it. That review state is kept in the module's folder (.yuvomi-install.json, approved), not only in the database: restoring an older backup or starting a fresh database over a kept modules volume cannot switch on what nobody has looked at, and since switching a module off writes approved: false there as well, it cannot switch on what an admin switched off either. Unlike the household switch for built-in modules, which leaves their routes open (docs/DECISIONS.md, entry 11), the switch for a third-party module is a hard gate on purpose: a disabled module has no route, is not served, and its assets answer 404. A replacement is new code as well: every replace, from GitHub or from a ZIP file, leaves the module disabled until it is approved again, because a branch or tag is no proof that the same code arrived. And the web interface removes or replaces only what it installed: a folder without an install record was placed on the server by hand, a delete or a replace that would reach it is refused, and it is removed there. What the installer protects against is the archive: size, entry-count and compression-ratio limits against ZIP bombs, refusal of symbolic links, path traversal (zip slip), reserved names, ZIP64 and encrypted entries, an allowlist of browser file types copied from the module's own folder only, downloads restricted to GitHub hosts behind the private-network guard, and nothing from the archive executed on the server. What it cannot protect against is the module itself: it does not review code, and a well-formed module that exfiltrates data or acts on a member's behalf installs as cleanly as an honest one. A report that an installed module can do what its members can do is not a vulnerability in Yuvomi; a way to install or enable a module without being an admin in a browser session, to install one while MODULES_ALLOW_WEB_INSTALL is unset, to have a module come up enabled without an approval in its record, or to make the installer write outside modules/<id>, is. Details in MODULES.md.

Supported Versions

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.

Learn more about advisories related to ulsklyc/yuvomi in the GitHub Advisory Database