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)

You should typically receive an acknowledgment within a few days (this is a solo-maintained project). Fixes for confirmed vulnerabilities will be released as soon as possible.

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, Secure cookies (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
  • 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
  • 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
  • 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_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
  • 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 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

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.
  • 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.

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), 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).

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. Split-expense guests cannot be selected as a subject, and deleting either the creator or the subject removes the token.

Supported Versions

Only the latest version on main receives security updates. There are no LTS branches.

There aren't any published security advisories