fix(wave-3): audit fixes — ACL guards, presence, moderation, theming perf
Wave-3 bug-hunt fixes (findings in LOTUS_TODO), reviewed + gate-green: - 🔴 ACL editor [H1–H4]: block saving an empty allow-list (was a one-click federation brick), warn on self-ban (case-insensitive glob match of mx.getDomain() vs allow/deny), accept real globs (1.2.3.*, *.evil.*), and gate Save behind a confirm dialog. - 🔴 [P1] room context menu no longer acts on the wrong room after a live reorder (key by roomId, not list index). 🔴 [P2] status writes no longer force presence to online over Invisible/DND (shared presenceStateFromSetting). - 🟠 [P3] timed mutes restored on boot; [P4] custom-status auto-clear now fires (always-mounted StatusExpiryMonitor); [P5] timezone also PUT to the m.tz profile field so it's visible to others; [H6] RoomInsights single-pass min/max (was Math.min(...spread) stack overflow); [H7/H8] mod-log labels. - 🟡 [P6/P7] favorites collapse+filter, [P8] charCount reset, [P9] DM preview refresh on decrypt; theming [T-P1] lazy decorations, [T-P2] drop the redundant always-on body animation, [T-P4] live useReducedMotion, [T-P5] decoration key. - NATIVE-CINNY LAW: notification presets + Powers permissions use folds icons. DEFERRED: [H5] invite-QR is fetched from api.qrserver.com (third-party leak); local generation needs a bundled QR lib (not added). tsc/eslint/prettier clean, build OK, 677 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,22 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
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 { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a server name or wildcard pattern.
|
||||
* Allowed forms:
|
||||
* - plain hostname / IP: letters, digits, hyphens, dots
|
||||
* - wildcard prefix: *.example.com (asterisk only at the very start)
|
||||
* The Matrix spec allows `*` on its own (match-all wildcard).
|
||||
* 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 {
|
||||
if (value === '*') return true;
|
||||
// Strip leading wildcard
|
||||
const rest = value.startsWith('*.') ? value.slice(2) : value;
|
||||
// Must not be empty after stripping wildcard
|
||||
if (!rest) return false;
|
||||
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
|
||||
return /^[A-Za-z0-9.:_-]+$/.test(rest);
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
||||
if (!value) return;
|
||||
|
||||
if (!isValidServerPattern(value)) {
|
||||
setError('Invalid server pattern. Use a hostname or *.example.com');
|
||||
setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)');
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
|
||||
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const modalStyle = useModalStyle(480);
|
||||
|
||||
// Power level checks
|
||||
const powerLevels = usePowerLevels(room);
|
||||
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
const saveError =
|
||||
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
||||
|
||||
// ── Save guards ───────────────────────────────────────────────────────────
|
||||
// #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and
|
||||
// partitions the room from all federation irreversibly — block the save.
|
||||
const emptyAllow = allowList.length === 0;
|
||||
|
||||
// #2 Self-ban: the local homeserver must match at least one allow glob and no
|
||||
// deny glob, otherwise applying this ACL removes our own server from the room.
|
||||
const localDomain = mx.getDomain() ?? '';
|
||||
const selfBanned =
|
||||
localDomain.length > 0 &&
|
||||
(!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList));
|
||||
|
||||
// #4 Gate the destructive write behind a confirmation dialog.
|
||||
const [prompt, setPrompt] = useState(false);
|
||||
|
||||
const handleConfirmSave = () => {
|
||||
setPrompt(false);
|
||||
save();
|
||||
};
|
||||
|
||||
// Required power level for this state event
|
||||
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={saving || !isDirty}
|
||||
onClick={() => save()}
|
||||
disabled={saving || !isDirty || emptyAllow}
|
||||
onClick={() => setPrompt(true)}
|
||||
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
||||
>
|
||||
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
||||
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* #1 Empty allow list guard — blocks save */}
|
||||
{canEdit && emptyAllow && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
The allow list is empty. An empty allow list denies every server and partitions
|
||||
this room from all federation permanently. Add at least one entry (use
|
||||
"*" to allow all servers).
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* #2 Self-ban warning — save allowed but confirmation required */}
|
||||
{canEdit && !emptyAllow && selfBanned && (
|
||||
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||
Applying it will remove your server from the room and you may lose the ability to
|
||||
moderate it.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Allow IP literals toggle */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">IP Address Access</Text>
|
||||
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
|
||||
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
|
||||
warnings and keeps a safe save one extra click. */}
|
||||
{prompt && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setPrompt(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
aria-labelledby="server-acl-confirm-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4" id="server-acl-confirm-title">
|
||||
Apply Server ACL
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={() => setPrompt(false)}
|
||||
radii="300"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400">
|
||||
Server ACL changes take effect immediately and control which servers can
|
||||
participate in this room. This cannot be undone by other servers once they are
|
||||
removed.
|
||||
</Text>
|
||||
{emptyAllow && (
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
The allow list is empty — this would deny every server and partition the
|
||||
room from all federation permanently.
|
||||
</Text>
|
||||
)}
|
||||
{!emptyAllow && selfBanned && (
|
||||
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||
Applying it will remove your server from the room and you may lose the
|
||||
ability to moderate it.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={selfBanned ? 'Critical' : 'Primary'}
|
||||
onClick={handleConfirmSave}
|
||||
disabled={emptyAllow}
|
||||
>
|
||||
<Text size="B400">Apply ACL</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user