feat: security hardening, DB indexes, token revocation, and input validation

- SSRF protection: DNS resolution + private IP blocking on metadata fetcher
- Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff
- Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at
- Attachment ownership verification before linking to messages
- Message length limit (4000 chars) enforced on client and server
- Asset URL validation on avatar/banner updates
- Federation instance validation (domain regex, origin scheme, length limits)
- DB indexes on all FK columns for query performance
- Migrations: nullable moderator columns, dm_messages reply_to FK constraint
- File cleanup on avatar/banner replacement and space deletion
- Fastify trustProxy, AbortController on fetches, typing map size cap
This commit is contained in:
Jannis Braun
2026-03-15 00:06:15 +01:00
parent ed4dcdcf69
commit 7c544c1ff4
37 changed files with 892 additions and 178 deletions
@@ -1,9 +1,9 @@
import React, { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { getAvatarGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
import { Avatar } from '../ui/Avatar';
export function IncomingCallModal() {
const incomingCall = useVoiceStore((s) => s.incomingCall);
@@ -15,7 +15,7 @@ export function IncomingCallModal() {
if (incomingCall) {
timerRef.current = setTimeout(() => {
// Auto-reject after timeout
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
setIncomingCall(null);
}, 30000);
}
@@ -39,12 +39,12 @@ export function IncomingCallModal() {
const handleAccept = () => {
if (timerRef.current) clearTimeout(timerRef.current);
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
};
const handleDecline = () => {
if (timerRef.current) clearTimeout(timerRef.current);
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId));
setIncomingCall(null);
};
@@ -54,26 +54,29 @@ export function IncomingCallModal() {
<div className="absolute inset-0 bg-black/50" />
{/* Call card */}
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden">
{/* Ring animation background */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-status-online/5 animate-ping" style={{ animationDuration: '2s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-status-online/10 animate-ping" style={{ animationDuration: '2s', animationDelay: '0.5s' }} />
<div className="relative glass-modal rounded-lg w-[340px] overflow-hidden animate-fade-in animate-slide-up">
{/* Ripple rings */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
/>
<div
className="absolute top-1/2 left-1/2 w-[160px] h-[160px] rounded-full border border-status-online/30 animate-call-ripple"
style={{ animationDelay: '1.5s' }}
/>
</div>
{/* Content */}
<div className="relative p-8 flex flex-col items-center gap-4">
{/* Caller avatar */}
<div className="relative">
<div className="w-20 h-20 rounded-full flex items-center justify-center text-white text-3xl font-bold" style={{ background: getAvatarGradient(callerAvatarId, callerBaseName).gradient }}>
{callerBaseName.charAt(0).toUpperCase()}
</div>
{/* Ringing phone icon */}
<div className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-status-online flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
</div>
<div className="rounded-full animate-call-glow">
<Avatar
src={callerMember?.avatar}
avatarColor={callerMember?.avatarColor}
userId={callerAvatarId}
name={callerBaseName}
size={80}
/>
</div>
{/* Caller info */}
@@ -87,10 +90,10 @@ export function IncomingCallModal() {
{/* Decline */}
<button
onClick={handleDecline}
className="w-14 h-14 rounded-full bg-accent-rose hover:bg-accent-rose/80 flex items-center justify-center transition-colors group"
className="w-14 h-14 rounded-full bg-accent-rose/20 border border-accent-rose/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-accent-rose/35 group"
title="Decline"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-accent-rose group-hover:scale-110 transition-transform">
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
</svg>
</button>
@@ -98,10 +101,10 @@ export function IncomingCallModal() {
{/* Accept */}
<button
onClick={handleAccept}
className="w-14 h-14 rounded-full bg-status-online hover:bg-status-online/80 flex items-center justify-center transition-colors group"
className="w-14 h-14 rounded-full bg-status-online/20 border border-status-online/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-status-online/35 animate-call-button-glow group"
title="Accept"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-status-online group-hover:scale-110 transition-transform">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
</button>
@@ -105,7 +105,7 @@ export function VoiceControlBar() {
const handleDisconnect = () => {
const { activeDmCall } = useVoiceStore.getState();
if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
useVoiceStore.getState().setActiveDmCall(null);
} else {
wsSend({ type: 'voice_leave' }, voiceOrigin);
@@ -76,7 +76,7 @@ export function VoiceControls() {
const handleDisconnect = () => {
const { activeDmCall } = useVoiceStore.getState();
if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
useVoiceStore.getState().setActiveDmCall(null);
} else {
wsSend({ type: 'voice_leave' }, voiceOrigin);