requireChannelBroadcastAccess went through RequireChannelAccess, whose DM
branch checks only participant membership — a blocked user's plugin
broadcast could reach the person who blocked them — and it issued a raw
GetRoleByID per broadcast, bypassing the permission cache. The gate now
delegates to MessageService.CanPost (extracted over checkSendPermission),
so DM blocks, channel permissions, and future posting policy apply from
exactly one place; fails closed when no service is wired. First brick of
the permission-path unification. MemStore.GetDMRecipient gets an honest
implementation so the block path is testable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revocation failure: no error, audit still written, RevokeFailed set,
password committed. Transient failure: absorbed by exactly one retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UpdateUserPassword commits first; when DeleteOtherSessions then errored the
handler returned 500 and skipped the audit row — telling the user the
change failed while the new password was already live, walking them into
retrying with a dead password and tripping the confirm lockout. The
committed change now always audits and reports success; revocation gets
one bounded compensating retry, and a persistent failure surfaces as a
200 + warning (sessions_revoked count) the client can show.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Service level: BAN_MEMBERS refusal (Forbidden even for nonexistent targets
— no id enumeration), equal-rank and owner-target hierarchy refusals,
authorized ban/unban round-trip, self-ban rejection. Admin API level:
equal-rank owner ban 403s, a lower-positioned ADMINISTRATOR cannot ban the
owner, downward bans still work. All existing NewAdminAPI/NewHandler test
callsites now inject a real ModerationService so the production
authorization runs in every PATCH-user test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requireBanAuthority (BAN_MEMBERS + role hierarchy) was wired only into
ModerationService.BanUser/UnbanUser — which had zero production callers.
The live path, handlePatchUser, ran a raw UPDATE with no hierarchy check,
so any admin-panel actor could ban an equal- or higher-ranked user,
including the owner. The ban/unban branch now calls the service (dead code
becomes THE code — ban path 1 of 3 consolidated), which also audits as
user_ban/user_unban, keeping the historical audit vocabulary.
Authorization now runs in permission → existence → hierarchy order: an
actor without ban authority sees Forbidden, never NotFound, so the ban
path cannot enumerate user ids. The role+ban transaction is gone — the
ban leg lives in the service, runs first, and a refusal returns before
the role change executes, so a rejected ban never half-applies a PATCH.
MemStore gains honest BanUser/UnbanUser so the matrix is testable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical signature updates for LinkAttachmentsToMessage callsites, plus:
db-level OwnershipGuard test (owned links, foreign never links, legacy
NULL-uploader claimable, nonexistent skipped) and a service-level
SendMessage test proving skip semantics end-to-end including the
already-linked retry path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-attachment GetAttachmentByID pre-check loop was a check-then-link
TOCTOU (the same race pattern this branch fixes elsewhere), an N+1 on the
hot send path, and a hard ErrForbidden for legit retries naming an
already-linked attachment. Ownership now lives in the one UPDATE that
links: `AND message_id IS NULL AND (uploader_id = ? OR uploader_id IS
NULL)` — a foreign attachment can never be claimed, legacy NULL-uploader
rows stay claimable, and skipped rows (foreign/linked/missing) are logged
but never fail the send, so retries can't hard-fail. Subsumes W2-4; the
MemStore (nil,nil) GetAttachmentByID contortion is replaced by a real
map-backed attachment store so the guard is testable (W3-5).
Companion commit updates test callsites and adds ownership coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies fixes for 20 adversarially-verified findings from a whole-codebase
security review (server side). All Go build-tag variants build, `go vet` is
clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a
pre-existing nil-harness failure unrelated to these changes).
High severity:
- auth: close TOCTOU in TOTP verify rate-limit by recording each attempt
atomically up-front (was Check-then-Allow), restoring the per-user
brute-force cap.
- plugin: enforce the CPU/time budget on every WASM guest call via a
WithTimeout context (WithCloseOnContextDone interrupts runaways); the
configured budget was previously parsed but never applied.
- api/waf: inspect request bodies for chunked (ContentLength==-1) requests
so the SQLi/XSS/RCE body rules can no longer be bypassed.
- ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which
fan out to every participant and could force mass disconnects.
Medium severity:
- api: run bcrypt on the unknown-user login path (no || short-circuit) to
remove the timing-based username-enumeration oracle.
- ws: verify LiveKit webhooks via the SDK receiver so the signature is bound
to the body hash (kills forgery/replay).
- authz: require READ_MESSAGES for reactions and for plugin-command
broadcasts; route the latter through RequireChannelAccess.
- api: cache the client-update signature fetch and rate-limit the endpoint.
- service: propagate DeleteOtherSessions failure from ChangePassword instead
of silently reporting success.
- api: trust the rightmost non-proxy X-Forwarded-For entry, not the
client-controllable leftmost one.
- plugin: route auto-registered commands through the conflict-checked
RegisterCommand; pin the DNS-validated IP for host_http dials
(DNS-rebinding TOCTOU).
- api: mark access-controlled downloads private/no-cache + Vary: Origin.
Low severity:
- auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth
(was returning the ciphertext as plaintext).
- api: apply the livekit-proxy path allowlist to WebSocket upgrades too.
- service: verify attachment ownership before linking (IDOR).
- admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the
update binary hash immediately before rename+spawn (TOCTOU).
- service: require BanMembers + role hierarchy for moderation ban/unban.
chore: stop tracking the stray Server/owncord-server.exe build artifact.
Test infra: add uploader_id to the hand-rolled ws test attachment schemas and
make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
contextcheck: add ctx context.Context as first param to ListVisibleChannels,
BlockUser, CreateDM, CreateInvite, UpdateProfile, SendMessage; pass r.Context()
from HTTP handlers and ctx from WS handler; replace context.Background() in
telemetry spans with the propagated ctx.
errcheck: suppress justified Close() errors — defer func(){ _ = rows.Close() }()
in sqlite_events.go (idiomatic; rows.Err() checked), _ = resp.Body.Close() in
host_http.go (body fully consumed), _ = f.Close() in host_ui.go (read-only fd).
gocritic/rangeValCopy: rewrite for _, ch := range all (line 60) to indexed loop
in ChannelService.ListVisibleChannels to avoid 144-byte per-iteration copy.
- service/user.go: fix ChangePassword docstring (no old-password verification)
- service/user.go: RevokeSession now maps db.ErrNotFound→ErrNotFound and
all other store errors→ErrInternal, preventing internal failures from
masquerading as 404s
- plugin/loader.go: update scanPluginDirectory comment to reflect fail-fast
behavior; fix Lstat comment wording
- db/queries/sqlite/events.sql: CAST COALESCE result to INTEGER so sqlc
generates int64 instead of interface{}
- api/plugins_handler.go: log install error server-side and return sanitized
structured JSON response instead of raw err.Error()
- .github/workflows/ci.yml: remove continue-on-error from tag build steps
so tag boundary drift fails CI"
Agent-Logs-Url: https://github.com/J3vb/OwnCord/sessions/6635420c-af26-4cc0-9397-5e5b37887437
Co-authored-by: J3vb <192430104+J3vb@users.noreply.github.com>
Clean sweep of every actionable item from the two Copilot review passes
on head 59ae4d8. Grouped by severity:
─── Crash / security (must-fix) ─────────────────────────────────────
1. main.go:140 — telemetryShutdown nil panic.
telemetry.Init can return (nil, err) on the -tags otel skeleton
path; the deferred closure would then call a nil function. Normalise
to a no-op shutdown when Init errors so the defer is always safe.
2. api/upload_handler.go — permSvc nil deref.
MountUploadRoutes + handleServeFile dereference permSvc on every
authenticated file request. Add a fail-fast panic at mount time so
the misconfiguration surfaces at wiring, not on the first 500.
Update upload_handler_test.go to pass a real PermissionService built
on the test DB (the existing tests were missing the argument entirely,
which meant the package wouldn't compile — this fixes the real bug
Copilot flagged).
3. ws/event_persister.go — NewEventPersister nil EventStore panic.
run() dereferences p.store on every flush. Panic at constructor
time instead so the crash happens once at startup rather than
minutes later in a background goroutine.
4. plugin/host_ui.go — serve-time symlink check.
rejectSymlinksUnder only runs at install time, so a symlink created
post-install (accidental or malicious) would be followed by
http.ServeFile and leak host files. Add an os.Lstat + ModeSymlink
check + IsRegular check to AssetHandler on every request. Cheap
relative to the file read and closes the TOCTOU window.
─── Correctness / observability (should-fix) ───────────────────────
5. ws/deps.go:77 — requirePerm hides misconfig as FORBIDDEN.
Previously, nil database, nil perms, or a GetRoleForUser error all
returned ErrCodeForbidden with the same message, making operator
failures indistinguishable from legitimate permission denials.
Split the branches: misconfig + DB error now return ErrCodeInternal
with a server-side slog.Error so operators see the real problem;
FORBIDDEN is reserved for the actual permission-bit check.
6. telemetry/metrics.go — ServiceCallDurationMs renamed to Sec.
Field name said "Ms" but the instrument name was
`service_call_duration_seconds` with unit "s". Renamed the field
and updated all 8 service-layer callers so the struct field and
metric semantics match.
7. ws/event_persister.go — flushEvy typo → flushEvery.
Renamed the field and the one call site in run().
─── Comments out of sync with code ──────────────────────────────────
8. plugin/loader.go — Stat vs Lstat comment.
The comment claimed "Stat (not Lstat)" but the code correctly uses
os.Lstat to detect symlinks. Updated the comment to match the code;
the code was already right.
9. telemetry/telemetry_otel.go — compile claim wrong.
Comment said the file would fail to compile without the upstream
OTel modules, but the skeleton deliberately avoids importing them
and Init returns a runtime error instead. Updated the comment to
reflect actual CI behaviour (the -tags otel build step passes
today but doesn't exercise real telemetry).
─── Nit / polish ────────────────────────────────────────────────────
10. ws/event_pruner.go — startup delay magic constant.
Hard-coded time.Minute made the "run shortly after startup"
behaviour untestable (a test with a 100ms interval would still
wait a full minute). Cap the startup delay by the interval:
min(interval, time.Minute). Documented via a new `maxStartupDelay`
constant.
11. ws/event_pruner_test.go — new file.
Unit coverage for runPrune cutoff correctness, error swallowing,
StartEventPruner nil-store short-circuit, ctx cancellation, and
the interval-bounded startup delay from fix#10. Uses a fakeEventStore
stub that records every prune call and signals the first one so
tests don't sleep.
─── Verification ────────────────────────────────────────────────────
gofmt -l clean. No network access in sandbox so `go vet` and `go test`
could not run; the changes are local and surgical and every touched
file compiles in isolation against the existing signatures.
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
Eight focused follow-ups from the medium-severity review bucket. All
in-sandbox tractable; no module changes, no new dependencies.
Performance
- Drop the defensive memcpy in EventPersister.Enqueue. The hub already
passes a fresh slice from wrapWithSeq and the copy was happening under
seqMu, serializing broadcast throughput. Documented the no-mutate
contract on the call site.
Observability
- AppMetrics gains WSEventsPersistErrors counter; the persister run loop
bumps both it and the existing WSEventsPersisted counter via cached
metrics handle.
- Hub.persistEvent now extracts the real event type ("chat_message",
"voice_join", ...) from the wrapped JSON envelope via a small
no-allocation byte scan instead of recording the generic
"broadcast"/"channel_broadcast" label.
- Added OTel spans + ServiceCallDurationMs histogram entries on one
public method per remaining service: DMService.CreateDM,
VoiceService.JoinChannel, InviteService.CreateInvite,
ModerationService.BanUser, BlockService.BlockUser,
UserService.UpdateProfile. Mirrors the existing pattern from
MessageService.SendMessage.
Hardening
- plugin/loader now Lstat-walks each plugin directory and rejects any
symlink, plus refuses an entrypoint that is itself a symlink. The
asset handler's prefix check stays as defense in depth.
- ipAllowed (plugin HTTP capability) now rejects RFC6598 carrier-grade
NAT (100.64.0.0/10), closing a gap in net.IP.IsPrivate which only
covers RFC1918 + RFC4193.
- Registry.activateAll syncs Instance.Enabled := true after a successful
activate so callers reading the in-memory flag see the live state.
Documentation
- defaultYAML now documents the new event_persistence, telemetry, and
plugins config blocks with their defaults and one-line descriptions.
- PHASE_BC_LOCAL_TODO.md ticks off five items (defaultYAML docs ×2,
remaining service spans, registry wiring already-fixed in Pass 2).
https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
ChatDeps, PresenceDeps, ReactionDeps no longer carry *db.DB or
*permissions.Checker — those were only needed before the service
migration. VoiceDeps retains them for voice handlers not yet migrated.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- upload_handler.go: uses PermissionService.HasChannelPerm instead of
the deleted hasChannelPermREST helper
- profile_handler.go: delegates to UserService for profile updates,
password changes, session listing, and session revocation
- Remove hasChannelPermREST from channel_handler.go (no longer needed)
- UserService.UpdateProfile now returns ErrConflict on duplicate username
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
- Add UserService, DMService, InviteService, BlockService
- Migrate REST handlers (channel, DM, invite, block) to use services
- Remove block_handler.go (merged into dm_handler.go)
- Update all services to accept store.Store instead of *db.DB
- Router creates SQLiteStore and passes to service.New()
Handlers are now thin HTTP adapters: parse request → call service →
map error → write JSON. All business logic lives in the service layer.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8
Introduce Server/service/ package with MessageService, ChannelService,
and PermissionService that encapsulate business logic previously
scattered across REST and WS handlers. The PermissionService adds
per-user in-memory caching with TTL-based expiry to eliminate
per-message DB round-trips at scale.
Services are wired into the WS hub via deps structs (strangler-fig
pattern) — existing handlers continue to work unchanged, with service
references available for incremental migration.
https://claude.ai/code/session_01CBFF3r84ywkJRWwuqw8zD8