import React, { useCallback, useRef, useState } from 'react'; 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'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels'; import { useRoomCreators } from '../../hooks/useRoomCreators'; 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 ───────────────────────────────────────────────────────────────────── type ServerAclContent = { allow: string[]; deny: string[]; allow_ip_literals: boolean; }; const DEFAULT_ACL: ServerAclContent = { allow: ['*'], deny: [], 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 = { label: string; entries: string[]; canEdit: boolean; onAdd: (value: string) => void; onRemove: (index: number) => void; }; function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProps) { const inputRef = useRef(null); const [error, setError] = useState(); const handleAdd = () => { const raw = inputRef.current?.value ?? ''; const value = raw.trim(); if (!value) return; if (!isValidServerPattern(value)) { setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)'); return; } setError(undefined); onAdd(value); if (inputRef.current) inputRef.current.value = ''; }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') handleAdd(); }; return ( {label} {canEdit && ( )} {canEdit && ( {error && ( {error} )} )} {entries.length === 0 ? ( (empty) ) : ( entries.map((entry, i) => ( {entry} {canEdit && ( onRemove(i)} style={{ flexShrink: 0 }} > )} )) )} ); } // ── Main component ──────────────────────────────────────────────────────────── type RoomServerACLProps = { requestClose: () => void; }; export function RoomServerACL({ requestClose }: RoomServerACLProps) { const mx = useMatrixClient(); const room = useRoom(); const modalStyle = useModalStyle(480); // Power level checks const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); const myUserId = mx.getSafeUserId(); const canEdit = permissions.stateEvent(StateEvent.RoomServerAcl, myUserId); // Read current ACL from room state const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl); const currentAcl = aclEvent?.getContent() ?? DEFAULT_ACL; // Local draft state — initialised from current ACL const [allowList, setAllowList] = useState(() => currentAcl.allow ?? ['*']); const [denyList, setDenyList] = useState(() => currentAcl.deny ?? []); const [allowIpLiterals, setAllowIpLiterals] = useState( () => currentAcl.allow_ip_literals ?? false, ); // Track whether there are unsaved changes const isDirty = JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) || JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) || allowIpLiterals !== (currentAcl.allow_ip_literals ?? false); // Save handler const [saveState, save] = useAsyncCallback( useCallback(async () => { await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, { allow: allowList, deny: denyList, allow_ip_literals: allowIpLiterals, }); }, [mx, room.roomId, allowList, denyList, allowIpLiterals]), ); const saving = saveState.status === AsyncStatus.Loading; 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); return ( Server ACL {canEdit && ( )} {/* Info banner */} Server ACL controls which servers can participate in this room. Changes take effect immediately. {!canEdit && ( You need power level {requiredPL} to edit the ACL (your level: {myPL}). )} {/* Save error */} {saveError && ( {saveError} )} {/* #1 Empty allow list guard — blocks save */} {canEdit && emptyAllow && ( 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). )} {/* #2 Self-ban warning — save allowed but confirmation required */} {canEdit && !emptyAllow && selfBanned && ( 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. )} {/* Allow IP literals toggle */} IP Address Access setAllowIpLiterals(!allowIpLiterals)} size="300" variant="Primary" /> {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot participate. {/* Allowed servers */} setAllowList((prev) => [...prev, value])} onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))} /> {/* Denied servers */} setDenyList((prev) => [...prev, value])} onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))} /> {/* Note about defaults */} Tip: The default ACL allows all servers (allow: ["*"], deny: []). Adding "*" to the allow list permits all servers not explicitly denied. {/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2) warnings and keeps a safe save one extra click. */} {prompt && ( }> setPrompt(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} >
Apply Server ACL setPrompt(false)} radii="300" aria-label="Cancel" >
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. {emptyAllow && ( The allow list is empty — this would deny every server and partition the room from all federation permanently. )} {!emptyAllow && selfBanned && ( 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. )}
)}
); }