fix(security): tab-nabbing hardening + /acl self-lockout guard (SEC-3/4)

SEC-3: add `noopener,noreferrer` to the 5 `window.open(_blank)` sites that
don't use the returned handle (UserChips, OidcManageAccount, OtherDevices x2,
Verification), closing reverse tab-nabbing. SSOStage is intentionally excluded —
it needs the window handle + intact opener for its origin-checked SSO
postMessage handshake.

SEC-4: guard the `/acl` slash command against bricking the room.
- Extract the ACL glob helpers (isValidServerPattern/globToRegExp/matchesAnyGlob)
  from RoomServerACL into a shared utils/serverAcl.ts (+ unit test) so the
  command and the settings editor validate identically.
- Default a MISSING allow list to `*` only when the room has NO existing ACL
  (a first `/acl -d x` otherwise sent `allow: []`, which bricks the room); an
  existing ACL's absent/empty allow is preserved, not silently widened.
- Reject invalid globs; fail CLOSED on the universally-catastrophic cases
  (empty allow, or a `*` deny) even when the local domain is unknown; and reject
  any change that would ban this homeserver (self-lockout).

Guard hardened per two review passes (fail-closed on unknown domain; no silent
federation widening).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:23:35 -04:00
co-authored by Claude Opus 4.8
parent 1b8f554584
commit 3e1106b2d9
8 changed files with 149 additions and 54 deletions
+43 -1
View File
@@ -25,6 +25,7 @@ import {
import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent, sendStateEvent } from '../utils/room';
import { isValidServerPattern, matchesAnyGlob } from '../utils/serverAcl';
import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState } from '../components/create-room';
@@ -509,8 +510,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
StateEvent.RoomServerAcl,
)?.getContent<RoomServerAclEventContent>();
// When there is NO existing ACL, default allow to `*` (allow all) so a
// first `/acl -d evil.com` doesn't send `allow: []` — which per spec
// allows NO server and bricks the room. But do NOT synthesize `*` for a
// room that already HAS an ACL whose allow key is absent/empty: that
// would silently widen a deny-all room to allow-all. Such a state is
// preserved as-is and then rejected by the guard below (fail closed,
// rather than silently opening federation).
const aclContent: RoomServerAclEventContent = {
allow: serverAcl?.allow ? [...serverAcl.allow] : [],
allow: serverAcl ? (serverAcl.allow ? [...serverAcl.allow] : []) : ['*'],
allow_ip_literals: serverAcl?.allow_ip_literals,
deny: serverAcl?.deny ? [...serverAcl.deny] : [],
};
@@ -534,6 +542,40 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
aclContent.allow?.sort();
aclContent.deny?.sort();
// Validate every added glob (empty/whitespace/malformed patterns).
const invalidPattern = [...allowList, ...denyList].find(
(servername) => !isValidServerPattern(servername),
);
if (invalidPattern) {
throw new Error(`/acl: invalid server pattern "${invalidPattern}"`);
}
const resultAllow = aclContent.allow ?? [];
const resultDeny = aclContent.deny ?? [];
// Fail CLOSED on the universally-catastrophic cases regardless of
// whether we can resolve our own server: an empty allow list bans every
// server, and a `*` deny bans everyone.
if (resultAllow.length === 0 || resultDeny.includes('*')) {
throw new Error(
'/acl: refused — this would ban all servers from the room. Use Room Settings → Server ACL to edit safely.',
);
}
// Refuse a change that would lock THIS homeserver out of the room — a
// room-bricking footgun (denying the local server, or an allow list that
// no longer matches it). Mirrors the Room Settings ACL editor's self-ban
// detection.
const localDomain = mx.getDomain() ?? '';
if (
localDomain &&
(!matchesAnyGlob(localDomain, resultAllow) || matchesAnyGlob(localDomain, resultDeny))
) {
throw new Error(
`/acl: refused — this would ban this homeserver (${localDomain}) from the room`,
);
}
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, aclContent);
},
},