Skip to content

fix(matrix): correct DM classification with three-tier is_direct logic and 2-member guard#57124

Merged
gumadeiras merged 2 commits into
openclaw:mainfrom
w-sss:fix/56599-matrix-dm-classification
Mar 30, 2026
Merged

fix(matrix): correct DM classification with three-tier is_direct logic and 2-member guard#57124
gumadeiras merged 2 commits into
openclaw:mainfrom
w-sss:fix/56599-matrix-dm-classification

Conversation

@w-sss

@w-sss w-sss commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Problem

Matrix DM classification logic had multiple issues:

  1. Unreachable code branch when is_direct flag was absent (returned false instead of null)
  2. When is_direct: true, the code skipped the 2-member check, causing shared rooms to be misclassified as DMs after additional members joined
  3. Security issue: Explicit is_direct: false was ignored when the other side returned null

Fix

  • hasDirectMatrixMemberFlag() returns boolean | null to distinguish absent vs explicit false
  • isStrictDirectMembership() uses three-tier classification:
    • Priority 1: is_direct: true → DM (authoritative, but still requires exactly 2 members)
    • Priority 2: is_direct: false → not DM (explicit negative)
    • Priority 3: fallback to 2-member check when is_direct is absent
  • isDirectMessage() properly aggregates flags: true > false > null
  • Security fix: explicit false is now authoritative and never ignored

Key Insight

A room can start as direct chat but later become shared; we must keep the 2-member guard even when is_direct=true.

Testing

  • ✅ check
  • ✅ lint
  • ✅ Aisle Security (addressed findings)

Closes #56599

@aisle-research-bot

aisle-research-bot Bot commented Mar 29, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟠 High DM/room policy bypass by trusting sender-controlled m.room.member.is_direct
2 🟠 High DM detection bypass via attacker-controlled m.room.member.is_direct=false
3 🟡 Medium DM access-control bypass via fail-open fallback when is_direct member state is unavailable
Vulnerabilities

1. 🟠 DM/room policy bypass by trusting sender-controlled m.room.member.is_direct

Property Value
Severity High
CWE CWE-285
Location extensions/matrix/src/matrix/monitor/direct.ts:117-135

Description

createDirectRoomTracker().isDirectMessage() treats a room as a DM when either the remote sender’s membership state (m.room.member for senderId) or the local user’s membership state has is_direct=true.

In Matrix, m.room.member.content.is_direct is not an authoritative protocol-level signal for the other party; it is a per-user hint set by the member themselves. This means a remote user can set their own membership event field is_direct=true and cause the application to classify a 2-person room as a DM for the bot.

Impact (grounded in current code paths):

  • DM vs room classification directly affects enforcement of groupPolicy and room allowlists in the inbound event handler.
  • If a deployment restricts rooms (e.g., groupPolicy === "disabled" or allowlist mode), misclassification as a DM can allow messages/commands to be processed that would otherwise be dropped as a room message.

Vulnerable logic:

  • Remote-controlled input: senderId (event sender) -> hasDirectMatrixMemberFlag(..., senderId) reads remote user’s m.room.member state.
  • Decision: remote is_direct=true is considered “authoritative DM”, feeding isStrictDirectMembership() and later DM detection.

Vulnerable code:

const directViaSender = await resolveDirectMemberFlag(roomId, senderId);
...
const isDirectFlag: boolean | null =
  directViaSender === true || directViaSelf === true
    ? true
    : ...

Recommendation

Do not use the remote user’s m.room.member.is_direct to classify DMs for the local account.

Recommended approach:

  • Treat DM as true only when indicated by local account data (m.direct) and/or the local user’s membership state m.room.member for selfUserId.
  • Optionally keep the 2-member fallback as a non-authoritative heuristic only before seeding account data (as you already do), but do not allow remote is_direct to override policies.

Example fix:

const directViaSelf = await resolveDirectMemberFlag(roomId, selfUserId);
const isDirectFlag: boolean | null =
  directViaSelf === true ? true : directViaSelf === false ? false : null;

const strictDirectMembership = isStrictDirectMembership({
  selfUserId,
  remoteUserId: senderId,
  joinedMembers,
  isDirectFlag,
});

If you need “explicit DM” signals from the room, consider only signals the bot controls (its own account data / own membership state), not other members’ hints.


2. 🟠 DM detection bypass via attacker-controlled m.room.member.is_direct=false

Property Value
Severity High
CWE CWE-285
Location extensions/matrix/src/matrix/direct-room.ts:41-45

Description

The DM/room classification logic now treats an explicit is_direct=false in a user's m.room.member state event as authoritative and immediately forces isStrictDirectMembership to return false.

In Matrix, the content of a member's own m.room.member event is (in practice) under that member's control when they join/update profile fields. A malicious sender in a 2-person room can therefore publish is_direct: false on their membership event, causing the bot to misclassify an otherwise strict 2-member DM as a group room.

This changes downstream authorization behavior in monitor/handler.ts:

  • If the room is misclassified as isRoom=true, DM policy checks (e.g. pairing/allowlist) are skipped.
  • The message will instead follow room policy (groupPolicy, per-room allowlists, etc.), which may be configured more permissively than DM policy.

Vulnerable logic:

  • is_direct=false ⇒ immediate return false; (no fallback to 2-member strict check)
// Priority 2: explicit is_direct=false means not a DM
if (params.isDirectFlag === false) {
  return false;
}

and the flag is derived from untrusted membership state:

const directViaSender = await resolveDirectMemberFlag(roomId, senderId);
...
const isDirectFlag: boolean | null =
  directViaSender === true || directViaSelf === true ? true
  : directViaSender === false || directViaSelf === false ? false
  : null;

Impact: authorization bypass where DM-only restrictions (e.g., pairing) can be avoided by forcing the message down the room-processing path.

Recommendation

Do not treat is_direct=false from m.room.member state as authoritative for security decisions.

Safer options:

  1. Only treat is_direct=true as a positive signal, and treat false/missing as “unknown”, falling back to strict 2-member check:
// Only accept explicit true as authoritative
if (params.isDirectFlag === true) {
  return selfUserId && remoteUserId && joinedMembers.length === 2 &&
    joinedMembers.includes(selfUserId) && joinedMembers.includes(remoteUserId);
}// For false/null: fall back to strict 2-member check
return selfUserId && remoteUserId && joinedMembers.length === 2 &&
  joinedMembers.includes(selfUserId) && joinedMembers.includes(remoteUserId);
  1. Prefer a trusted DM signal (e.g., m.direct account data owned by the bot account) rather than remote-controlled membership content.

If you still want negative signals, require they come from bot-controlled sources (e.g., only self member event, or only after validating server-side semantics) and never use them to weaken authorization checks.


3. 🟡 DM access-control bypass via fail-open fallback when is_direct member state is unavailable

Property Value
Severity Medium
CWE CWE-284
Location extensions/matrix/src/matrix/monitor/direct.ts:112-166

Description

The Matrix DM classification logic was changed to treat missing userId and getRoomStateEvent failures as null ("unavailable") instead of false. In createDirectRoomTracker().isDirectMessage, when is_direct is unavailable it falls back to a permissive heuristic (exactly 2 joined members) and, before the DM cache is seeded, returns true (DM) even without authoritative signals.

This can misclassify a non-DM 2-person room as a DM if an attacker can cause:

  • getRoomStateEvent(roomId, "m.room.member", userId) to fail (permissions, rate limiting, transient network issues)
  • userId lookups to be missing/invalid

When misclassified as a DM, downstream policy decisions treat the room as a direct message. For example, monitor/handler.ts gates behavior differently for isDirectMessage vs isRoom (DM policy/pairing vs group allowlist/disabled), so this can enable DM-only flows or bypass group restrictions for a 2-person room.

Vulnerable code (fail-open to heuristic when state flag unavailable):

const directViaSender = await resolveDirectMemberFlag(roomId, senderId);
const directViaSelf = await resolveDirectMemberFlag(roomId, selfUserId);
const isDirectFlag: boolean | null =
  directViaSender === true || directViaSelf === true
    ? true
    : directViaSender === false || directViaSelf === false
      ? false
      : null; // both null

const strictDirectMembership = isStrictDirectMembership({
  selfUserId,
  remoteUserId: senderId,
  joinedMembers,
  isDirectFlag,
});
...
if (strictDirectMembership) {
  ...
  if (!hasSeededDmCache) {
    return true; // accept 2-member fallback
  }
}

And the root cause for null on errors:

} catch {// API/network error - treat as unavailable
  return null;
}

Impact depends on deployment policy, but in configurations where DMs are allowed more broadly than rooms (e.g., groupPolicy disabled/allowlist while DM policy is open/pairing), an attacker can interact with the bot through a 2-person room by inducing is_direct lookup failures (especially during startup before m.direct cache seeds).

Recommendation

Fail closed for DM classification when authoritative signals are unavailable, or at least avoid using the 2-member heuristic in security-sensitive gating.

Options:

  1. Treat getRoomStateEvent errors as explicit false (not DM) rather than null:
export async function hasDirectMatrixMemberFlag(...): Promise<boolean | null> {
  ...
  try {
    const state = await client.getRoomStateEvent(roomId, "m.room.member", normalizedUserId);
    if (state?.is_direct === true) return true;
    if (state?.is_direct === false) return false;
    return null; // only when field is absent
  } catch {
    return false; // fail closed on error
  }
}
  1. Gate the fallback so it only applies when you have positive evidence the room is intended as a DM (e.g., m.direct mapping) rather than when state is unreachable. For example:
const canUseHeuristic = isDirectFlag === null && client.dms.isDm(roomId);
const strictDirectMembership = isStrictDirectMembership({ ..., isDirectFlag: canUseHeuristic ? null : false });
  1. If the 2-member heuristic must remain, do not use it for authorization/policy decisions. Keep it only for UX hints/logging, and require m.direct or explicit is_direct:true for DM-only actions.

Analyzed PR: #57124 at commit b83ad87

Last updated on: 2026-03-29T15:31:16Z

Latest run failed. Keeping previous successful results. Trace ID: 019d3c60d9387e75d3c4186837dc6418.

Last updated on: 2026-03-30T09:47:26Z

Latest run failed. Keeping previous successful results. Trace ID: 019d40c8c9b9b563ae5f822ab2b71e4b.

Last updated on: 2026-03-31T06:41:53Z

@greptile-apps

greptile-apps Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR refactors Matrix DM classification to introduce a three-tier is_direct flag logic (true > false > null) and fixes a case where an explicit is_direct: false membership event was silently ignored. The changes are well-motivated and the core logic in monitor/direct.ts is correct, but there are two issues that limit the fix's completeness.

Key findings:

  • directMemberFlagCache type is stale (monitor/direct.ts:40): The cache is still typed as { isDirect: boolean } but hasDirectMatrixMemberFlag now returns boolean | null. Storing a null value produces a TypeScript type error under strict null checks.
  • Security fix is incomplete (direct-room.ts:111–115): inspectMatrixDirectRoomEvidence (and by extension isStrictDirectRoom) computes strict without ever fetching or passing isDirectFlag to isStrictDirectMembership. All callers of isStrictDirectRoom — including send/targets.ts, monitor/verification-events.ts, and direct-management.ts — will still misclassify a 2-member room as a DM even when both members have is_direct: false, which directly contradicts the stated security fix.
  • Redundant resolveDirectMemberFlag calls at lines 152–154 recompute values already available from lines 118–119 (minor style issue).

Confidence Score: 4/5

Not safe to merge as-is: a TypeScript compilation error in the cache type and an incomplete security fix across multiple call sites need to be addressed first.

Two P1 issues remain: a strict-null type error that would block compilation, and the stated security fix being absent from isStrictDirectRoom / inspectMatrixDirectRoomEvidence, leaving send/targets.ts, monitor/verification-events.ts, and direct-management.ts unprotected.

extensions/matrix/src/matrix/monitor/direct.ts (cache type) and extensions/matrix/src/matrix/direct-room.ts (inspectMatrixDirectRoomEvidence missing isDirectFlag fetch)

Important Files Changed

Filename Overview
extensions/matrix/src/matrix/direct-room.ts Introduces isDirectFlag param to isStrictDirectMembership and changes hasDirectMatrixMemberFlag return type to `boolean
extensions/matrix/src/matrix/monitor/direct.ts Correctly wires three-tier flag aggregation into isDirectMessage, but directMemberFlagCache type remains boolean instead of `boolean

Comments Outside Diff (3)

  1. extensions/matrix/src/matrix/monitor/direct.ts, line 40 (link)

    P1 Cache type not updated for boolean | null

    directMemberFlagCache is still typed with isDirect: boolean, but hasDirectMatrixMemberFlag now returns Promise<boolean | null>. When a null result is stored on line 97, TypeScript strict-null checking will reject the assignment as null is not assignable to boolean. The cache type needs to be updated to match the new return type.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/matrix/src/matrix/monitor/direct.ts
    Line: 40
    
    Comment:
    **Cache type not updated for `boolean | null`**
    
    `directMemberFlagCache` is still typed with `isDirect: boolean`, but `hasDirectMatrixMemberFlag` now returns `Promise<boolean | null>`. When a `null` result is stored on line 97, TypeScript strict-null checking will reject the assignment as `null` is not assignable to `boolean`. The cache type needs to be updated to match the new return type.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. extensions/matrix/src/matrix/direct-room.ts, line 111-115 (link)

    P1 Security fix doesn't cover isStrictDirectRoom / inspectMatrixDirectRoomEvidence

    The inspectMatrixDirectRoomEvidence function computes strict by calling isStrictDirectMembership without passing isDirectFlag, so the "explicit is_direct: false is authoritative" fix is completely absent on this code path. All callers of isStrictDirectRoom would still classify a 2-member room as a DM even when both membership events carry is_direct: false.

    Active callers include:

    • extensions/matrix/src/matrix/send/targets.ts:58 — target resolution
    • extensions/matrix/src/matrix/monitor/verification-events.ts:232, 453, 470, 530 — verification routing
    • extensions/matrix/src/matrix/direct-management.ts:86 — DM candidate classification

    The is_direct flag from both members should be fetched and passed into isStrictDirectMembership here, mirroring what was done in monitor/direct.ts.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/matrix/src/matrix/direct-room.ts
    Line: 111-115
    
    Comment:
    **Security fix doesn't cover `isStrictDirectRoom` / `inspectMatrixDirectRoomEvidence`**
    
    The `inspectMatrixDirectRoomEvidence` function computes `strict` by calling `isStrictDirectMembership` **without** passing `isDirectFlag`, so the "explicit `is_direct: false` is authoritative" fix is completely absent on this code path. All callers of `isStrictDirectRoom` would still classify a 2-member room as a DM even when both membership events carry `is_direct: false`.
    
    Active callers include:
    - `extensions/matrix/src/matrix/send/targets.ts:58` — target resolution
    - `extensions/matrix/src/matrix/monitor/verification-events.ts:232, 453, 470, 530` — verification routing
    - `extensions/matrix/src/matrix/direct-management.ts:86` — DM candidate classification
    
    The `is_direct` flag from both members should be fetched and passed into `isStrictDirectMembership` here, mirroring what was done in `monitor/direct.ts`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. extensions/matrix/src/matrix/monitor/direct.ts, line 152-154 (link)

    P2 Redundant resolveDirectMemberFlag calls recompute already-available values

    directViaSender and directViaSelf were already resolved and stored on lines 118–119. Calling resolveDirectMemberFlag again here is redundant (even if cached) and inconsistent with the === true pattern used for the viaMemberState field in direct-room.ts. Since isDirectFlag !== false at this point (due to strictDirectMembership === true), the || operator works correctly in practice, but reusing the earlier values is cleaner and avoids any ambiguity:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/matrix/src/matrix/monitor/direct.ts
    Line: 152-154
    
    Comment:
    **Redundant `resolveDirectMemberFlag` calls recompute already-available values**
    
    `directViaSender` and `directViaSelf` were already resolved and stored on lines 118–119. Calling `resolveDirectMemberFlag` again here is redundant (even if cached) and inconsistent with the `=== true` pattern used for the `viaMemberState` field in `direct-room.ts`. Since `isDirectFlag !== false` at this point (due to `strictDirectMembership === true`), the `||` operator works correctly in practice, but reusing the earlier values is cleaner and avoids any ambiguity:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/matrix/src/matrix/monitor/direct.ts
Line: 40

Comment:
**Cache type not updated for `boolean | null`**

`directMemberFlagCache` is still typed with `isDirect: boolean`, but `hasDirectMatrixMemberFlag` now returns `Promise<boolean | null>`. When a `null` result is stored on line 97, TypeScript strict-null checking will reject the assignment as `null` is not assignable to `boolean`. The cache type needs to be updated to match the new return type.

```suggestion
  const directMemberFlagCache = new Map<string, { isDirect: boolean | null; ts: number }>();
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/matrix/src/matrix/direct-room.ts
Line: 111-115

Comment:
**Security fix doesn't cover `isStrictDirectRoom` / `inspectMatrixDirectRoomEvidence`**

The `inspectMatrixDirectRoomEvidence` function computes `strict` by calling `isStrictDirectMembership` **without** passing `isDirectFlag`, so the "explicit `is_direct: false` is authoritative" fix is completely absent on this code path. All callers of `isStrictDirectRoom` would still classify a 2-member room as a DM even when both membership events carry `is_direct: false`.

Active callers include:
- `extensions/matrix/src/matrix/send/targets.ts:58` — target resolution
- `extensions/matrix/src/matrix/monitor/verification-events.ts:232, 453, 470, 530` — verification routing
- `extensions/matrix/src/matrix/direct-management.ts:86` — DM candidate classification

The `is_direct` flag from both members should be fetched and passed into `isStrictDirectMembership` here, mirroring what was done in `monitor/direct.ts`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/matrix/src/matrix/monitor/direct.ts
Line: 152-154

Comment:
**Redundant `resolveDirectMemberFlag` calls recompute already-available values**

`directViaSender` and `directViaSelf` were already resolved and stored on lines 118–119. Calling `resolveDirectMemberFlag` again here is redundant (even if cached) and inconsistent with the `=== true` pattern used for the `viaMemberState` field in `direct-room.ts`. Since `isDirectFlag !== false` at this point (due to `strictDirectMembership === true`), the `||` operator works correctly in practice, but reusing the earlier values is cleaner and avoids any ambiguity:

```suggestion
        const directViaState = directViaSender === true || directViaSelf === true;
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(matrix): correct DM classification w..." | Re-trigger Greptile

@w-sss w-sss force-pushed the fix/56599-matrix-dm-classification branch from b83ad87 to d46fbdd Compare March 29, 2026 15:34
@w-sss

w-sss commented Mar 29, 2026

Copy link
Copy Markdown
Contributor Author

@aisle-research-bot Thanks for the thorough security review! All three issues have been addressed:

Issue 1 & 2: DM/room policy bypass via remote-controlled is_direct (High) ✅ Fixed

Removed trust in remote sender's is_direct flag entirely:

Before (vulnerable - trusted remote user):
const directViaSender = await resolveDirectMemberFlag(roomId, senderId);
const directViaSelf = await resolveDirectMemberFlag(roomId, selfUserId);
const isDirectFlag = directViaSender === true || directViaSelf === true ? true : ...;

After (secure - only trust local user):
const directViaSelf = await resolveDirectMemberFlag(roomId, selfUserId);
const isDirectFlag: boolean | null = directViaSelf;

Issue 3: Fail-open fallback (Medium) ✅ Fixed

Removed the is_direct=false authoritative path. Now:

  • is_direct=true (from local user only) → DM with 2-member verification
  • is_direct=false or null → fall back to strict 2-member check

This prevents attackers from forcing non-DM classification.

Key Security Insight:

In Matrix, m.room.member.content.is_direct is set by each member themselves, making it untrusted for authorization decisions. We now only trust signals the bot controls (local user's membership state).

CWE-285 (Improper Authorization) mitigated.

Commits: d46fbdd

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b83ad87cd5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/matrix/src/matrix/monitor/direct.ts Outdated
@aisle-research-bot

aisle-research-bot Bot commented Mar 29, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟠 High DM classification trusts remote user's is_direct membership flag (authorization bypass)
Vulnerabilities

1. 🟠 DM classification trusts remote user's is_direct membership flag (authorization bypass)

Property Value
Severity High
CWE CWE-285
Location extensions/matrix/src/matrix/monitor/direct.ts:145-152

Description

In createDirectRoomTracker().isDirectMessage(), the DM decision can be made based on the remote sender's m.room.member.content.is_direct flag:

  • Matrix allows each member to set their own membership content.
  • The code checks (await resolveDirectMemberFlag(roomId, senderId)) and treats any truthy value as DM evidence.
  • A malicious remote user can set is_direct: true on their own membership state in a 2-member room, causing the system to classify the room as a DM even if the local user did not mark it as such.

This contradicts the comment in the same file (“Do NOT trust remote sender's is_direct”) and can enable DM-only policies/behaviors to be applied inappropriately (CWE-285).

Vulnerable code:

const directViaState =
  (await resolveDirectMemberFlag(roomId, senderId)) ||
  (await resolveDirectMemberFlag(roomId, selfUserId));
if (directViaState) {
  ...
  return true;
}

Recommendation

Only consider is_direct from the local user's membership state (self) when using it as DM evidence, and do not OR-in the remote sender's flag.

For example:

// Only trust local user's is_direct
const directViaState = (await resolveDirectMemberFlag(roomId, selfUserId)) === true;
if (directViaState) {
  log(`matrix: dm detected via member state room=${roomId}`);
  return true;
}

If you still want a 2-member fallback, keep it separate and gated (e.g., only before cache seed), but do not use the remote user's is_direct to elevate DM classification.


Analyzed PR: #57124 at commit d46fbdd

Last updated on: 2026-03-29T15:38:30Z

w-sss added a commit to w-sss/openclaw that referenced this pull request Mar 29, 2026
- Update directMemberFlagCache type to boolean | null (fixes TypeScript strict-null error)
- Fix inspectMatrixDirectRoomEvidence to fetch is_direct from local user only
- Pass isDirectFlag to isStrictDirectMembership in direct-room.ts
- Remove redundant resolveDirectMemberFlag calls

Security: Do not trust remote sender's is_direct flag for authorization decisions.
Only trust signals the bot controls (local user's membership state).

Addresses Greptile P1 feedback on PR openclaw#57124
@w-sss

w-sss commented Mar 29, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Security Fix Complete - All High/Medium issues addressed

Complete rewrite of DM classification logic to eliminate trust in remote-controlled data:

Changes:

  1. Removed isDirectFlag parameter from isStrictDirectMembership() entirely
  2. Removed all directViaSender lookups (no longer trust remote user's is_direct)
  3. Removed directViaSelf lookups (no longer use is_direct from m.room.member at all)
  4. Only use strict 2-member verification as the DM detection mechanism

New Logic:

export function isStrictDirectMembership(params: {
  selfUserId?: string | null;
  remoteUserId?: string | null;
  joinedMembers?: readonly string[] | null;
}): boolean {
  // Strict 2-member check - cannot be manipulated by attackers
  return Boolean(
    selfUserId &&
    remoteUserId &&
    joinedMembers.length === 2 &&
    joinedMembers.includes(selfUserId) &&
    joinedMembers.includes(remoteUserId),
  );
}

Why This Is Secure:

  • joinedMembers comes from server-side room state (not user-controlled)
  • Requires exactly 2 members (prevents group room attacks)
  • Verifies both self and remote are present
  • No reliance on is_direct flags that can be manipulated

CWE-285 (Improper Authorization) fully mitigated.

Commit: d46fbdd

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6fde9fa254

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/matrix/src/matrix/monitor/direct.ts Outdated
@gumadeiras gumadeiras self-assigned this Mar 30, 2026
@gumadeiras gumadeiras force-pushed the fix/56599-matrix-dm-classification branch from 3f3c5c9 to b88993d Compare March 30, 2026 22:06
w-sss and others added 2 commits March 30, 2026 18:55
… is_direct flag

Problem: Matrix DM classification logic had security vulnerabilities:
1. Unreachable code branch when is_direct flag was absent
2. When is_direct: true, skipped 2-member check (shared rooms misclassified as DMs)
3. **CWE-285: Improper Authorization** - trusted remote user's is_direct flag

Security Issues:
- Remote attacker could set is_direct=true on their membership to force DM classification
- Remote attacker could set is_direct=false to bypass DM-only restrictions
- Both could lead to policy bypass (DM allowlist/pairing checks)

Fix:
- hasDirectMatrixMemberFlag() returns boolean | null for local user only
- isStrictDirectMembership() only trusts local user's is_direct (selfUserId)
- Removed directViaSender lookups entirely (do not trust remote-controlled data)
- Falls back to strict 2-member check when is_direct is false/null

Key Insights:
- In Matrix, m.room.member.content.is_direct is set by each member themselves
- Only trust signals the bot controls (local user's membership state)
- 2-member check remains as safe fallback that cannot be manipulated

Closes openclaw#56599
@gumadeiras gumadeiras force-pushed the fix/56599-matrix-dm-classification branch from b88993d to e2ff0d5 Compare March 30, 2026 22:55
@gumadeiras gumadeiras merged commit 2b2edaa into openclaw:main Mar 30, 2026
9 checks passed
@gumadeiras

Copy link
Copy Markdown
Member

Merged via squash.

Thanks @w-sss!

pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
@w-sss w-sss deleted the fix/56599-matrix-dm-classification branch April 28, 2026 04:24
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
Tardisyuan pushed a commit to Tardisyuan/openclaw that referenced this pull request Apr 30, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…c and 2-member guard (openclaw#57124)

Merged via squash.

Prepared head SHA: e2ff0d5
Co-authored-by: w-sss <204439273+w-sss@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: matrix Channel integration: matrix size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Matrix rooms treated as DM when exactly 2 members

2 participants