Skip to content

Commit ddce6d4

Browse files
committed
Security: user-scoped data deletion, ARIA labels, prompt validation
- clearAllData() now uses .eq('user_id', userId) to only delete current user's data - Added triple confirmation for data deletion (confirm + email verification) - Added ARIA labels to AskChat and LibraryPage for accessibility - Added React.memo to MessageBubble for performance - Added URL validation and prompt injection hardening - Fixed file list keys to use file.name instead of index
1 parent 9e53f05 commit ddce6d4

13 files changed

Lines changed: 3433 additions & 2851 deletions

File tree

‎package-lock.json‎

Lines changed: 2784 additions & 2760 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/components/features/ask/AskChat.tsx‎

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useState, useRef, useEffect } from 'react';
2-
import { MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff } from 'lucide-react';
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import { MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff, AlertCircle } from 'lucide-react';
33
import { Button } from '@/components/ui/button';
44
import { Input } from '@/components/ui/input';
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -11,6 +11,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
1111
import type { AskOutput } from '@/lib/ai/schemas/ask';
1212
import { cn } from '@/lib/utils';
1313
import { useSpeechToText } from '@/hooks/use-speech-to-text';
14+
import { useToast } from '@/hooks/use-toast';
15+
import { validateFileUpload, MAX_FILE_COUNT, formatFileSize } from '@/lib/validation/file';
1416

1517
interface Message {
1618
id: string;
@@ -35,7 +37,15 @@ const modeOptions = [
3537
{ id: 'argumentmap', label: 'Arg Map', icon: Map },
3638
];
3739

38-
function MessageBubble({ message, onSave }: { message: Message, onSave?: (title: string, content: string) => void }) {
40+
const LOADING_MESSAGES = [
41+
'Thinking...',
42+
'Researching...',
43+
'Analyzing...',
44+
'Generating response...',
45+
'Almost there...',
46+
];
47+
48+
const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { message: Message, onSave?: (title: string, content: string) => void }) {
3949
const isUser = message.role === 'user';
4050
const output = message.output;
4151

@@ -61,6 +71,7 @@ function MessageBubble({ message, onSave }: { message: Message, onSave?: (title:
6171
onSave(title, message.content);
6272
}}
6373
title="Save to Library"
74+
aria-label="Save message to library"
6475
>
6576
<Bookmark className="h-4 w-4" />
6677
</Button>
@@ -114,17 +125,31 @@ function MessageBubble({ message, onSave }: { message: Message, onSave?: (title:
114125
</div>
115126
</div>
116127
);
117-
}
128+
});
118129

119130
export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }: AskChatProps) {
120131
const [input, setInput] = useState('');
121132
const [mode, setMode] = useState('general');
122133
const [citationMode, setCitationMode] = useState(false);
123134
const [files, setFiles] = useState<File[]>([]);
135+
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
124136
const fileInputRef = useRef<HTMLInputElement>(null);
125-
137+
const { toast } = useToast();
138+
126139
const { isListening, transcript, startListening, stopListening, resetTranscript, hasSupport } = useSpeechToText();
127140

141+
// Cycle through loading messages
142+
useEffect(() => {
143+
if (!isLoading) {
144+
setLoadingMessageIndex(0);
145+
return;
146+
}
147+
const interval = setInterval(() => {
148+
setLoadingMessageIndex(prev => (prev + 1) % LOADING_MESSAGES.length);
149+
}, 3000);
150+
return () => clearInterval(interval);
151+
}, [isLoading]);
152+
128153
useEffect(() => {
129154
if (transcript) {
130155
setInput(transcript);
@@ -142,7 +167,35 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
142167

143168
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
144169
if (e.target.files) {
145-
setFiles(prev => [...prev, ...Array.from(e.target.files!)]);
170+
const newFiles = Array.from(e.target.files);
171+
const allFiles = [...files, ...newFiles];
172+
173+
// Validate total file count
174+
if (allFiles.length > MAX_FILE_COUNT) {
175+
toast({
176+
title: 'Too many files',
177+
description: `You can only upload up to ${MAX_FILE_COUNT} files at once.`,
178+
variant: 'destructive',
179+
});
180+
return;
181+
}
182+
183+
// Validate each file
184+
const result = validateFileUpload(allFiles);
185+
if (!result.valid) {
186+
toast({
187+
title: 'File validation failed',
188+
description: result.error,
189+
variant: 'destructive',
190+
});
191+
return;
192+
}
193+
194+
setFiles(allFiles);
195+
}
196+
// Reset input so same file can be selected again
197+
if (e.target) {
198+
e.target.value = '';
146199
}
147200
};
148201

@@ -196,9 +249,14 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
196249
<MessageBubble key={message.id} message={message} onSave={onSaveSource} />
197250
))}
198251
{isLoading && (
199-
<div className="flex gap-3">
252+
<div className="flex gap-3" role="status" aria-live="polite" aria-atomic="true">
200253
<div className="bg-card border border-border rounded-2xl rounded-bl-sm px-4 py-3">
201-
<LoadingSpinner size="sm" />
254+
<div className="flex items-center gap-2">
255+
<LoadingSpinner size="sm" />
256+
<span className="text-sm text-muted-foreground animate-pulse">
257+
{LOADING_MESSAGES[loadingMessageIndex]}
258+
</span>
259+
</div>
202260
</div>
203261
</div>
204262
)}
@@ -236,11 +294,15 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
236294

237295
{files.length > 0 && (
238296
<div className="flex flex-wrap gap-2">
239-
{files.map((file, i) => (
240-
<div key={i} className="flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-xs">
297+
{files.map((file) => (
298+
<div key={file.name} className="flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-xs">
241299
<Paperclip className="h-3 w-3" />
242300
<span className="max-w-[100px] truncate">{file.name}</span>
243-
<button onClick={() => removeFile(i)} className="text-muted-foreground hover:text-foreground">
301+
<button
302+
onClick={() => removeFile(files.indexOf(file))}
303+
className="text-muted-foreground hover:text-foreground"
304+
aria-label={`Remove ${file.name}`}
305+
>
244306
<X className="h-3 w-3" />
245307
</button>
246308
</div>
@@ -262,17 +324,19 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
262324
size="icon"
263325
onClick={() => fileInputRef.current?.click()}
264326
title="Attach files"
327+
aria-label="Attach files"
265328
>
266329
<Paperclip className="h-4 w-4" />
267330
</Button>
268-
331+
269332
{hasSupport && (
270333
<Button
271334
type="button"
272335
variant={isListening ? "destructive" : "outline"}
273336
size="icon"
274337
onClick={toggleListening}
275338
title={isListening ? "Stop listening" : "Start voice input"}
339+
aria-label={isListening ? "Stop listening" : "Start voice input"}
276340
className={isListening ? "animate-pulse" : ""}
277341
>
278342
{isListening ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
@@ -286,7 +350,7 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveSource }:
286350
disabled={isLoading}
287351
className="flex-1"
288352
/>
289-
<Button type="submit" disabled={(!input.trim() && files.length === 0) || isLoading}>
353+
<Button type="submit" disabled={(!input.trim() && files.length === 0) || isLoading} aria-label="Send message">
290354
<Send className="h-4 w-4" />
291355
</Button>
292356
</form>

‎src/components/features/attack/AttackResults.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function AttackResults({ result, onDiscuss }: AttackResultsProps) {
8888
<span className={cn(
8989
'text-lg font-bold',
9090
result.overallAssessment.score >= 70 ? 'text-primary' :
91-
result.overallAssessment.score >= 50 ? 'text-amber-500' : 'text-destructive'
91+
result.overallAssessment.score >= 50 ? 'text-amber-500' : 'text-destructive'
9292
)}>
9393
{result.overallAssessment.score}/100
9494
</span>
@@ -100,8 +100,8 @@ export function AttackResults({ result, onDiscuss }: AttackResultsProps) {
100100
)}
101101
</div>
102102
</div>
103-
<Progress
104-
value={result.overallAssessment.score}
103+
<Progress
104+
value={result.overallAssessment.score}
105105
className="h-3"
106106
/>
107107
</div>

‎src/components/features/factoids/FactoidsResults.tsx‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SectionCard } from '@/components/ui/section-card';
66
import { Progress } from '@/components/ui/progress';
77
import type { FactoidsOutput, Factoid } from '@/lib/ai/schemas/factoids';
88
import { cn } from '@/lib/utils';
9+
import { validateUrl } from '@/lib/validation/url';
910

1011
interface FactoidsResultsProps {
1112
result: FactoidsOutput;
@@ -21,27 +22,21 @@ const verificationConfig = {
2122
pending: { icon: Clock, color: 'text-muted-foreground', bg: 'bg-muted', label: 'Pending' },
2223
};
2324

24-
function formatUrl(url?: string) {
25-
if (!url) return undefined;
26-
if (url.startsWith('http')) return url;
27-
if (url.includes('.')) return `https://${url}`;
28-
return undefined;
29-
}
30-
3125
function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsResultsProps['onSaveSource'] }) {
3226
const config = verificationConfig[factoid.citation.verified];
3327
const VerifyIcon = config.icon;
34-
const safeUrl = formatUrl(factoid.citation.url);
28+
// Use secure URL validation instead of simple string checks
29+
const safeUrl = validateUrl(factoid.citation.url);
3530

3631
return (
3732
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
3833
{/* Header */}
3934
<div className="flex items-start justify-between gap-2">
4035
<div className="flex-1">
4136
{safeUrl ? (
42-
<a
43-
href={safeUrl}
44-
target="_blank"
37+
<a
38+
href={safeUrl}
39+
target="_blank"
4540
rel="noopener noreferrer"
4641
className="group flex items-center gap-2"
4742
>
@@ -66,9 +61,9 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
6661
<div className="flex flex-col items-end gap-2">
6762
<Progress value={factoid.relevanceScore} className="w-16 h-2" />
6863
{onSave && (
69-
<Button
70-
variant="ghost"
71-
size="icon"
64+
<Button
65+
variant="ghost"
66+
size="icon"
7267
className="h-8 w-8 text-muted-foreground hover:text-primary"
7368
onClick={() => onSave(factoid.shortClaim, `${factoid.context}\n\nKey Figure: ${factoid.keyFigure}\nLimitations: ${factoid.limitations}`, factoid.citation.url)}
7469
title="Save to Library"

‎src/lib/ai/client.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,46 @@ import { useSettingsStore } from '../stores/settings-store';
99
import { useSessionStore } from '../stores/session-store';
1010
import { supabase } from '@/lib/supabase/client';
1111

12+
// Request timeout in milliseconds (200 seconds)
13+
const REQUEST_TIMEOUT_MS = 200 * 1000;
14+
15+
/**
16+
* Custom error for request timeouts.
17+
*/
18+
export class TimeoutError extends Error {
19+
constructor(message: string = 'Request timed out. Please try again.') {
20+
super(message);
21+
this.name = 'TimeoutError';
22+
}
23+
}
24+
25+
/**
26+
* Checks if an error is a TimeoutError.
27+
*/
28+
export function isTimeoutError(error: unknown): error is TimeoutError {
29+
return error instanceof TimeoutError || (error instanceof Error && error.name === 'TimeoutError');
30+
}
31+
32+
/**
33+
* Wraps a promise with a timeout. Rejects with TimeoutError if the promise
34+
* doesn't resolve within the specified time.
35+
*/
36+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
37+
let timeoutId: ReturnType<typeof setTimeout>;
38+
39+
const timeoutPromise = new Promise<never>((_, reject) => {
40+
timeoutId = setTimeout(() => {
41+
reject(new TimeoutError());
42+
}, timeoutMs);
43+
});
44+
45+
try {
46+
return await Promise.race([promise, timeoutPromise]);
47+
} finally {
48+
clearTimeout(timeoutId!);
49+
}
50+
}
51+
1252
async function callSupabaseAI(
1353
feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask',
1454
input: Record<string, unknown>,
@@ -25,7 +65,8 @@ async function callSupabaseAI(
2565
const { data: sessionData } = await supabase.auth.getSession();
2666
const accessToken = sessionData.session?.access_token;
2767

28-
const { data, error } = await supabase.functions.invoke('ai', {
68+
// Wrap the API call with timeout
69+
const invokePromise = supabase.functions.invoke('ai', {
2970
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
3071
body: {
3172
feature,
@@ -38,6 +79,8 @@ async function callSupabaseAI(
3879
},
3980
});
4081

82+
const { data, error } = await withTimeout(invokePromise, REQUEST_TIMEOUT_MS);
83+
4184
if (error) throw new Error(error.message);
4285
if (!data?.output) throw new Error('AI response missing output');
4386
return data.output;

‎src/lib/stores/session-store.ts‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,14 @@ export const useSessionStore = create<SessionState>()(
183183
sourceLibrary: state.sourceLibrary.map((s) =>
184184
s.id === localId
185185
? {
186-
id: data.id,
187-
title: data.title,
188-
content: data.content,
189-
type: data.type,
190-
origin: data.origin || undefined,
191-
isActive: data.is_active,
192-
createdAt: toTimestamp(data.created_at),
193-
}
186+
id: data.id,
187+
title: data.title,
188+
content: data.content,
189+
type: data.type,
190+
origin: data.origin || undefined,
191+
isActive: data.is_active,
192+
createdAt: toTimestamp(data.created_at),
193+
}
194194
: s
195195
),
196196
}));
@@ -498,18 +498,27 @@ export const useSessionStore = create<SessionState>()(
498498
},
499499

500500
clearAllData: async () => {
501-
await supabase.from('messages').delete().neq('id', '');
502-
await supabase.from('chats').delete().neq('id', '');
503-
await supabase.from('cases').delete().neq('id', '');
504-
await supabase.from('attacks').delete().neq('id', '');
505-
await supabase.from('brainstorms').delete().neq('id', '');
506-
await supabase.from('factoids').delete().neq('id', '');
507-
await supabase.from('sources').delete().neq('id', '');
508-
await supabase.from('source_files').delete().neq('id', '');
509-
await supabase.from('ai_requests').delete().neq('id', '');
510-
await supabase.from('api_keys').delete().neq('id', '');
511-
await supabase.from('user_settings').delete().neq('user_id', '');
501+
// SAFETY: Get current user ID - abort if not authenticated
502+
const userId = await getUserId();
503+
if (!userId) {
504+
throw new Error('Cannot delete data: No authenticated user');
505+
}
512506

507+
// SAFETY: All deletes explicitly filter by user_id
508+
// This ensures only the current user's data is deleted, even if RLS fails
509+
await supabase.from('messages').delete().eq('user_id', userId);
510+
await supabase.from('chats').delete().eq('user_id', userId);
511+
await supabase.from('cases').delete().eq('user_id', userId);
512+
await supabase.from('attacks').delete().eq('user_id', userId);
513+
await supabase.from('brainstorms').delete().eq('user_id', userId);
514+
await supabase.from('factoids').delete().eq('user_id', userId);
515+
await supabase.from('sources').delete().eq('user_id', userId);
516+
await supabase.from('source_files').delete().eq('user_id', userId);
517+
await supabase.from('ai_requests').delete().eq('user_id', userId);
518+
await supabase.from('api_keys').delete().eq('user_id', userId);
519+
await supabase.from('user_settings').delete().eq('user_id', userId);
520+
521+
// Clear local state
513522
set({
514523
savedCases: [],
515524
savedAttacks: [],

0 commit comments

Comments
 (0)