From fa72672aaccfe1ce3a35abcefe2b660d621474bf Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 30 Apr 2026 21:02:13 +0200 Subject: [PATCH] security: fix critical auth gaps, SSRF, IDOR, and embedding error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: - Add auth + admin check to 10 unprotected API routes (test-*, debug/*, config, models, fix-labels) - Add CRON_SECRET bearer auth to /api/cron/reminders (was fully open) - Add SSRF protection to getOllamaModels (blocks private/internal IPs) HIGH: - Fix getAllLabels() missing userId filter (leaked all users' labels) - Fix /api/labels OR clause leaking other users' labels - Fix IDOR in toggleAgent/getAgentActions (add ownership check) - Fix getEmbeddings() returning [] on error in all 5 providers (corrupted semantic search with NaN cosine similarity) — now throws instead Co-Authored-By: Claude Opus 4.7 --- memento-note/app/actions/agent-actions.ts | 9 ++++++++- memento-note/app/actions/notes.ts | 7 ++++++- memento-note/app/actions/ollama.ts | 18 ++++++++++++++++++ memento-note/app/api/ai/config/route.ts | 9 +++++++++ memento-note/app/api/ai/models/route.ts | 9 +++++++++ memento-note/app/api/ai/test-chat/route.ts | 9 +++++++++ .../app/api/ai/test-embeddings/route.ts | 9 +++++++++ memento-note/app/api/ai/test-tags/route.ts | 9 +++++++++ memento-note/app/api/ai/test/route.ts | 9 +++++++++ .../app/api/ai/web-search-available/route.ts | 6 ++++++ memento-note/app/api/cron/reminders/route.ts | 14 ++++++++++++-- memento-note/app/api/debug/config/route.ts | 9 +++++++++ memento-note/app/api/debug/test-chat/route.ts | 9 +++++++++ memento-note/app/api/fix-labels/route.ts | 9 +++++++++ memento-note/app/api/labels/route.ts | 7 ++----- memento-note/lib/ai/providers/custom-openai.ts | 2 +- memento-note/lib/ai/providers/deepseek.ts | 2 +- memento-note/lib/ai/providers/ollama.ts | 2 +- memento-note/lib/ai/providers/openai.ts | 2 +- memento-note/lib/ai/providers/openrouter.ts | 2 +- 20 files changed, 138 insertions(+), 14 deletions(-) diff --git a/memento-note/app/actions/agent-actions.ts b/memento-note/app/actions/agent-actions.ts index 306b561..60f9e4d 100644 --- a/memento-note/app/actions/agent-actions.ts +++ b/memento-note/app/actions/agent-actions.ts @@ -246,6 +246,13 @@ export async function getAgentActions(agentId: string) { } try { + // Verify the agent belongs to the user + const agent = await prisma.agent.findFirst({ + where: { id: agentId, userId: session.user.id }, + select: { id: true } + }) + if (!agent) throw new Error('Agent non trouve') + const actions = await prisma.agentAction.findMany({ where: { agentId }, orderBy: { createdAt: 'desc' }, @@ -276,7 +283,7 @@ export async function toggleAgent(id: string, isEnabled: boolean) { try { const agent = await prisma.agent.update({ - where: { id }, + where: { id, userId: session.user.id }, data: { isEnabled } }) return { success: true, agent } diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 180e6ab..c90c4a5 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -1169,8 +1169,13 @@ export async function updateSize(id: string, size: 'small' | 'medium' | 'large') // Get all unique labels export async function getAllLabels() { + const session = await auth(); + if (!session?.user?.id) return []; try { - const notes = await prisma.note.findMany({ select: { labels: true } }) + const notes = await prisma.note.findMany({ + where: { userId: session.user.id }, + select: { labels: true } + }) const labelsSet = new Set() notes.forEach((note: any) => { const labels = note.labels ? JSON.parse(note.labels) : null diff --git a/memento-note/app/actions/ollama.ts b/memento-note/app/actions/ollama.ts index 2dd164b..d5f5180 100644 --- a/memento-note/app/actions/ollama.ts +++ b/memento-note/app/actions/ollama.ts @@ -26,6 +26,24 @@ export async function getOllamaModels(baseUrl: string): Promise<{ success: boole // Ensure URL doesn't end with slash const cleanUrl = baseUrl.replace(/\/$/, '') + // SSRF protection: block internal/private IPs + try { + const parsed = new URL(cleanUrl) + const hostname = parsed.hostname.toLowerCase() + const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254'] + if (blockedHosts.includes(hostname)) { + return { success: false, models: [], error: 'Private/internal URLs are not allowed' } + } + if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.') || hostname.startsWith('fc') || hostname.startsWith('fd')) { + return { success: false, models: [], error: 'Private/internal URLs are not allowed' } + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { success: false, models: [], error: 'Only http/https protocols allowed' } + } + } catch { + return { success: false, models: [], error: 'Invalid URL' } + } + try { const response = await fetch(`${cleanUrl}/api/tags`, { method: 'GET', diff --git a/memento-note/app/api/ai/config/route.ts b/memento-note/app/api/ai/config/route.ts index 4d7b4e7..7edde06 100644 --- a/memento-note/app/api/ai/config/route.ts +++ b/memento-note/app/api/ai/config/route.ts @@ -1,7 +1,16 @@ import { NextRequest, NextResponse } from 'next/server' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' export async function GET(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() diff --git a/memento-note/app/api/ai/models/route.ts b/memento-note/app/api/ai/models/route.ts index 6ba7b8c..4b76897 100644 --- a/memento-note/app/api/ai/models/route.ts +++ b/memento-note/app/api/ai/models/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' // Modèles populaires pour chaque provider (2025) const PROVIDER_MODELS = { @@ -41,6 +42,14 @@ const PROVIDER_MODELS = { } export async function GET(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() const provider = (config.AI_PROVIDER || 'ollama').toLowerCase() diff --git a/memento-note/app/api/ai/test-chat/route.ts b/memento-note/app/api/ai/test-chat/route.ts index 196b2ec..77c2908 100644 --- a/memento-note/app/api/ai/test-chat/route.ts +++ b/memento-note/app/api/ai/test-chat/route.ts @@ -1,8 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() const provider = getChatProvider(config) diff --git a/memento-note/app/api/ai/test-embeddings/route.ts b/memento-note/app/api/ai/test-embeddings/route.ts index eefdf94..236a12f 100644 --- a/memento-note/app/api/ai/test-embeddings/route.ts +++ b/memento-note/app/api/ai/test-embeddings/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getEmbeddingsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' function getProviderDetails(config: Record, providerType: string) { const provider = providerType.toLowerCase() @@ -34,6 +35,14 @@ function getProviderDetails(config: Record, providerType: string } export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() const provider = getEmbeddingsProvider(config) diff --git a/memento-note/app/api/ai/test-tags/route.ts b/memento-note/app/api/ai/test-tags/route.ts index cbb4d9d..703a312 100644 --- a/memento-note/app/api/ai/test-tags/route.ts +++ b/memento-note/app/api/ai/test-tags/route.ts @@ -1,8 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' import { getTagsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() const provider = getTagsProvider(config) diff --git a/memento-note/app/api/ai/test/route.ts b/memento-note/app/api/ai/test/route.ts index d647443..28e2b64 100644 --- a/memento-note/app/api/ai/test/route.ts +++ b/memento-note/app/api/ai/test/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getTagsProvider, getEmbeddingsProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' function getProviderDetails(config: Record, providerType: string) { const provider = providerType.toLowerCase() @@ -34,6 +35,14 @@ function getProviderDetails(config: Record, providerType: string } export async function GET(request: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig() const tagsProvider = getTagsProvider(config) diff --git a/memento-note/app/api/ai/web-search-available/route.ts b/memento-note/app/api/ai/web-search-available/route.ts index 52d0a8e..5d73623 100644 --- a/memento-note/app/api/ai/web-search-available/route.ts +++ b/memento-note/app/api/ai/web-search-available/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from 'next/server' import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' export async function GET() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + try { const config = await getSystemConfig() const available = !!( diff --git a/memento-note/app/api/cron/reminders/route.ts b/memento-note/app/api/cron/reminders/route.ts index 61529fa..9d25b9b 100644 --- a/memento-note/app/api/cron/reminders/route.ts +++ b/memento-note/app/api/cron/reminders/route.ts @@ -1,9 +1,19 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/prisma'; export const dynamic = 'force-dynamic'; // No caching -export async function POST(request: Request) { +export async function POST(request: NextRequest) { + // Optional auth: set CRON_SECRET env var, callers must pass + // Authorization: Bearer + const cronSecret = process.env.CRON_SECRET + if (cronSecret) { + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + } + try { const now = new Date(); diff --git a/memento-note/app/api/debug/config/route.ts b/memento-note/app/api/debug/config/route.ts index c20a822..5557143 100644 --- a/memento-note/app/api/debug/config/route.ts +++ b/memento-note/app/api/debug/config/route.ts @@ -1,11 +1,20 @@ import { NextResponse } from 'next/server'; import { getSystemConfig } from '@/lib/config'; +import { auth } from '@/auth'; /** * Debug endpoint to check AI configuration * This helps verify that OpenAI is properly configured */ export async function GET() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const config = await getSystemConfig(); diff --git a/memento-note/app/api/debug/test-chat/route.ts b/memento-note/app/api/debug/test-chat/route.ts index 9bc18b8..d921a3e 100644 --- a/memento-note/app/api/debug/test-chat/route.ts +++ b/memento-note/app/api/debug/test-chat/route.ts @@ -1,7 +1,16 @@ import { NextResponse } from 'next/server'; import { chatService } from '@/lib/ai/services/chat.service'; +import { auth } from '@/auth'; export async function POST(req: Request) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const body = await req.json(); console.log("TEST ROUTE INCOMING BODY:", body); diff --git a/memento-note/app/api/fix-labels/route.ts b/memento-note/app/api/fix-labels/route.ts index 1d38c42..84381e0 100644 --- a/memento-note/app/api/fix-labels/route.ts +++ b/memento-note/app/api/fix-labels/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import prisma from '@/lib/prisma' import { revalidatePath } from 'next/cache' +import { auth } from '@/auth' function getHashColor(name: string): string { const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray'] @@ -12,6 +13,14 @@ function getHashColor(name: string): string { } export async function POST() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + try { const result = { created: 0, deleted: 0, missing: [] as string[] } diff --git a/memento-note/app/api/labels/route.ts b/memento-note/app/api/labels/route.ts index 2f58d1c..a2f7872 100644 --- a/memento-note/app/api/labels/route.ts +++ b/memento-note/app/api/labels/route.ts @@ -25,11 +25,8 @@ export async function GET(request: NextRequest) { // Get labels for a specific notebook where.notebookId = notebookId } else { - // Get all labels for the user (both old and new system) - where.OR = [ - { notebookId: { not: null } }, - { userId: session.user.id } - ] + // Get all labels for the user + where.userId = session.user.id } const labels = await prisma.label.findMany({ diff --git a/memento-note/lib/ai/providers/custom-openai.ts b/memento-note/lib/ai/providers/custom-openai.ts index 535256a..fcf3007 100644 --- a/memento-note/lib/ai/providers/custom-openai.ts +++ b/memento-note/lib/ai/providers/custom-openai.ts @@ -64,7 +64,7 @@ export class CustomOpenAIProvider implements AIProvider { return embedding; } catch (e) { console.error('Error generating embeddings (Custom OpenAI):', e); - return []; + throw e; } } diff --git a/memento-note/lib/ai/providers/deepseek.ts b/memento-note/lib/ai/providers/deepseek.ts index d9abd51..1e1b86d 100644 --- a/memento-note/lib/ai/providers/deepseek.ts +++ b/memento-note/lib/ai/providers/deepseek.ts @@ -48,7 +48,7 @@ export class DeepSeekProvider implements AIProvider { return embedding; } catch (e) { console.error('Error generating embeddings (DeepSeek):', e); - return []; + throw e; } } diff --git a/memento-note/lib/ai/providers/ollama.ts b/memento-note/lib/ai/providers/ollama.ts index 4923879..118d318 100644 --- a/memento-note/lib/ai/providers/ollama.ts +++ b/memento-note/lib/ai/providers/ollama.ts @@ -105,7 +105,7 @@ Note content: "${content}"`; return data.embedding; } catch (e) { console.error('Error generating embeddings (Ollama):', e); - return []; + throw e; } } diff --git a/memento-note/lib/ai/providers/openai.ts b/memento-note/lib/ai/providers/openai.ts index 4a4b447..cedb1d7 100644 --- a/memento-note/lib/ai/providers/openai.ts +++ b/memento-note/lib/ai/providers/openai.ts @@ -48,7 +48,7 @@ export class OpenAIProvider implements AIProvider { return embedding; } catch (e) { console.error('Error generating embeddings (OpenAI):', e); - return []; + throw e; } } diff --git a/memento-note/lib/ai/providers/openrouter.ts b/memento-note/lib/ai/providers/openrouter.ts index f39616f..5a38c72 100644 --- a/memento-note/lib/ai/providers/openrouter.ts +++ b/memento-note/lib/ai/providers/openrouter.ts @@ -48,7 +48,7 @@ export class OpenRouterProvider implements AIProvider { return embedding; } catch (e) { console.error('Error generating embeddings (OpenRouter):', e); - return []; + throw e; } }