LOAM is an actively developed project and security fixes are applied to the latest released version.
At the moment, only the most recent release is considered supported for security issues.
| Version | Supported |
|---|---|
| Latest release | Yes |
| Older releases | No |
Please do not open a public GitHub issue for suspected security vulnerabilities.
If you believe you have found a security issue in LOAM, please report it privately, either through GitHub's private vulnerability reporting for this repository (https://github.com/MagicZebraLtd/loam/security/advisories/new) or by emailing:
Please include as much detail as you can, for example:
- a description of the issue
- steps to reproduce it
- the version, commit, or branch of LOAM affected
- the impact you believe it may have
- any proof of concept, logs, screenshots, or configuration details that would help reproduce it safely
You can encrypt your report if needed. If you want to use encrypted email, mention that in your initial message and we can arrange a suitable method.
We aim to:
- acknowledge receipt of your report within 5 working days
- assess and triage the report as quickly as possible
- keep you informed of the outcome where practical
- coordinate a fix and release before public disclosure where appropriate
This policy covers security issues in:
- the LOAM web application
- the LOAM server
- the host apps that run it: the Android app, the desktop app (Mac, Windows, Linux) and the
loamnetterminal app - shared LOAM packages in this repository
- authentication, identity, messaging, syncing, storage, and network communication code
- the official project repository and published packages, if any
It does not cover:
- vulnerabilities in third-party dependencies outside LOAM’s own code, unless LOAM uses them in an unsafe way
- general usage questions or feature requests
- issues in unofficial forks, deployments, devices, or infrastructure not maintained by the LOAM project
- reports about insecure local networks, routers, browsers, operating systems, or hardware that are not caused by LOAM itself
Please allow reasonable time for investigation and remediation before making any public disclosure.
Security research conducted in good faith is welcome and authorized, provided it is non-disruptive and directed only at a LOAM node or instance you control. This authorization does not extend to accessing data that isn't yours, denial-of-service testing, or any other destructive activity: those remain out of scope regardless of intent. Report any issue you find privately, before any public disclosure.
To the extent it is within our legal authority, we will not pursue, or support, legal action against researchers who follow this policy in good faith, even where a report turns out to describe intended behaviour rather than a bug. We cannot waive the rights of third parties (for example, the operator of a LOAM instance you don't control), so this commitment reaches only as far as our own authority to grant it.
LOAM is maintained by Magic Zebra Ltd, a small company, not a paid bug-bounty programme with contractual SLAs, so response times vary with maintainer availability. If a report is accepted as a genuine security issue, we will credit the reporter in release notes or documentation, unless they ask to remain anonymous.
LOAM's threat models range from open, relaxed settings such as disaster relief to settings where people face real risk from others nearby, and the server host is trusted (it can read all plaintext). The following are deliberate, documented trade-offs, not open bugs:
- Plain HTTP on the LAN (
http://<lan-ip>, no TLS/CA on a local hotspot). Not a browser secure context, so WebCrypto and service workers are unavailable. App-layer transport encryption is shipped (docs/08): QR-bootstrapped X25519/XChaCha20 session encryption,optionalby default,requiredin thehardenedprofile. Honest residual:optionalmode still accepts a plaintext client that joined without the QR key (a manually typed URL);requiredrefuses them. - No end-to-end encryption: the server processes plaintext (search, LLM, audience filtering). Optional E2EE is a future initiative (docs/07), strongest once a signed native client exists (a host-served PWA can replace its own JavaScript).
- The web client is delivered over plain HTTP, so the transport encryption protects the protocol, not the code that runs it. An attacker on the LAN who can rewrite traffic between a phone and the host while the page loads (ARP spoofing on an open hotspot, a malicious router) can serve a modified client that reads messages before they are encrypted, or forges what is shown. A passive listener learns nothing from the sealed traffic, and an attacker who can only impersonate the host at join time is defeated by the QR key; one who can alter the page load is not. This is architectural: only a signed native client closes it. The host's own windows (the Android app's WebView, the desktop app's Electron window) load the client from loopback and are not exposed; phones joining over the LAN are (docs/08, threat 4).
- Node-to-node sync peers are coarsely authenticated: an optional shared bearer token
(
sync.token) gates the sync endpoints, and only public content is exposed with defensive imports (docs/11). Honest residuals: peer transport keys are TOFU-learned unless pinned by the admin; there are no per-peer credentials; and an authorized but hostile peer can attribute imported messages to an ordinary (non-privileged) local user id: signed authorship is the tracked long-term fix (docs/25). - On-device Android database encryption is shipped (SQLCipher via
better-sqlite3-multiple-ciphers, docs/04) and fails closed: an
encrypted mode whose driver won't load locks rather than falling back to plaintext. OS cloud backup
and Android 12+ device-to-device transfer are both excluded. Runtime verification on a physical phone
(actual
PRAGMA key, rekey and wipe) is tracked in docs/21 and is not complete; the checks that exist are source-level and emulator-level. - Uploaded media (attachments, avatars) is stored as plaintext files outside the encrypted database: destroying the database key does not cryptographically erase media; the kill switch deletes the files, which is best-effort on flash storage. Media-at-rest encryption is tracked in docs/25.
- Logical delete is not secure flash erasure without encryption (docs/02).
- The desktop app (Mac, Windows, Linux; docs/24) runs the same server
in-process and keeps its database key as the Android app does: a device secret sealed by the operating
system's keychain (
safeStorage), a passphrase, or a key held only in memory. Its Electron fuses are closed (norunAsNode, noNODE_OPTIONS, no inspector) and it has no auto-updater. Honest residuals: the keychain flow is tested with fakes, not against each real keychain, and the Windows, Linux and Intel Mac builds have not yet been run on real machines. The Windows installer is not yet signed. - The optional online assistant sends the messages people write to the assistant, and the recent
history of that conversation, to the chat service the host chooses (OpenRouter, OpenAI and so on), under
that service's own terms. The admin page says where those messages go, and members see a notice naming
the service in the assistant's own chat, which each device acknowledges once before its first message is
sent. When the host changes where the assistant runs, the earlier history is not sent to the new
destination: the conversation's history starts again from that change. A
hardenednetwork only accepts a service, or an Ollama address, on the host or the local network, judged by the address as written (loopback, private and link-local addresses,.localand dotless names), not by resolving it. The service's API key is never returned by the API, but it is stored with the node's configuration, which is encrypted only when the database is (and is plain text if the host writes it intoconfig.jsonby hand).
How these reviews were done: the maintainer runs the reviews below with AI models, each working from the source with a written brief, and every finding is re-verified against the code and covered by a regression test before it is counted as fixed. There has been no third-party human security audit yet. If you do one, we will link it here.
Two reviews ahead of the 0.5.0 release, each finding re-verified against the source. Fixed on
fix/pre-release-2026-09-25:
Server: a profile edit could point a user's avatar at, and a later replacement delete, a file other
than their own avatar (imageId is now exactly avt_<16hex> and may only name the user's current
image); on an approval-policy node a successful admin claim left the claimer pending;
transportEncryption: "off" was still accepted from the admin API and config files (now refused, or
coerced to optional; plaintext is Developer Mode only); llm.ollama.botId could name an existing
person and turn them into a bot (now an llm.* id no human holds); a moderator-removed message could
still be edited by its author, replied to or reacted to; an upload racing an Emergency Reset could
restore the pre-wipe user or leave its file behind; moderator timeouts trusted the moderator's device
clock (now a duration, clamped to 7 days on the server clock); the claim and panic caps didn't count
tunnel re-dispatches; tunnel re-dispatches were request-logged with their real path and query,
undoing the tunnel's path hiding in the logs; the ephemeral and plaintext Emergency Reset paths left
preserved-recovery snapshots behind.
Sync and mesh: a sync peer could rewrite a post a local user wrote, re-type a private message id into the public flow, alias a local private attachment into an anonymous download, or undo a moderator's removal with its next edit; a peer-supplied mesh key could be adopted onto a local user; sealed-mail replay protection keyed on the unsealed outer id, and a carrier could shadow genuine mail with a forged hop budget, TTL or alternative base64 spelling; a node pulled exactly its own sealed mail, telling the serving peer where the recipient lives; the sync token could be sent on a plaintext pull; the mesh bridge accepted any loopback caller when no host token was set (behind a same-host reverse proxy, that is every LAN client).
Client: an unsealed tunnel reply on a pinned session reached the caller (an on-path attacker could
forge, for example, a mesh identity card); a #k= link could silently replace a pinned host key; a
member's invite QR could pass on an unverified key; a device that missed an Emergency Reset kept its
old cache under the next identity (it now purges when the server-confirmed identity changes).
Android host: an encrypted mode whose SQLCipher driver failed to load booted on plaintext (now fails closed); Android 12+ device-to-device transfer copied the database and media; the foreground service could be refused when started from the background, leaving the host frozen at screen-off; a release AAB could be silently debug-signed (now refused without a release keystore).
A second full read of the post-#118 code (server, client, Android host) with three parallel adversarial
audits, each finding re-verified against the source. Fixed on feat/review-fixes-and-server-split:
High: a QR-joined client on the default optional transport mode silently continued in plaintext
with a cookie identity when its handshake failed once: the fallback keyed off the unauthenticated
advertised mode rather than the QR-pinned effective mode, so an on-path attacker only had to drop one
handshake POST.
Medium: the Android host's firstUser grant was raceable from the LAN on every fresh-DB boot (the
server listens on all interfaces before the operator's own WebView loads); replaced by a hostDevice
bootstrap keyed on a per-boot launcher token that only the host's WebView receives. Passphrase mode
auto-unlocked from the device on every boot (the passphrase was stored beside the device secret), so it
protected nothing beyond persistent; the passphrase is now asked for at every start and never stored.
The WebSocket accepted 100 MiB inbound frames (library default) on pre-auth sockets, now capped at 16 KiB.
An Emergency Reset stranded previously QR-joined clients in a resume loop (nothing detected the changed
host key, now a mismatching handshake marks the pin broken and gates on a rescan, keeping the pin); a forged unsealed 401 on a GET could leave a socket silently deaf after the re-handshake;
ephemeral mode left uploaded avatars on disk across restarts; a system-stopped hotspot kept showing a
dead SSID/QR.
Low: a non-member's PATCH on a private channel returned 403 where a missing channel returned 404 (an existence oracle); sync import bypassed a local channel's posting policy and the node's feature flags; typing signals ignored the posting policy; sealed mesh mail materialised DMs on a node with DMs disabled; the "loopback-only" mesh bridge is reachable by any app on Android (now also token-gated); decrypted image blobs and rendered markdown outlived a wipe in memory; the native SQLite wrappers' transitive dependencies were unpinned.
Found clean: the transport hooks, tunnel path checks and replay window; identity resolution and
revocation; the WebSocket challenge and audience filtering; the mutation policy across every mutating
route; the markdown sanitiser and every HTML sink; #k= fragment handling; the key handoff, wipe resume
and supply-chain pinning on the Android host.
Round 2 (same day): three independent reviews of the fixes themselves found, and this branch closed, two regressions the first pass had introduced: the client's "key changed" handling could drop a QR pin and then fall back to plaintext mid-session (now: the pin is kept and marked broken, the client gates on a rescan, and a pinned client refuses plaintext outright); and the Android passphrase rework retired a pre-change install's stored passphrase at read time, so a boot whose key attempt was discarded (the launcher's 5 s bridge timeout) would have lost the only copy (now: retired only on the server's confirmed-open ack, which every passphrase-mode open sends). Also closed: "start fresh" after a mistyped passphrase keyed the new database with the typo; a co-located app could exhaust the host's own admin-claim budget (a correct host token is now honoured ahead of the limiter, and the client keeps the token until a claim succeeds); sync import skipped the posting policy on imported channels and reactions into locally archived ones; a setup code was minted on a host-token node where it could never be claimed. The server split was verified by a normalised per-function diff against master: no function or route missing or altered beyond the intended fixes.
An independent external review of v0.4.0-era code (commit 7cd5558) reproduced six findings:
three authorization-lifecycle gaps, a join-bootstrap gap, and two resource-limit gaps, all
confirmed and fixed on the feat/pre-tester-hardening branch:
Fixed: members removed from a private channel could still edit/delete their old messages there;
timed-out users could bypass moderation by editing existing messages; archived channels remained
readable and mutable through direct routes (archive semantics are now uniform: read-only but
available, with a separate permanent channel delete); the npx loamnet join QR omitted the #k=
transport key, leaving CLI first-joins open to an active MITM the QR key exists to prevent; the
advertised 1 MB attachment limit was unreachable (the default request body cap rejected the base64
envelope from ~790 KB); tunneled requests bypassed per-route semantic rate limits. Two further
review rounds over the fixes closed the follow-on gaps: reaction deletion bypassing the new gate,
edits bypassing posting-policy lockdowns and runtime feature shutdowns (DMs/replies), moderator
timeouts not covering channel creation/metadata/roster growth, and a non-member admin's client
retaining a deleted private channel.
Documented (not silently promised): uploaded media files live outside the encrypted database: see the threat-model list above.
Found clean: the transport cryptography, wipe paths, migrations, sync bounds, schema validation, and client privacy filtering; the full test suite, build, and typecheck passed.
An adversarial review covered every server route, the WebSocket audience filter, the DAL, the client markdown/XSS path and all HTML sinks, the QR/avatar generators, and the Android host.
Fixed: Android host admin lockout (the readiness probe consumed the firstUser admin grant;
fixed by adding a no-identity GET /api/health); banned/pending users can no longer edit their profile or
upload avatars; human-submitted message/reaction bodies are length-capped; the session cookie is
Secure based on real TLS (not NODE_ENV, which broke sessions on the http LAN); a strict CSP +
nosniff are served; Android OS backup disabled; QR rendering degrades gracefully instead of
throwing.
Deferred (tracked): unbounded anonymous user creation by a LAN participant who withholds their
cookie (a behavioural fix, to defer persisting a user until first participation, held back to avoid
destabilising identity semantics right before handoff; the /api/health fix removed the accidental
Android self-DoS contributor); release APK is debug-signed (needs a real release keystore, a manual
release-engineering step); sync author-id namespacing (belongs with sync peer-auth).
Found clean: the SQL layer (prepared statements; escaped SQLCipher pragma), the markdown
sanitizer and every dangerouslySetInnerHTML sink, the QR/avatar SVG generators, server
authorization/audience filtering, sync export scoping + defensive imports + resource caps, secret
handling (scrypt + constant-time + redaction), attachment path/traversal handling, and the Android
native surface. Dependencies are reputable and pinned.