Files
OwnCord/docs/security.md
T
J3vbandClaude Fable 5 2b2d58abc1 feat(b2-6): safe audit coverage (S-02) (#1441)
* docs(b2-6): enumerate the security-sensitive mutations and their audit coverage

Step 1 of B2-6: the mutation inventory crossed with the 43 non-test
Audit( call sites at 67fdd18d, recorded in the plan's evidence block.
Invite create/revoke (S-02) and plugin install/uninstall have no audit
row; no timeout mutation exists (kick is force_logout / voice_mod_kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* added new doc

* docs(audit): update changelog and security documentation to include admin panel actions in audit log

* feat(b2-6): audit every security-sensitive mutation, invite and plugin rows first (S-02)

Step 2 of B2-6. TestAuditCoverage_* in service, api and admin drive each
mutation from the plan's inventory against a fake db.AuditStore
(Server/db/audittest) and assert the expected action arrives. Red before
this commit on exactly four rows: invite_create, invite_revoke,
plugin_install, plugin_uninstall.

- InviteService writes invite_create / invite_revoke naming the invite by
  id, never by code; RevokeInvite now takes the actor, threaded from the
  handler. A failed revoke writes nothing (test).
- The plugin admin handler takes a db.Auditor and writes plugin_install /
  plugin_uninstall against the RequireAdminAuth principal
  (admin.ActorIDFromContext, exported for that).
- docs/security.md lists the four new actions; CHANGELOG under Unreleased.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b2-6): denylist over the recorded audit detail corpus

Step 3 of B2-6. Each TestAuditCoverage_* table now ends with a subtest
that runs audittest.AssertSafeDetails over every entry its rows recorded:
a shape denylist (bcrypt/argon2 hashes, password=/token=/secret=/
recovery-code key-value leaks, otpauth URIs, Bearer credentials) plus the
fixture's own secrets (raw tokens, passwords and hashes, TOTP secrets and
codes, invite codes, message bodies). audittest_test.go proves each class
bites and that ordinary details pass. Zero hits on the corpus at HEAD, so
no call site needed changing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record pre-squash SHAs, red/green and denylist evidence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-6): audit committed invites on a canceled request; 404 unknown plugin uninstall

Codex P2s on #1441, both test-first:
- CreateInvite read the invite back on the request context, so a cancel
  after the insert committed returned an error and skipped invite_create.
  The read-back and audit now run on context.WithoutCancel, like the
  password-change tail. TestCreateInvite_AuditSurvivesCanceledLookup.
- Registry.UninstallPlugin is idempotent on an unknown id, so the handler
  wrote plugin_uninstall for plugins that never existed. It now checks the
  row first: 404 and no audit. TestPluginsHandlerUninstallUnknownID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record the Codex review outcome on #1441

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:11:37 +02:00

8.8 KiB
Raw Blame History

Security Policy

Security guidelines and vulnerability reporting for OwnCord.

Reporting Vulnerabilities

Report privately via GitHub Security Advisories: github.com/J3vb/OwnCord/security/advisories/new.

Do NOT open public issues for security bugs.

The repository-root SECURITY.md is the canonical reporting policy — what to include and the response timeline (initial response within 7 days) live there, so the two files cannot disagree.

What stays private, and for how long

This applies to weaknesses in the repository's own automation and settings — workflow authorization, credential scope, release gating — as much as to bugs in the server or client. Planning documents cite this section as the rule; it is written here so the citation points at something.

  • Public artifacts — commits, issues, pull request descriptions, changelogs — carry an opaque identifier, the affected property, safe acceptance criteria, and a status. Nothing more.
  • Reproduction steps, source-to-sink traces, exploit conditions, and the state a fix replaced stay in the private advisory. A commit that fixes a weakness describes the control it adds, not the gap it closes.
  • Every private finding has exactly one public owner, so nothing is tracked only in private and nothing is silently dropped.
  • Release notes may describe repaired impact after coordinated remediation, without the detail needed to reproduce it.

This repository is public. A commit message is a disclosure channel.

Two-Factor Authentication

OwnCord supports TOTP-based 2FA:

  • Users enroll via Settings > Account (QR code + backup codes)
  • Admins can enforce server-wide 2FA via the require_2fa setting in the admin panel
  • require_2fa requires all users to have 2FA enabled and registration to be closed
  • Login flow returns requires_2fa: true with a partial_token (10-min TTL, 5-attempt limit)
  • Auth challenges are rate-limited to 10 req/min per IP
  • TOTP code verification uses constant-time comparison (subtle.ConstantTimeCompare) to prevent timing side-channel attacks

Account Deletion

Users can delete their own account via DELETE /api/v1/auth/account with password confirmation. The last admin account cannot be deleted. After 3 failed password attempts, the endpoint locks out for 15 minutes.

Audit Logging

Security-relevant actions are recorded in the audit_log table with actor, action, target, and detail:

  • Auth: user_register, user_login, user_logout, login_blocked_banned, account_deleted, password_change, session_revoke
  • 2FA: totp_enabled, totp_verified, totp_disabled
  • Admin: role_change, role_create, role_update, role_delete, role_reorder, user_ban, user_unban, force_logout, setting_change, server_setup, api_token_create, api_token_revoke, config_write, invite_create, invite_revoke, plugin_install, plugin_uninstall
  • Content: channel_create, channel_update, channel_delete, channel_perms_update, channel_perms_clear, channel_user_perms_update, channel_user_perms_clear, message_delete, message_purge, emoji_create, emoji_delete
  • Profile: profile_update, identity_key_update
  • Ops: backup_create, backup_delete, backup_restore, ws_connect

Note: backup_restore is written synchronously to the live database before the pre-restore safety copy is taken, so the row survives inside the pre_restore_*.db backup. The restored database itself will not contain it — the restore replaces the database file wholesale.

Client Security Hardening

The Tauri desktop client implements the following security measures:

Credential Storage

  • Credentials are stored in the OS keyring (Windows Credential Manager / macOS Keychain / Secret Service) via the keyring crate, with every write read back and verified; if no keyring is available they fall back to an encrypted file (Windows DPAPI with CRYPTPROTECT_UI_FORBIDDEN, ChaCha20-Poly1305 elsewhere) — see credential-storage.md
  • Plaintext passwords are never returned to the frontend over IPC — only tokens are accessible from JavaScript
  • Auto-login uses stored tokens for reconnection, not passwords

Tauri Capabilities (Least Privilege)

  • Filesystem write access is scoped to $APPDATA/** and $APPLOG/** only
  • DevTools command is gated behind the devtools feature flag (excluded from release builds)
  • HTTP fetch is restricted to https:// origins plus http://127.0.0.1:* (the Rust TOFU proxy's loopback tunnel), and denies https://localhost[:*] and https://127.0.0.1[:*] — no legitimate flow reaches loopback over https, so the deny list keeps the renderer from probing other local services
  • http:allow-fetch is the only URL-scoped HTTP identifier. tauri-plugin-http validates the URL exactly once, in the fetch command; fetch_send and fetch_read_body operate on an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert and were removed rather than left in place advertising a control that does not exist
  • The https://* wildcard cannot be removed today: link previews (embeds.ts) fetch arbitrary user-posted URLs by design, and Tauri scopes per command, not per JS caller. Bounded in TypeScript by isPrivateHost/isBlockedForPreview, a 5 s timeout and a 50 KB body cap; the response is regex-scraped for og: tags and never executed
  • Regression-guarded by tests/unit/capabilities-scope.test.ts; rationale and the follow-up that would remove the wildcard are in docs/plans/tauri-capability-narrowing.md

TLS and Certificate Pinning (TOFU)

  • Self-signed certificates are supported via Trust-On-First-Use (TOFU) pinning
  • The WebSocket proxy (ws_proxy) pins the server certificate fingerprint on first connection
  • The LiveKit proxy (livekit_proxy) reuses the pinned fingerprint from the WS proxy
  • Certificate mismatch triggers a modal requiring user acknowledgment
  • Update downloads validate server_url uses https:// and rejects URLs with userinfo

Input Validation

  • IPC commands validate host format, string lengths, and character allowlists
  • PTT virtual key codes are validated to the Win32 range (1254)
  • LiveKit proxy remote_host is validated against CRLF injection
  • API client validates host format before constructing URLs
  • File uploads enforce a MIME type allowlist (images, video, audio, PDF, text)
  • Error messages from server responses are capped at 200 characters
  • Notification titles are sanitized (control chars stripped, length capped)

XSS Prevention

  • All user-generated content is rendered via textContent/setText — never innerHTML
  • The single innerHTML usage (SVG icons) operates on compile-time constants with a runtime guard
  • URLs are validated via isSafeUrl (rejects javascript:, data:, vbscript:)
  • YouTube embeds use sandbox attribute on iframes
  • image/svg+xml is excluded from safe MIME types for data URIs
  • GIF media URLs are validated against the trusted Klipy CDN origins
  • Linkified URLs strip trailing punctuation to prevent misleading destinations

Search and Rate Limiting

  • Client-side search requests are rate-limited (500ms minimum interval + 300ms debounce)

Known Limitations

  • Server auto-updates depend on a dedicated pinned minisign/Ed25519 server release key in Server/updater/server_update_public_key.txt and a signed release manifest that binds the shipped binary hash to the release version; Windows Authenticode/SmartScreen code signing is still separate work
  • CSP connect-src allows https: to any host (necessary for self-hosted server URLs not known at build time). Because of this, narrowing the Tauri http:allow-fetch scope alone would not bound exfiltration from a compromised renderer — the webview's own fetch reaches the same hosts without going through the plugin. Closing that requires narrowing connect-src and moving the link-preview fetch into Rust in the same change

Security Hardening Checklist for Operators

  • Enable TLS (self-signed is the default; custom certs recommended for production)
  • Keep invite-only registration enabled (default)
  • Set a strong admin password
  • Configure rate limits (defaults are sensible but review for your use case)
  • Run regular backups via the admin panel
  • Keep the server updated (admin panel shows available updates)
  • Firewall: only expose port 8443 (HTTPS); for voice/video also 7880-7881/TCP and 50000-60000/UDP (LiveKit signaling + media — see deployment.md); port 80 only when using ACME
  • Enable server-wide 2FA requirement once all users have enrolled
  • Set admin_allowed_cidrs to restrict admin panel access to trusted networks