mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
docs(security): add 2026-08-04 whole-codebase security review (#1326)
Read-only security review of the full tree (Go server, admin panel, WASM plugin host, LiveKit voice, Tauri client). No code changes. Three findings, all the same defect class — a security predicate enforced at some members of a handler family but not all: - A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can clear their own role's channel deny and read private channels. - A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the type == "dm" guard their sibling getPermChannel carries, so the same role can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs. - A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five other DM interaction sinks perform, so a blocked user can ring the person who blocked them. Also records one non-vulnerability observation (backup restore writes to a hardcoded database path, silently no-opping when database.path is customised), the candidates rejected during verification, the areas verified clean, and the areas not examined. Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# 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 | OPEN |
|
||||
| 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 | OPEN |
|
||||
| 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 | OPEN |
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user