Skip to content

fix: enforce guild-scoped auth for audit log websocket#265

Merged
BillChirico merged 1 commit intoVolvoxLLC:mainfrom
sirmalloc:codex/fix-audit-log-websocket-cross-guild-access
Mar 10, 2026
Merged

fix: enforce guild-scoped auth for audit log websocket#265
BillChirico merged 1 commit intoVolvoxLLC:mainfrom
sirmalloc:codex/fix-audit-log-websocket-cross-guild-access

Conversation

@sirmalloc
Copy link
Contributor

Describe your changes

Please describe your changes in detail.

Issue ticket number and link

What type of PR is this? (check all applicable)

  • Refactor
  • Feature
  • Bug Fix
  • Optimization
  • Database
  • Documentation Update
  • Infrastructure
  • CI/CD

Copilot AI review requested due to automatic review settings March 10, 2026 17:51
@github-project-automation github-project-automation bot moved this to Backlog in Volvox.Bot Mar 10, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 10, 2026

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced WebSocket connection security with server-side guild isolation, preventing unauthorized access to audit and log stream data across different guilds.
    • Maintained backward compatibility with existing authentication tokens while implementing new guild-bound security controls.
  • Tests

    • Expanded coverage for guild-level access validation and cross-guild isolation verification.

Walkthrough

This pull request enhances WebSocket ticket authentication with guild-scoped security. The HMAC ticket format is extended to include guildId, server-side tenant scoping is enforced, and backward compatibility is maintained for legacy tickets while tests are extended to validate cross-guild isolation.

Changes

Cohort / File(s) Summary
Documentation
CLAUDE.md
Added session notes documenting security fixes including HMAC format extension, server-side tenant scoping, guildId payload inclusion, and backward-compatible ticket parsing.
Guild-Scoped Ticket Validation
src/api/ws/auditStream.js, src/api/ws/logStream.js
Extended HMAC ticket validation to include guildId; auditStream enforces guild-matched filtering and connection-scoped guildId tracking; logStream adds backward-compatible support for both legacy 3-part and new 4-part ticket formats.
Ticket Generation
web/src/app/api/log-stream/ws-ticket/route.ts
Updated ticket creation to include guildId in the HMAC payload, generating guild-bound tickets in the nonce.expiry.guildId.hmac format.
Test Coverage
tests/api/ws/auditStream.test.js
Updated makeTicket helper to accept guildId parameter; added tests for guild-aware broadcasting, filter validation against authenticated guild, and cross-guild isolation scenarios.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is completely empty beyond a template structure; it provides no actual information about the changes, their rationale, or related issues. Complete the description by explaining the security issue being fixed, the changes made to enforce guild-scoped auth, and reference any related issue ticket.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security fix: enforcing guild-scoped authentication for the audit log WebSocket, which matches the core changes across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • 🛠️ Publish Changes: Commit on current branch
  • 🛠️ Publish Changes: Create PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR closes a guild-tenant isolation gap in the audit log WebSocket stream — previously, an authenticated client could receive or filter entries from any guild. The fix binds auth tickets to a specific guildId (HMAC now covers nonce.expiry.guildId), stores the guild on the ws object at auth time, and enforces that scoping on every broadcast and filter operation.

Key changes:

  • auditStream.js: validateTicket now returns { valid, guildId } from a mandatory 4-part ticket; handleFilter rejects any filter whose guildId doesn't match the authenticated guild; matchesFilter enforces guild isolation on both the no-filter and filtered paths.
  • logStream.js: Backward-compatible ticket parsing added to accept the new 4-part format without breaking existing log-stream clients.
  • route.ts (ws-ticket): Ticket issuance updated to sign over nonce.expiry.guildId, binding the credential to the requesting guild.
  • auditStream.test.js: Two new cross-guild isolation tests added. However, a pre-existing test at line 278 ("should NOT broadcast to client with non-matching guildId filter") is broken by the new enforcement — the cross-guild filter is now rejected rather than applied, leaving ws.auditFilter null, so a same-guild entry is broadcast and the timeout expectation fails. This needs to be corrected before merging.

Confidence Score: 3/5

  • The security logic is correct, but a pre-existing test is broken by the new enforcement and must be fixed before merging.
  • The guild isolation mechanism is well-designed and the new tests cover the important cross-guild scenarios. However, the existing test "should NOT broadcast to client with non-matching guildId filter" will fail with the updated code — the cross-guild filter is now rejected, leaving ws.auditFilter null, and the no-filter broadcast path then delivers the same-guild entry the test expects to be suppressed. This needs to be fixed (test entry should use guild_id: 'other-guild') before the suite passes cleanly.
  • tests/api/ws/auditStream.test.js — the test at line 278 is broken and needs updating before this PR is safe to merge.

Important Files Changed

Filename Overview
src/api/ws/auditStream.js Core security fix: validateTicket now returns { valid, guildId } from a 4-part ticket; ws.guildId is stored on auth; handleFilter rejects cross-guild filter attempts; matchesFilter enforces guild isolation on both the no-filter and filtered paths. Logic is sound with one minor defense-in-depth gap in the filter-present path.
src/api/ws/logStream.js Backward-compatible ticket parsing added — accepts both legacy 3-part (nonce.expiry.hmac) and new 4-part (nonce.expiry.guildId.hmac) tickets. Log stream itself intentionally does not enforce guild scoping. Logic and HMAC payload derivation are correct for both formats.
tests/api/ws/auditStream.test.js Good new cross-guild isolation tests added (no-filter drop + mismatched filter rejection). However, the pre-existing "should NOT broadcast to client with non-matching guildId filter" test (line 278) is broken by the new guild enforcement: the cross-guild filter is now rejected, leaving ws.auditFilter null, so a matching-guild entry is broadcast and the timeout expectation fails.
web/src/app/api/log-stream/ws-ticket/route.ts createTicket updated to accept and bind guildId in the signed HMAC payload, matching the new 4-part ticket format. guildId is validated from query params and guild-admin authorization is checked before the ticket is issued. No issues found.
CLAUDE.md Session notes appended documenting the security changes made in this PR. No code impact.

Sequence Diagram

sequenceDiagram
    participant C as Dashboard Client
    participant R as ws-ticket Route
    participant A as auditStream.js

    C->>R: GET /api/log-stream/ws-ticket?guildId=G1
    R->>R: authorizeGuildAdmin(request, G1)
    R->>R: createTicket(secret, G1)<br/>HMAC over nonce.expiry.G1
    R-->>C: { wsUrl, ticket: "nonce.expiry.G1.hmac" }

    C->>A: WS connect /ws/audit-log
    A->>A: handleConnection()<br/>ws.guildId = null

    C->>A: { type: "auth", ticket }
    A->>A: validateTicket()<br/>verify HMAC, extract guildId
    A->>A: ws.authenticated = true<br/>ws.guildId = "G1"
    A-->>C: { type: "auth_ok" }

    C->>A: { type: "filter", guildId: "G2" }
    A->>A: G2 ≠ ws.guildId (G1) → reject
    A-->>C: { type: "error", message: "Guild filter does not match..." }

    Note over A: broadcastAuditEntry(entry)
    A->>A: matchesFilter(filter, entry, ws.guildId)<br/>no filter → entry.guild_id === "G1"?
    A-->>C: { type: "entry", entry } only if entry.guild_id === "G1"
Loading

Comments Outside Diff (1)

  1. tests/api/ws/auditStream.test.js, line 278-298 (link)

    Existing test broken by new guild enforcement

    This test will now fail due to the new handleFilter rejection logic introduced in this PR.

    Here's the sequence of events with the updated code:

    1. makeTicket() defaults to guildId = 'guild1', so ws.guildId = 'guild1' after auth.
    2. The filter message sends guildId: 'other-guild', which differs from ws.guildId. handleFilter now rejects this with { type: 'error', ... }ws.auditFilter is never set and stays null.
    3. await q.next() on line 283 consumes the error response (not filter_ok), but that's fine since the result is discarded.
    4. broadcastAuditEntry({ guild_id: 'guild1', ... }) is called. matchesFilter(null, { guild_id: 'guild1' }, 'guild1') now hits the new !filter branch and returns entry.guild_id === authenticatedGuildId'guild1' === 'guild1'true.
    5. The entry is broadcast, so q.next(500) resolves instead of timing out — the rejects.toThrow('Message timeout') expectation fails.

    The test needs to be updated to use an entry from a different guild (e.g. guild_id: 'other-guild') to verify it is not broadcast, which aligns with the new cross-guild isolation behavior already covered by the new test added in this PR:

Last reviewed commit: 2ffc2b6

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens multi-tenant security for the audit log WebSocket by binding authentication tickets (and downstream filtering/broadcast) to a specific guild context.

Changes:

  • Updated audit-log WS auth to require guild-bound tickets and persist the authenticated guildId on the connection.
  • Enforced guild scoping on the server: mismatched filter requests are rejected and broadcasts are constrained to the authenticated guild even with no filter.
  • Updated ticket generation/validation logic and expanded WS audit stream tests for cross-guild isolation.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
web/src/app/api/log-stream/ws-ticket/route.ts Includes guildId in the signed WS ticket payload returned to the client.
src/api/ws/auditStream.js Requires guild-bound tickets, stores ws.guildId, enforces guild scoping in filters and broadcasts.
src/api/ws/logStream.js Adds backward-compatible parsing for both legacy 3-part and new 4-part tickets.
tests/api/ws/auditStream.test.js Updates ticket helper format and adds tests covering cross-guild isolation and mismatched filter rejection.
CLAUDE.md Documents session notes describing the security change and test updates.
Comments suppressed due to low confidence (1)

src/api/ws/auditStream.js:17

  • The protocol header comment lists server→client messages as auth_ok, entry, and error, but the implementation also sends filter_ok in handleFilter(). Since this block was touched while updating the ticket format, please update it to include filter_ok (and ideally note that guildId in filters is constrained to the authenticated guild).
 * Protocol (JSON messages):
 *   Client → Server:
 *     { type: 'auth', ticket: '<nonce>.<expiry>.<guildId>.<hmac>' }
 *     { type: 'filter', guildId: '...', action: '...', userId: '...' }
 *
 *   Server → Client:
 *     { type: 'auth_ok' }
 *     { type: 'entry', entry: { ... } }
 *     { type: 'error', message: '...' }
 */

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/ws/logStream.js`:
- Around line 205-220: validateTicket() currently accepts 4-part tickets but
discards the guildId and only returns a boolean; update validateTicket() to
return an object like { valid: boolean, guildId: string|null } and ensure it
extracts and returns the verified guildId (use the existing nonce/expiry/hmac
logic but include guildId in the returned payload when parts.length === 4). In
handleAuth(), capture the returned guildId and set it on the WebSocket (e.g.
ws.guildId = guildId) so subsequent logic can scope the session. Use that
ws.guildId when calling queryLogs({ limit: HISTORY_LIMIT }) to filter history
and ensure wsTransport.addClient(ws) / WebSocketTransport.passesFilter() receive
or read the guild context so broadcasts are guild-scoped (pass guildId into
filter calls or rely on ws.guildId inside passesFilter()). Ensure tests/usage of
validateTicket(), handleAuth(), queryLogs(), wsTransport.addClient(), and
WebSocketTransport.passesFilter() are updated to expect and handle the guildId.

In `@tests/api/ws/auditStream.test.js`:
- Around line 373-386: The earlier test "should NOT broadcast to client with
non-matching guildId filter" conflicts with the new rejection behavior; update
that test so it expects an error when sending a mismatched guildId instead of
awaiting 'filter_ok'. Specifically, in that test (the one using sendJson(ws, {
type: 'filter', guildId: 'other-guild', ... }) and consuming messages via
q.next() / createMessageQueue), change the assertion that awaited 'filter_ok' to
assert msg.type === 'error' and the message contains the same 'Guild filter does
not match authenticated guild' text (or adjust the guildId to match the
authenticated ticket if you want to preserve the original flow), ensuring you
update the sendJson/q.next assertions accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4ee32807-1d28-4555-9c8c-59b7650c5a3f

📥 Commits

Reviewing files that changed from the base of the PR and between 62f6c57 and 2ffc2b6.

📒 Files selected for processing (5)
  • CLAUDE.md
  • src/api/ws/auditStream.js
  • src/api/ws/logStream.js
  • tests/api/ws/auditStream.test.js
  • web/src/app/api/log-stream/ws-ticket/route.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,ts,tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,tsx,jsx}: Use ESM only — Use import/export, no CommonJS
Use single quotes — No double quotes except in JSON
Semicolons are always required

Files:

  • src/api/ws/auditStream.js
  • web/src/app/api/log-stream/ws-ticket/route.ts
  • tests/api/ws/auditStream.test.js
  • src/api/ws/logStream.js
**/*.{js,ts,tsx,jsx,json,md}

📄 CodeRabbit inference engine (AGENTS.md)

Use 2-space indent — Biome enforced

Files:

  • src/api/ws/auditStream.js
  • web/src/app/api/log-stream/ws-ticket/route.ts
  • CLAUDE.md
  • tests/api/ws/auditStream.test.js
  • src/api/ws/logStream.js
src/**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.{js,ts,tsx}: Use Winston logger from src/logger.js, NEVER console.*
Use safe Discord message methods — safeReply()/safeSend()/safeEditReply()
Use parameterized SQL — Never string interpolation in queries

Files:

  • src/api/ws/auditStream.js
  • src/api/ws/logStream.js
tests/**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Tests required with 80% coverage threshold, never lower it

Files:

  • tests/api/ws/auditStream.test.js
🧠 Learnings (7)
📚 Learning: 2026-03-08T04:37:50.316Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T04:37:50.316Z
Learning: Read `CLAUDE.md` before doing anything else and update it after completing infrastructure work with technical decisions and session notes

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T02:15:42.321Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-08T02:15:42.321Z
Learning: Applies to web/src/routes/dashboard/**/*.{ts,tsx} : Server-rendered dashboard entry pages should include static metadata for SEO and consistency with client-side title updates

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T02:15:42.321Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-08T02:15:42.321Z
Learning: Applies to web/src/lib/page-titles.ts : Dashboard browser titles should sync with route changes using centralized title helpers with the canonical app title `Volvox.Bot - AI Powered Discord Bot`

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T02:15:42.321Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-08T02:15:42.321Z
Learning: Applies to web/src/components/layout/dashboard-shell.tsx : Dashboard shell layout component should mount `DashboardTitleSync` to sync `document.title` on pathname changes for client-rendered pages

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T04:37:50.316Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T04:37:50.316Z
Learning: Applies to web/src/app/dashboard/**/*.{ts,tsx} : New dashboard routes must add a matcher entry to `dashboardTitleMatchers` in `web/src/lib/page-titles.ts` with exact equality for leaf routes and subtree check for subtrees

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T02:15:42.321Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-08T02:15:42.321Z
Learning: Applies to web/tests/components/dashboard/**/*.test.{ts,tsx} : Web dashboard config editor tests should cover category switching, search functionality, dirty badges, and explicit manual-save workspace behavior instead of autosave assumptions

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-08T04:37:50.316Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T04:37:50.316Z
Learning: Applies to web/src/app/dashboard/page.{ts,tsx} : Dashboard SSR entry points must export `metadata` using `createPageMetadata()` from `web/src/lib/page-titles.ts`

Applied to files:

  • CLAUDE.md
🔇 Additional comments (1)
src/api/ws/auditStream.js (1)

157-168: Server-side guild binding closes the no-filter bypass.

Binding ws.auditFilter.guildId to ws.guildId and then checking the authenticated guild again in broadcastAuditEntry() is the right place to enforce tenant isolation, even when the client omits a filter.

Also applies to: 245-267

@github-project-automation github-project-automation bot moved this from Backlog to In Review in Volvox.Bot Mar 10, 2026
BillChirico pushed a commit that referenced this pull request Mar 10, 2026
Thread 1: Pass guildId query param to ws-ticket endpoint
- useLogStream() now accepts guildId as first param
- fetchTicket appends ?guildId=<id> to the ws-ticket request
- Connection is skipped until guildId is non-null
- logs/page.tsx uses useGuildSelection() to get guildId and passes it

Thread 2: Update JSDoc for matchesFilter in auditStream.js
- Add @param for authenticatedGuildId
- Clarify 'no filter' behavior (falls back to guild_id === authenticatedGuildId)

Thread 3-5: Fix contradicting test in auditStream.test.js
- 'should NOT broadcast to client with non-matching guildId filter' was
  sending guildId:'other-guild' and expecting filter_ok — but the new
  server logic returns an error for mismatched guild filters
- Replaced with 'should NOT broadcast cross-guild entries to a
  guild1-authenticated client' which broadcasts an entry from 'other-guild'
  and verifies the guild1-authenticated client does NOT receive it
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
12.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@BillChirico BillChirico merged commit 30a5f57 into VolvoxLLC:main Mar 10, 2026
11 of 18 checks passed
@github-project-automation github-project-automation bot moved this from In Review to Done in Volvox.Bot Mar 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants