# OwnCord — Security Review

**Date:** 2026-08-04
**Branch:** `claude/security-review-workflows-oa0lm5` (audited tree: `cbc4f9e` = `dev`)
**Scope:** whole-codebase security review — Go server, admin panel, WASM plugin host,
LiveKit voice, Tauri desktop client. Read-only; no code changes ship with this audit.
**Relationship to prior audits:** successor to
[audit-2026-07-19.md](audit-2026-07-19.md), which remains the closure tracker for its
own findings. This review is security-only and does not re-open architectural items.

> **Note on scope selection:** this branch carries no diff against `dev`, so a
> pending-changes review had nothing to examine. The review was run against the full
> codebase instead.

---

## Finding closure status (maintained; update statuses in place)

| ID | Sev | Finding | Status |
|----|-----|---------|--------|
| A-2026-08-01 | HIGH | `handleDeleteChannelPermission` omits the role-hierarchy and grantability guards its `PUT` twin carries — a MANAGE_CHANNELS holder can clear their own role's channel `deny` and read private channels | RESOLVED 2026-08-04 — DELETE now carries the PUT twin's guards verbatim (role resolved with 404 on missing, fail-closed 401 without an actor role, 403 at/above own position). Pinned by `TestDeleteChannelPermission_RefusesEqualOrHigherRole` + `_UnknownRole` |
| A-2026-08-02 | HIGH | Admin channel `LIST`/`PATCH`/`DELETE` handlers omit the `type == "dm"` guard their sibling `getPermChannel` carries — a MANAGE_CHANNELS holder can enumerate and irreversibly destroy arbitrary DMs and group DMs | RESOLVED 2026-08-04 — list filters `type="dm"` rows; PATCH/DELETE resolve through the new `getAdminChannel`, which answers 404 for DM ids so the surface does not confirm which ids are private conversations. Pinned by `TestListChannels_ExcludesDMs`, `TestPatchChannel_RefusesDM`, `TestDeleteChannel_RefusesDM` |
| A-2026-08-03 | MEDIUM | `DMService.RingTargets` omits the block check every other DM interaction sink performs — a blocked user can ring the person who blocked them | RESOLVED 2026-08-04 — rings route through `requireDMNotBlocked` like every other sink (group DMs stay exempt inside it, matching the send path). Pinned by `TestCallRing_BlockedOneToOneForbidden` + `TestCallRing_GroupWithInternalBlockStillRings` |

All three share one root cause: **a security predicate applied at some members of a
handler family but not all of them.** The codebase states this rule in its own comments
(`handlers_channel_perms.go:348`: *"clearing a higher-ranked member's override is the
same authority as writing one, so gate it identically"*; `message_perms.go:95`:
*"the single block-check implementation, called from every DM interaction sink"*) and
then violates it in three places. See [§4](#4-systemic-observation) for the structural fix.

---

## 1. A-2026-08-01 — Missing hierarchy guard on channel role-override delete

* **Severity:** HIGH
* **Category:** `authz_bypass` / privilege escalation
* **Location:** `Server/admin/handlers_channel_perms.go:180` (`handleDeleteChannelPermission`)
* **Route:** `DELETE /admin/api/channels/{id}/permissions/{roleId}`
* **Attacker:** any authenticated user holding a role with `MANAGE_CHANNELS` and *not*
  `ADMINISTRATOR` — the seeded **Moderator** role (`0x000FFFFF`, position 60) qualifies.

### Description

The role layer of channel permissions is exposed as a `PUT`/`DELETE` pair, both gated
only by `r.Use(requirePerm(permissions.ManageChannels))` (`Server/admin/api.go:70`).

`handlePutChannelPermission` carries two deliberate escalation guards
(`handlers_channel_perms.go:141-150`):

```go
// Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR
// cannot grant bits their own role lacks via a channel override.
if err := requireGrantableOverride(actorRole, allow, deny); err != nil { ... }
// Hierarchy guard: a role override can only target a role strictly
// below the actor's own position, mirroring service.requireBelowActor.
if role.Position >= actorRole.Position { ... }
```

`handleDeleteChannelPermission` has **neither**. Its whole body resolves the channel,
parses `roleId`, and calls `database.DeleteChannelOverride`. It never reads
`actorRoleFromContext(r)` at all, so no position comparison is possible.

Deleting an override *is* a permission mutation. `EffectiveChannelPerms`
(`Server/permissions/permissions.go:161`) resolves to `(base &^ deny) | allow`, so
removing the row reverts the role to its bare mask. A private channel in OwnCord is
built precisely by writing a `deny` of `READ_MESSAGES` for the roles that must not see
it — so deleting your own role's row restores exactly the access the `PUT` path refuses
to grant.

The per-user sibling `handleDeleteChannelUserPermission`
(`handlers_channel_perms.go:333-352`) *does* guard, with a comment stating the rule the
role-layer delete breaks. There is also a dedicated regression test for the `PUT` case
(`TestPutChannelPermission_RefusesEqualOrHigherRole`) and none for `DELETE`.

### Exploit scenario

1. Owner creates private `#staff-only` and locks moderators out:
   `PUT /admin/api/channels/42/permissions/3` with `{"allow":0,"deny":2}`
   (`2` = `READ_MESSAGES`). The channel correctly disappears from the moderator's
   `ready` payload, `ListVisibleChannels`, REST reads, and reconnect replay.
2. Moderator confirms the override exists: `GET /admin/api/channels/42/permissions`.
3. Moderator tries the sanctioned path and is refused:
   `PUT .../permissions/3` `{"allow":2,"deny":0}` → `403 FORBIDDEN`,
   *"cannot manage a role at or above your own rank"* (position 60 ≥ 60).
4. Moderator sends **`DELETE /admin/api/channels/42/permissions/3`** with the same
   token. No guard runs. The row is deleted, `permInvalidator.InvalidateAll()` drops
   every cached verdict, and `hub.RefreshChannelVisibility(ch)` pushes a live
   `channel_create` to the attacker's socket.
5. Effective mask is now the bare `0x000FFFFF`, which includes `READ_MESSAGES`. Full
   history, pins, attachments and search on the private channel are readable — plus
   `SEND_MESSAGES`, `MANAGE_MESSAGES` and bulk purge, all of which the deny withheld.

The same request with `roleId=1` or `2` strips an override protecting Owner or Admin —
the exact cross-rank mutation the hierarchy rule exists to forbid.

### Recommendation

Give the handler the guards its twin has: load `actorRole := actorRoleFromContext(r)`
(fail closed on `nil`), fetch the target role, and refuse `403` when
`role.Position >= actorRole.Position` unless `permissions.HasAdmin(actorRole.Permissions)`.
Optionally also run `requireGrantableOverride` against the bits the row being removed
carries. Add the `DELETE` twin of `TestPutChannelPermission_RefusesEqualOrHigherRole`.

---

## 2. A-2026-08-02 — Admin channel handlers operate on DM channels

* **Severity:** HIGH
* **Category:** `missing_authorization_guard` — metadata disclosure + irreversible destruction
* **Location:** `Server/admin/handlers_channels.go:237` (`handleDeleteChannel`);
  same defect at `:159` (`handlePatchChannel`) and `:38` (`handleListChannels`)
* **Attacker:** same as A-2026-08-01 — a `MANAGE_CHANNELS` holder who is not an administrator.

### Description

DMs and group DMs are ordinary rows in the `channels` table with `type = 'dm'`, sharing
the autoincrement id space with guild channels (`migrations/009_dm_tables.sql`,
`migrations/013_channel_type_constraint.sql`).

The override handlers in the same package resolve channels through `getPermChannel`,
which explicitly refuses DMs (`handlers_channel_perms.go:40`):

```go
if ch.Type == "dm" {
    writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides")
    return nil
}
```

That guard is proof the authors knew DM ids reach this route family. Its siblings do not
have it: `handlePatchChannel` and `handleDeleteChannel` both call a bare
`database.GetChannel(r.Context(), id)` and inspect `ch.Type` nowhere.
`handleListChannels` returns `db.ListChannels` verbatim, whose SQL is
`SELECT ... FROM channels ORDER BY position ASC, id ASC` — no `type` predicate — so it
enumerates every private conversation on the server.

The shipped admin UI already renders these rows: `Server/admin/static/index.html:905`
suppresses only the *lock* button for `type==='dm'`, leaving **Edit** and **Delete**
live. `AdminDeleteChannel` is `DELETE FROM channels WHERE id = ?` with `foreign_keys`
enabled (`db/db.go:59`), and `messages`, `dm_participants` and `dm_open_state` all
declare `ON DELETE CASCADE` — destruction is total and irreversible.

### Exploit scenario

1. Moderator authenticates to the admin panel; `adminAuthMiddleware` admits them because
   `AdminPerimeter` (`permissions.go:42`) includes `ManageChannels` on its own.
2. `GET /admin/api/channels` returns every DM and group-DM row — ids, plus user-chosen
   group names, which act as a membership-graph oracle for conversations they are not
   party to, including the owner's.
3. `DELETE /admin/api/channels/{dm_id}` → no `ch.Type` check → cascade wipes the entire
   conversation: every message, every participant row, every open-state row.
4. Iterating step 2's ids destroys every private conversation on the server, including
   those of principals who strictly outrank the attacker. No hierarchy check, no
   participant check, no recovery short of a database restore.
5. `PATCH /admin/api/channels/{dm_id}` rewrites a group DM's `name` and `archived` flag,
   silently relabelling the conversation for its real participants.

### Scope correction

`PATCH` cannot expose DM message **content**: `AdminUpdateChannel` never writes `type`,
and every DM read path is independently participant-gated
(`permissions/checker.go:113`, `service/message_query.go:25`, `service/channel.go:128`;
`ws/hub_broadcast.go:340` skips `type=="dm"`). The real impact is **metadata
enumeration, irreversible destruction, and silent renaming** — integrity and
availability plus metadata confidentiality, not message-content disclosure.

### Recommendation

Route `handleListChannels`, `handlePatchChannel` and `handleDeleteChannel` through the
same DM-refusing resolver `getPermChannel` already implements, returning `404 NOT_FOUND`
so a DM's existence is not confirmed. Filter `type != 'dm'` out of the admin channel
listing — DM lifecycle already has its own participant-gated surface in
`service.DMService`. Add DM-rejection coverage to `admin/handlers_channels_test.go`,
which currently has none.

---

## 3. A-2026-08-03 — `call_ring` / `call_decline` bypass DM block enforcement

* **Severity:** MEDIUM
* **Category:** `access-control` — user-safety control bypass
* **Location:** `Server/service/dm.go:336` (`DMService.RingTargets`), reached from
  `Server/ws/handlers_call.go:45` (`call_ring`) and `:73` (`call_decline`)
* **Attacker:** any ordinary authenticated user who shares an existing 1:1 DM with the victim.

### Description

`requireDMNotBlocked` (`Server/service/message_perms.go:117`) describes itself as
*"the single block-check implementation, called from every DM interaction sink — send,
edit, react, pin and typing"*, and its doc comment explains precisely why partial
coverage fails:

> Enforcing it on the send path alone left a blocked user an open channel to the
> blocker: editing an already-sent message fans `MessageEditedDMEvent` out to every
> participant, so arbitrary new text still reached the person who blocked them, and
> reactions and typing indicators did the same.

Five sinks call it (`message_perms.go:79`, `message_crud.go:223`,
`message_reactions.go:113`, `channel.go:136`, `message_query.go:212`). `RingTargets`
does not — it checks `IsDMParticipant` and returns the other participants. Blocking does
not remove `dm_participants` rows (`service/block.go` only inserts a block row), so a
blocked user remains a participant, and `CreateDM` only gates *new* DMs — the normal
case is a pre-existing conversation.

The resulting `CallSignalEvent` is delivered straight to the target's live socket via
`SendToUserHigh` (`ws/emit.go:34`), with no block filtering at the hub. The client
surfaces it as a banner naming the sender plus a repeating chime for 30 s
(`Client/tauri-client/src/lib/call-ring.ts`). `call_ring` is limited to one per 3 s;
`call_decline` has no limiter at all.

### Caveat — this sits on a documented design boundary

`Server/ws/deps.go:190` states that blocking is *deliberately* not consulted on the
voice-**access** path (*"it is the message paths' rule … a blocked user is still a
participant"*). That comment governs `hasChannelAccess`, not the ring fan-out, and
`requireDMNotBlocked`'s own sink list does not name ringing. So the maintainers should
decide whether a ring is a message-path sink or a voice-path one. The argument for
treating it as a message-path sink is that it is functionally identical to the typing
indicator the project already hardened: an unsolicited, identity-bearing event pushed to
the blocker's client. Group DMs are correctly out of scope — blocks there are enforced
at `CreateGroupDM` by design.

### Recommendation

Call `requireDMNotBlocked` inside `RingTargets` alongside the existing `IsDMParticipant`
check. One call site covers both handlers and matches the five sibling sinks.

---

## 4. Systemic observation

All three findings are the same defect class: **one member of a handler family enforces
a security predicate and a sibling does not.** In every case the guard already exists,
correct, a few dozen lines away, and in two of the three the codebase's own comments
state the rule being broken.

This is the same shape as audit-2026-07-19's A-2026-07-07 and A-2026-07-16 (a
channel-visibility rule copy-pasted across five sites, and a server-permission rule
hand-rolled at two), both closed by collapsing the duplicates onto one shared predicate.
That remedy was applied to the *read* paths; these three are *write* and *notify* paths
that were not part of that sweep.

Suggested follow-up, in preference order:

1. **Make the resolver own the guard.** Handlers should not receive a `*db.Channel` they
   are trusted to validate. One `resolveManageableChannel(r)` helper that refuses DMs and
   enforces hierarchy, used by every handler in the admin channel family, makes the
   asymmetry impossible rather than merely fixed.
2. **Pair-test the mutation surface.** Every guard test that asserts a refusal on one
   verb should have a twin asserting the same refusal on the inverse verb. Both HIGH
   findings would have been caught by that rule alone.
3. **Audit the remaining families** for the same shape — the `PUT`/`DELETE`,
   `add`/`remove` and `REST`/`WS` pairs elsewhere in `admin/` and `ws/`.

---

## 5. Additional observation — not a vulnerability

**`Server/admin/handlers_backup.go:177` — restore writes to a hardcoded database path.**

```go
dbPath := filepath.Join("data", "chatserver.db")
```

`main.go:129` opens the live database at `cfg.Database.Path`, which is operator-settable
via `database.path` and `OWNCORD_DATABASE_PATH`. The restore handler ignores both. The
same file deliberately resolves `backupBaseDir` with `filepath.Abs` at init *"so handlers
don't depend on the process CWD (L14)"* — the database path never got the same treatment.

Because the shipped default *is* `data/chatserver.db`, this is latent: it only bites an
operator who changed the path or runs the server from a different working directory.
When it does bite, `POST /admin/api/backups/{name}/restore` copies the backup over a
decoy file, returns `200 "database restored — server restarting"`, and respawns against
the untouched original — a **silent no-op in the disaster-recovery path**. In an
incident-response context (rolling back to a known-good snapshot after a compromise) the
operator is told they have rolled back and has not.

A secondary failure sits on the same path: if `copyFile` fails, the handler returns `500`
*without* calling `requestRestart`, but the database was already `Close()`d — so the
process keeps serving every request against a closed database.

This is listed as an observation rather than a finding because no attacker controls it
and it requires a privileged operator action; it is a correctness bug with security
consequences, not an exploitable vulnerability. Root cause is structural:
`admin.NewAdminAPI` is never passed a `*config.Config` and `db.DB` exposes no path
accessor, so the admin package cannot learn the real path.
`admin/handlers_backup_test.go:336` `chdir`s to a temp dir and hand-creates
`data/chatserver.db`, encoding the hardcoded assumption instead of contrasting it with a
configured path.

---

## 6. Coverage and method

Two review rounds ran, deliberately along different axes so the second could catch what
the first's shape would miss.

**Round 1 — by subsystem (12 hunts):** authentication/session/TOTP, authorization,
injection, path/file handling, plugin sandbox, cryptography and the update channel,
admin panel and setup, WebSocket protocol, SSRF, uploads and media, client rendering and
IPC, data exposure. One finding (A-2026-08-01).

**Round 2 — by cross-cutting modality (6 hunts):** guard asymmetry between sibling code
paths, fail-open error branches in security decisions, sink-driven grep of the whole Go
tree, the unauthenticated surface, cross-user data boundaries traced up from the query
layer, and recently changed code. Two findings (A-2026-08-02, A-2026-08-03) — both of
which the subsystem-shaped round missed, which is the argument for running the second axis.

Every candidate was put through two independent adversarial reviewers (one instructed to
refute, one applying an exclusion policy) and, on surviving both, a final adjudicator
that re-traced the path from source. Findings below confidence 8/10 were dropped.

**Rejected during verification** (recorded so they are not re-raised):

| Candidate | Why rejected |
|-----------|--------------|
| `ws_proxy.rs` TOFU pins self-approvable via `accept_cert_fingerprint` | Only attacker is one who already has arbitrary JS in the webview, who can already call `load_identity_key` — strictly stronger. No marginal gain. |
| `ptt.rs` `ptt_set_key` omits the `is_allowed_ptt_capture_vk` allowlist | Same precondition. Impact is a lossy one-key-at-a-time oracle, not keystroke recovery; the actual keylogging primitive (`ptt_listen_for_key`) *is* still allowlisted. Worth a small fix, not a MEDIUM. |

**Verified clean** (read and found sound, recorded to save future effort): reconnect
replay authorization across both the hot ring buffer and cold `EventStore` tier,
including the empty-`channelIDs` degenerate case; group-DM membership churn
(`LeaveGroupDM` drops `dm_participants` and `dm_open_state` in one serializable tx);
LiveKit grant scoping (`RoomJoin` bound to `channel-<id>`, no `RoomCreate`, no wildcard);
the migration runner and its per-file transactions; `token_cli.go`; plugin
`host_storage.go`; the admin log-stream ticket (32-byte `crypto/rand`, single-use, TTL'd,
re-checks `HasAdmin` per frame); `client_update.go`; `proc_spawner_nix.go`; and the
metrics and diagnostics endpoints.

**Not examined.** Coverage was not total. No hunt targeted `Server/telemetry/`,
`Server/syncutil/`, `Server/stackutil/`, `Server/logctx/`, `db/dbgen/`,
`ws/topic_rate_limiter.go`, `ws/event_pruner.go`, `Server/scripts/`, or
`tools/mcp-introspect/`. Vulnerability classes not hunted include backup/restore
integrity beyond §5, SQLite-specific dynamic-`IN` construction, and protocol codegen.
