Security
How Otavi protects tenant data, and the hardening applied before the platform was opened to unknown users hub 0.47.0.
Trust model
- The application owns all authentication and authorization. The reverse proxy terminates TLS and routes; it does not gate app routes hub 0.46.
- Tenants are isolated by
tenant_idon every data table. Every counting or species-facing endpoint applies the tenant scope server-side; an omittedtenantIdmeans "the caller's tenant", not "all tenants" (hub admins excepted). - Roles: viewer < member < admin < owner, per tenant; plus a global hub admin. Routes that take a target tenant from the body re-anchor on that tenant, so an admin of one tenant cannot act on another. Routes that change an existing row (alert rules and channels, scheduled exports, alert acknowledgement, votes, annotations, comments, pins, field notes, a satellite's settings lock) check the role in that row's own team (
guardRowTeaminmiddleware/auth.ts, hub 0.64.3). Before, they checked the role in the caller's active team and only membership in the row's team: an admin of one team who was a viewer of another could edit the other's rules, and an admin of the row's team was refused when another team was active. - Lists that name a team in the query and serve admins (pending invites with their tokens, member lists) pin team API keys to their own team (hub 0.64.3; a key used to ignore
?tenantId=). - Votes and spectrogram annotations come from signed-in people only hub 0.64.5: with a team API key, the voter used to be whatever user id the request carried.
- Live listening (the satellite's real microphone) checks the satellite's own team: member or above there, and it must be the socket's team hub 0.64.5. Before, the client named the team and only that name was checked, so a member of one team could join another team's live session, which is keyed by satellite, or open it under the wrong team and break that team's own listening.
- Share links are public by design, so they carry only what the share page shows: species, time, confidence and badges, for promoted detections in a category the team shows (audio too). Until hub 0.64.5 the response also held the team id, the satellite's name (often a person's) and its exact coordinates; until hub 0.78.1 hidden categories were served too; since hub 0.79.0 a link carries its expiry, signed with the id, and opens for 90 days (
services/share-links.ts; links made before open until 90 days after the upgrade), and an alert sent to a channel carries a share link, so a rule posting to a chat channel could publish a link that played a recording of human voices to anyone holding it. Since 0.78.1 alerts skip hidden categories unless a rule names the species. - "All teams" (
__all__) is a hub admin's scope only (sessionScopeinmiddleware/auth.ts, hub 0.68.1). Every access check reads it as hub-wide, and the session resolver kept a client-sent?tenantId=__all__for a signed-in person who belonged to no team: such a person could read, change, archive, delete or take over any team's satellites, and reach every other route whose real check is on another team. No account on the hub was without a team, so it was never reachable there; it would have been as soon as self-registration opened, or anyone left their last team. Found by the route inventory made for the permissions registry, reproduced insecurity-all-teams.test.tsbefore the fix. - Every change is recorded hub 0.72.0: each route that changes something declares its audit entries and writes them through one helper that records who acted, from which address, and in which team, so an entry cannot miss its team again. Entries were added where there were none: minting a team API key, registering or re-registering a satellite (credentials handed out), fetching a satellite's logs, listening to a satellite live, deleting a comment or a field note, refitting the calibration, changing a mobile release's forced update or a model, revoking a remembered device, withdrawing a request to join. What stays unrecorded is declared as such, with the reason. Tests:
permissions-registry.test.ts,audit-teams.test.ts,audit-labels.test.ts. - Teammates see each other's names, not their email addresses hub 0.71.0: the verification leaderboard, the field notes and the team's activity feed name people; team admins keep addresses where they need them (members, invites, join requests), and hub admins in the audit log.
- One guard for every route hub 0.70.0: each route declares the action it performs and where its team comes from (
config.action,config.team), andapplyPermissionsinmiddleware/permissions.tsapplies the action's rule from the permissions registry before any route code. The role counts in the team the action concerns only: ten routes also checked it in the caller's active team, which refused a person acting in a team where their role allowed it. Actions that need a person refuse API keys on every one of their routes (a key used to reach the insert of a comment or a field note and fail there), a hub admin's?tenantId=__all__reaches the routes as every team (it used to reach the database as a team id), and the password re-checks and the passkey sign-in step are rate limited. Answers are the same everywhere: 401 for no one signed in, 403 for a role or a caller that the rule refuses, 404 for a team or an item out of reach. Tests:permissions-guard.test.tsholds every route to its declaration against anonymous callers, a satellite's key, a team key and a person who is no hub admin. - A satellite named in a statistics query or a report belongs to the scoped team, or it is not found hub 0.68.2. Expected species, weather correlation, the compare page and the PDF report read any satellite's name and exact position by its id, and the expected-species list marked as detected what any team had heard. The queue and worker routes (every team's jobs, the worker hosts) are for hub admins, and an alert channel's settings (webhook URLs, tokens) are returned only to the admins of its team, who edit them. Scheduled exports, whose delivery holds addresses and webhook URLs, are listed to the team's admins only (hub 0.69.0; any viewer could list them through the API). Tests:
security-reads.test.ts. - Deleting an account works the same whoever asks (
services/account-erasure.ts): a team's sole owner must hand the team over first, the person's satellites become the team's and are disconnected, and their email and IP leave the audit log. Before hub 0.64.5 a hub admin's deletion removed the row only.
Devices
Every mutating satellite route goes through one rule (deviceAccess in middleware/auth.ts), anchored on the device's own team. Before hub 0.51 those routes checked the role on the caller's active team and only membership on the device's team, so a person who was admin in one team and viewer in another could administer the second team's devices; the regression test for that lives in tenant-isolation.test.ts. Re-registration no longer adopts a device by name for anyone but its registrant or an admin (a name proves nothing about possession; the device id does). When a membership ends (removal, leaving, account deletion) the person's devices are disconnected by default: keys expired, broker login dropped. A phone must not keep uploading audio to a team its owner can no longer see.
Joining without an invite
Off by default: the platform setting allow_public_teams gates everything, and while it is off every team behaves as invitation-only whatever it saved. When on, a team admin can set join_policy to request or open and optionally list the team in the directory. These are the only routes where a stranger touches a team they do not belong to, so they are session-only (machine keys refused), rate-limited, audited, and answer one uniform 404 for "no such team", "invitation-only", "you are blocked" and "switch off". The directory exposes a name, a description and two counts, never members, places or detections. Approval never grants admin. A removed or declined person can be blocked (tenant_blocks); an admin's invitation is the explicit override and lifts the block.
Device keys (the long-lived key a phone or Pi holds) are confined to what devices call: their own satellite routes, their own detections feed, species names and photos, the APK and version checks. Everything else is 403 before route logic runs (deviceKeyGuard in middleware/permissions.ts, reading each route's deviceKey declaration), so a device key cannot vote, comment, read other devices' audio or browse team data even though it carries the member role. Every satellite route must name the key's own satellite, so the list and registration routes are out of reach even through a trailing slash (/api/satellites/, which reached both until hub 0.64.3). A satellite's device id works as proof of possession when it re-registers, so the hub shows it only to those who manage that satellite.
Mail templates escape every value a person typed (account name, team name, inviter, join message) and strip line breaks from subjects: a team named as an HTML link must not become one in an invitation sent to an arbitrary address.
Public team pages
The one team surface readable without a session (routes/public-team.ts, /t/:slug). Gated twice, the hub switch allow_public_pages (default off, separate from allow_public_teams because publishing to anonymous visitors is a bigger step than showing team names to signed-in users) and the team's own public_page; anything else is one uniform 404, including a malformed slug. What leaves the hub is decided in that one file and pinned by public-team.test.ts: aggregates, credited photos, recent firsts by day, daily totals. Never coordinates, device names, member names, ids, audio, confidence or a timestamp finer than a day. Only promoted detections count and the team's category visibility applies, so what a team hides in the app stays hidden here. Towns are a second opt-in (public_page_places): one phone in a small village would otherwise publish where someone lives. The response is cached for ten minutes but any settings save drops the cache, so turning a page off is immediate.
Sessions and tokens
- Session JWTs are
httpOnly,secure,SameSite=Lax, host-only cookies, signed HS256 (pinned on verify), 7-day expiry. - Each JWT carries the
token_versionit was minted with. A password change or reset bumpsusers.token_version, so every older session stops resolving immediately, and remembered MFA devices are dropped at the same time. - Password reset tokens: 256-bit random, SHA-256 at rest, single-use, 60-minute expiry. Invite tokens: 192-bit random, bound to an email when one is given (only that address can redeem), TTL clamped to a week. Share links: truncated HMAC of the detection id, promoted detections only, compared in constant time.
- API keys: 256-bit random, SHA-256 at rest. Tenant keys expire after a year; satellite keys are per-device and pinned to their satellite.
- MFA: TOTP (AES-256-GCM at rest) + WebAuthn passkeys + bcrypt-hashed backup codes.
Abuse resistance
@fastify/rate-limitguards/api/auth/*, tenant creation and invites.request.ipis the first address past at mostOTAVI_HUB_TRUST_PROXY_HOPSproxies (default 2, Traefik then nginx), each on a private network (middleware/proxy-trust.ts), so a spoofedX-Forwarded-Forcannot reset the limiter or forge audit rows. Since fastify 5.12 a bare hop count trusts nobody, which would give every visitor nginx's address and one shared login limit (proxy-trust.test.ts).- Login is uniform: a locked account and a wrong password return the same response, and the unknown-user path burns the same bcrypt work as a real one, so registered emails cannot be enumerated.
- Self-registration and tenant creation are platform-setting toggles; invite links bypass the registration toggle by design.
Browser hardening
- Security headers (HSTS,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy) are set at the edge (Traefik), by nginx for the SPA, and by@fastify/helmetfor the API. The API sends a strict CSP; the SPA ships a Report-Only CSP that becomes enforcing once validated. - CORS and the WebSocket upgrade accept only the app origin and the Capacitor WebView origins, never a reflected origin.
Outbound requests (SSRF)
User-supplied URLs (webhook alert channels, scheduled-export delivery) are validated before every send: https only, the hostname is DNS-resolved and rejected if it maps to a private, loopback or link-local address (or is a bare, non-qualified name), and redirects are not followed. This keeps a webhook from reaching internal services (postgres, redis, minio, the cloud metadata endpoint).
Ingest integrity
The MQTT broker pins each device to its own topic, but the tenant segment is a wildcard, so the hub does not trust the message body: tenant and satellite identity come from the topic and the database, and identifiers are validated before a storage key is built. A device cannot write into another tenant or forge an object key.
Storage
- MinIO buckets are private; no bucket is public-read and no presigned URLs are issued. Every object is streamed through an authenticated hub route that checks the object's tenant first.
- Uploaded images are validated by content (magic bytes), stored and served with the sniffed type plus
nosniffandContent-Disposition. CSV exports neutralise spreadsheet formula injection.
Mobile app
- Release APKs are signed with an offline keystore kept out of the repository; the build produces a non-debuggable release build with backups disabled, so device backups do not expose the stored hub key or MQTT password.
- The in-app updater downloads only over https from the configured hub, follows no redirects, and verifies the APK's SHA-256 before handing it to the system installer.
Operator checklist before going public
- Set strong values for
OTAVI_DB_PASSWORD,OTAVI_REDIS_PASSWORD,OTAVI_S3_ROOT_PASSWORD,OTAVI_MQTT_ADMIN_PASSWORD,OTAVI_MQTT_HUB_PASSWORDandOTAVI_AUTH_JWT_SECRET(32-byte random). These reach only the internal Docker networks, but should not be defaults. - Set
OTAVI_AUTH_MFA_ENCRYPTION_KEY(32-byte hex) so TOTP and webhook secrets are encrypted at rest. - Generate the Android release keystore once and back it up with the volume tree; losing it means installed apps can never be updated again.
- Decide the registration posture: invite-only, or self-registration on.
Follow-ups from the audit hub 0.62.0
- Weak-secret guard. The hub refuses to start when a secret is a placeholder from the example file, one of the code's own fallbacks, or too short to be random:
OTAVI_AUTH_JWT_SECRET(32+), the database, MinIO and broker passwords (12+), the internal API key and the metrics token when set (16+), and a malformedOTAVI_AUTH_MFA_ENCRYPTION_KEY. The log names each one.OTAVI_HUB_ALLOW_WEAK_SECRETS=1downgrades that to a warning for a development hub. - Hub admin by email.
OTAVI_AUTH_PLATFORM_ADMIN_EMAILSgrants the flag at registration and at a password login only. A signed-in user cannot claim it by typing a listed address into their profile: the profile route refuses listed addresses (403EMAIL_RESERVED), and the request resolver no longer promotes on the strength of the current email. Addresses are not verified, so a listed address with no account yet goes to whoever registers it first: the hub warns about it at every start until the account exists. - Per-device broker roles. Each device's Mosquitto login holds its own role,
sat-<team>-<device>, whose permissions name its team and its id (birdnet/<team>/<device>/..., eight publish and seven subscribe topics); the sharedsatelliterole with a wildcard team segment is gone (its last 14 logins, all for deleted satellites, were removed on 2026-09-27, and the broker no longer creates it at start). Roles are created at registration, replaced on transfer (the new one is attached and confirmed before the old one goes, so a device never has no access), removed on delete, and checked at every api start: devices set up earlier are moved over (each reconnects once), and a device already in order is only read, never rewritten or disconnected. - Log requests carry a random UUID instead of a timestamp-derived id.
- TOTP codes are single-use. An accepted code is remembered for its validity window, so a replay is refused.