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:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface EmbedProps {
|
||||
url: string;
|
||||
@@ -17,26 +17,28 @@ export function Embed({ url }: EmbedProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Simple fetch from our new API
|
||||
const controller = new AbortController();
|
||||
|
||||
const token = useAuthStore.getState().token;
|
||||
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('backspace_token')}`
|
||||
}
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (isMounted && data.title) {
|
||||
if (data.title) {
|
||||
setMetadata(data);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted) setIsLoading(false);
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => { isMounted = false; };
|
||||
return () => { controller.abort(); };
|
||||
}, [url]);
|
||||
|
||||
if (isLoading || !metadata) return null;
|
||||
@@ -50,9 +52,9 @@ export function Embed({ url }: EmbedProps) {
|
||||
</div>
|
||||
)}
|
||||
{metadata.title && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[16px] text-txt-link font-semibold hover:underline block mb-2"
|
||||
>
|
||||
@@ -67,9 +69,9 @@ export function Embed({ url }: EmbedProps) {
|
||||
</div>
|
||||
{metadata.image && (
|
||||
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-[4px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,10 @@ export function FriendsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
|
||||
const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => {
|
||||
try {
|
||||
// Check if a DM already exists with this user (on any instance)
|
||||
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId });
|
||||
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId, homeUserId: homeUserId ?? undefined });
|
||||
if (existing) {
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${existing.dm.id}`);
|
||||
@@ -99,7 +99,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
) : (
|
||||
onlineFriends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -116,7 +116,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
) : (
|
||||
friends.map(friend => (
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin, friend.homeUserId ?? undefined)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -227,7 +227,7 @@ function AddFriendTab({
|
||||
addStatus: { type: 'success' | 'error'; message: string } | null;
|
||||
isLoading: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||
}) {
|
||||
const discoverUsers = useDiscoverStore((s) => s.users);
|
||||
const discoverLoading = useDiscoverStore((s) => s.isLoading);
|
||||
@@ -354,7 +354,7 @@ function UserDiscoverCard({
|
||||
onOpenDm,
|
||||
}: {
|
||||
user: TaggedDiscoverUser;
|
||||
onOpenDm: (userId: string, origin: string) => void;
|
||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||
}) {
|
||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||
@@ -443,7 +443,7 @@ function UserDiscoverCard({
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
onOpenDm(user.id, user._instanceOrigin);
|
||||
onOpenDm(user.id, user._instanceOrigin, user.homeUserId ?? undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -192,6 +192,8 @@ function buildComponents(): Components {
|
||||
alt={alt ?? ''}
|
||||
className="max-w-full max-h-[350px] rounded-md mt-1"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
),
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
@@ -76,9 +76,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
}, 3000);
|
||||
}, [channelId]);
|
||||
|
||||
const remaining = MAX_MESSAGE_LENGTH - content.length;
|
||||
const isOverLimit = remaining < 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed && files.length === 0) return;
|
||||
if (isOverLimit) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setMentionState(null);
|
||||
@@ -364,6 +368,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character counter (shows when near or over limit) */}
|
||||
{content.length > MAX_MESSAGE_LENGTH - 200 && (
|
||||
<span className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}>
|
||||
{remaining}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* GIF button */}
|
||||
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
Reference in New Issue
Block a user