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
@@ -32,6 +32,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -48,57 +49,6 @@ const DEFAULT_ACL: ServerAclContent = {
allow_ip_literals: false,
};
// ── Validation ────────────────────────────────────────────────────────────────
/**
* Validate a server-name glob for an ACL entry.
*
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
* `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
* rather than a concrete hostname:
* - reject empty / whitespace-only
* - allow only hostname/IP chars plus the wildcards `*` and `?`
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
*/
function isValidServerPattern(value: string): boolean {
const v = value.trim();
if (!v) return false;
// Only hostname/IP glob chars — wildcards may appear at any position.
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
// Structural rules for the dotted parts.
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
if (!/[A-Za-z0-9*?]/.test(v)) return false;
return true;
}
/**
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
* escaping every other regex metacharacter. Used only for local self-ban
* detection — never sent to the server.
*/
function globToRegExp(glob: string): RegExp {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
return new RegExp(`^${pattern}$`, 'i');
}
function matchesAnyGlob(domain: string, globs: string[]): boolean {
return globs.some((glob) => {
try {
return globToRegExp(glob).test(domain);
} catch {
return false;
}
});
}
// ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = {
@@ -20,7 +20,11 @@ export function OidcManageAccount() {
const open = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
window.open(
withSearchParam(authUrl, { action: accountManagementActions.profile }),
'_blank',
'noopener,noreferrer',
);
}, [authMetadata, accountManagementActions]);
if (!authMetadata) return null;
@@ -38,6 +38,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
action: accountManagementActions.sessionsList,
}),
'_blank',
'noopener,noreferrer',
);
}, [authMetadata, accountManagementActions]);
@@ -52,6 +53,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
device_id: deviceId,
}),
'_blank',
'noopener,noreferrer',
);
},
[authMetadata, accountManagementActions],
@@ -278,6 +278,7 @@ export function DeviceVerificationOptions() {
action: accountManagementActions.crossSigningReset,
}),
'_blank',
'noopener,noreferrer',
);
return;
}