Skip to content

Fix/mesh isolation 4 shared records - #159

Open
Arsenii-Malov wants to merge 4 commits into
masterfrom
fix/mesh-isolation-4-shared-records
Open

Fix/mesh isolation 4 shared records#159
Arsenii-Malov wants to merge 4 commits into
masterfrom
fix/mesh-isolation-4-shared-records

Conversation

@Arsenii-Malov

Copy link
Copy Markdown

No description provided.

Arsenii-Malov and others added 4 commits September 4, 2026 01:01
All tenant servers share one MongoDB database and are separated only by the
`domain` field, but both of these routines are written for upstream's "one
server owns the whole database" assumption and act on everything they can see.

cleanup() runs unconditionally on every boot. It rewrote every tenant's user
and mesh documents, and ended with

    deleteMany({ meshid: { $exists: true, $nin: meshlist } })

which deletes anything whose device group is not in the list it had just read.
That statement sat outside the `if (err == null && docs.length > 0)` guard, so a
transient read error left meshlist empty and `$nin: []` matched every document
with a meshid — one boot could wipe every tenant's nodes. Reproduced on a
two-domain database: a tenant A boot deleted a tenant B device.

The call is now gated out of OpenFrame mode and the delete is removed from the
MongoDB branch entirely, so it cannot fire if the image is run without the flag.
What cleanup() repairs (pre-1.0 field formats, legacy event/power/smbios rows in
the main collection) never existed in this database; routine housekeeping is done
by RemoveMeshDocuments(), the explicit Remove() calls on device deletion, and the
TTL indexes.

removeInactiveDevices() runs hourly. It collected domains out of the fleet-wide
device-group cache, so one tenant enabling expireDevs made all servers delete
that tenant's devices — using the local pod's connectivity state, which never
holds another tenant's agents, so a foreign pod could delete a device that was
online on its own. It is now limited to this pod's domain.

Adds a write guard as a second layer: Set/SetUser/Remove refuse documents whose
domain is not ours, with OPENFRAME_CROSS_TENANT_WRITES=allow as a rollback
switch. It is the only way to show "zero writes outside our domain" in
production, since these writes never pass through the gateway.

Tests run against a real two-domain database; see test/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAcuUJKfVLEX4KpvpKd1pS
…nant

Two disclosure paths through the shared database, both reachable from a normal
tenant admin session (in this product every logged-in user's browser holds one,
since the frontend opens control.ashx with the mesh service credentials).

getpluginpermissionlist answered with four unscoped GetAllType calls, returning
every user in the fleet — ids are user/<domain>/<name>, so other tenants' domain
keys and admin names come with it — plus every user group, device group and
device. Its sibling command 'users' does filter by domain, so this handler was
bypassing the filter its neighbour applies. Verified against a two-domain
database from a real admin session: before, both tenants came back; now, one.

changeuserpass looked up parent.users[command.userid] with no domain check at
all. The guard above it never trips, because our bootstrap admin *is* a full
site administrator, and the group check passes for an admin with no groups — so
a tenant admin could set the password and strip 2FA on another tenant's admin,
with the write landing in the shared database. Adds the same domain check
'deleteuser' already had, and the same to notifyuser, meshmessenger, emailUser,
smsUser and msgUser, which had the same shape (their transports are not
configured today, but the lookup was equally unscoped).

Adds GetAllTypeForDomain for the scoped reads. It deliberately does not project
the type field away the way GetAllTypeNoTypeField does: callers keep these
documents and write them back, and a document saved without `type` stops being
returned by every type query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAcuUJKfVLEX4KpvpKd1pS
The three startup loads in webserver.js were unscoped, so every pod held every
tenant's users, device groups and user groups in memory, keyed by full _id. That
is the root the previous two changes grew from: any lookup that does not
separately re-check .domain resolves a foreign object, which is how
removeInactiveDevices found other tenants' device groups and how changeuserpass
reached another tenant's admin. It also makes memory O(fleet) rather than
O(tenant), and hands ~heapdump a dump of the whole fleet.

The loads now go through GetAllTypeForDomain in OpenFrame mode and stay
unchanged elsewhere. Boot census on a two-domain database goes from
"users=2 deviceGroups=2" to "users=1 deviceGroups=1".

The empty domain is dropped from the changeStream filter at the same time, so
the cache and the change feed describe the same set of documents: '' is one
shared space in a shared database, and treating it as ours contradicts a cache
that now loads this domain alone.

Must not ship before the destructive-routine change: cleanup() and
removeInactiveDevices relied on these maps being complete.

`explain()` confirms {type, domain} uses the existing TypeDomainMesh1 index
despite it being sparse, because the query pins `type`. No new index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAcuUJKfVLEX4KpvpKd1pS
…ie keys

The previous changes filtered on `domain`. These four records have no domain at
all — upstream stores one of each per database, which on a shared database means
one for the whole fleet.

serverstats: written with no tenant marker and read with no filter, so every
tenant's "My Server" timeline was the summed load of all of them. The write now
stamps the domain; the read takes own rows plus rows that predate the field, so
the timeline does not go blank while those expire.

cfile/*: keyed by bare filename, and with autoSyncConfigFiles every pod pushes
its certificates there on boot — last pod to start wins, and all tenants ended
up with identical server certificates, hence the identical ServerID that agents
pin. The key is now cfile/<domain>/<name>, and reads fall back to the legacy
shared row. That fallback IS the migration: a pod pulls the shared certificate
and writes it back under its own key, so certificates stay byte-identical and
the ServerID does not change. Regenerating instead would mean reinstalling every
agent, since the agent sends the ServerID it expects and verifies the server
signature against it.

LoginCookieEncryptionKey / InvitationLinkEncryptionKey: one row per database
meant one signing key across all pods, so a cookie minted by one tenant's server
verified on every other. Now per domain, plus the domain check that was missing
where a ?login= token is accepted (every other cookie acceptance point had one).
Rotation invalidates cookies issued earlier: nobody is logged out of OpenFrame
and no login screen appears, but remote-access sessions open at that moment drop
and have to be reopened, so this wants a quiet window.

The '*' power marker written at boot is no longer written in OpenFrame mode: the
power collection has no domain either, and getPowerTimeline matches '*', so
every pod's restarts showed up in every tenant's device timeline.

Also resolves the tenant domain once per database instance instead of reading
the environment per call — the tests caught removeInactiveDevices scoping itself
differently depending on when the query ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAcuUJKfVLEX4KpvpKd1pS
@Arsenii-Malov
Arsenii-Malov requested a review from a team September 3, 2026 23:43
@Arsenii-Malov Arsenii-Malov self-assigned this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants