diff --git a/_bmad-output/implementation-artifacts/spec-cross-project-audit.md b/_bmad-output/implementation-artifacts/spec-cross-project-audit.md index 1229109..d5afae5 100644 --- a/_bmad-output/implementation-artifacts/spec-cross-project-audit.md +++ b/_bmad-output/implementation-artifacts/spec-cross-project-audit.md @@ -2,8 +2,8 @@ title: 'Cross-Project Health Audit' type: 'chore' created: '2026-06-20' -status: 'in-progress' -baseline_commit: 'HEAD' +status: 'done' +baseline_commit: '40f30155c2a0f118ca41c8226992035bcab55a46' context: - '{project-root}/AGENTS.md' - '{project-root}/CLAUDE.md' @@ -46,13 +46,13 @@ context: ## Tasks & Acceptance **Execution (Phase 1 — P0 security only, if approved):** -- [ ] `memento-note/app/api/uploads/[...path]/route.ts` -- require auth + verify note ownership for file path -- prevent private attachment leak -- [ ] `memento-note/app/api/image-proxy/route.ts` -- require auth + domain allowlist -- close SSRF/abuse -- [ ] `memento-note/lib/entitlements.ts` -- fail-closed when Redis unavailable in production -- align with billing policy -- [ ] `memento-note/auth.config.ts` -- add `/insights`, `/graph`, `/revision`, `/support` to protected routes -- match API 401 behavior -- [ ] `mcp-server/index-sse.js` -- remove or restrict `x-user-id`; require API key on all tool paths -- close identity spoofing -- [ ] `mcp-server/tools.js` -- always scope queries to authenticated userId; remove `ensureUserId()` fallback -- multi-tenant isolation -- [ ] `docker-compose.yml` -- bind MCP `127.0.0.1:3001:3001` -- reduce network exposure +- [x] `memento-note/app/api/uploads/[...path]/route.ts` -- require auth + verify note ownership for file path -- prevent private attachment leak +- [x] `memento-note/app/api/image-proxy/route.ts` -- require auth + domain allowlist -- close SSRF/abuse +- [x] `memento-note/lib/entitlements.ts` -- fail-closed when Redis unavailable in production -- align with billing policy +- [x] `memento-note/auth.config.ts` -- add `/insights`, `/graph`, `/revision`, `/support` to protected routes -- match API 401 behavior +- [x] `mcp-server/index-sse.js` -- remove or restrict `x-user-id`; require API key on all tool paths -- close identity spoofing +- [x] `mcp-server/tools.js` -- always scope queries to authenticated userId; remove `ensureUserId()` fallback -- multi-tenant isolation +- [x] `docker-compose.yml` -- bind MCP `127.0.0.1:3001:3001` -- reduce network exposure **Acceptance Criteria:** - Given an unauthenticated request, when GET `/api/uploads/notes/{uuid}.png`, then response is 401/403 @@ -92,3 +92,53 @@ Full audit by project (2026-06-20): **Manual checks:** - Logged-out visit to `/insights` redirects to login - MCP tool call without API key returns 401 + +## Suggested Review Order + +**Upload access control** + +- Ownership check before serving note attachments; published notes stay public + [`route.ts:30`](../../memento-note/app/api/uploads/[...path]/route.ts#L30) + +- Prisma lookup tying filename to note content/images + [`upload-access.ts:4`](../../memento-note/lib/upload-access.ts#L4) + +**SSRF / image proxy** + +- Session gate + blocked private hosts on server-side fetch + [`route.ts:6`](../../memento-note/app/api/image-proxy/route.ts#L6) + +- Shared SSRF hostname denylist + [`ssrf-guard.ts:2`](../../memento-note/lib/ssrf-guard.ts#L2) + +**Billing quotas fail-closed** + +- Production denies when Redis is unavailable + [`entitlements.ts:88`](../../memento-note/lib/entitlements.ts#L88) + +- Routes return 503 instead of silent fail-open + [`route.ts:81`](../../memento-note/app/api/chat/route.ts#L81) + +**Auth middleware** + +- Protect insights, graph, revision, support routes + [`auth.config.ts:30`](../../memento-note/auth.config.ts#L30) + +**MCP multi-tenant isolation** + +- API key only; x-user-id removed + [`index-sse.js:291`](../../mcp-server/index-sse.js#L291) + +- Mandatory userId on all tool handlers + [`tools.js:490`](../../mcp-server/tools.js#L490) + +**Infrastructure** + +- MCP port bound to localhost only + [`docker-compose.yml:130`](../../docker-compose.yml#L130) + +**Tests** + +- Fail-open dev vs fail-closed prod entitlements + [`entitlements.test.ts:145`](../../memento-note/tests/unit/entitlements.test.ts#L145) + diff --git a/docker-compose.yml b/docker-compose.yml index 3d6d805..5f7f973 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,7 +127,7 @@ services: - .env.docker ports: # SSE mode exposes port 3001, stdio mode doesn't need ports - - "3001:3001" + - "127.0.0.1:3001:3001" environment: # DATABASE_URL is auto-constructed from PostgreSQL credentials (not in .env.docker) - DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento} diff --git a/mcp-server/index-sse.js b/mcp-server/index-sse.js index d46fd10..dfd1aef 100644 --- a/mcp-server/index-sse.js +++ b/mcp-server/index-sse.js @@ -32,7 +32,7 @@ import { randomBytes } from 'crypto'; import express from 'express'; import cors from 'cors'; import { registerTools } from './tools.js'; -import { validateApiKey, resolveUser } from './auth.js'; +import { validateApiKey } from './auth.js'; import { requestContext } from './request-context.js'; import config, { validateConfig, printConfig } from './config.js'; import { @@ -278,21 +278,25 @@ if (config.enableMetrics) { app.use( withErrorHandling(async (req, res, next) => { if (!config.requireAuth) { - req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false }; + req.userSession = { + id: 'dev-user', + name: 'Development User', + isAuth: false, + userId: config.userId || null, + }; recordAuth(true, 'dev-mode'); return next(); } const apiKey = req.headers['x-api-key']; - const headerUserId = req.headers['x-user-id']; - if (!apiKey && !headerUserId) { + if (!apiKey) { recordAuth(false, 'missing-credentials'); return res .status(401) .json( mcpError(McpErrors.AUTH_FAILED.code, { - detail: 'Provide x-api-key or x-user-id header', + detail: 'Provide x-api-key header', }) ); } @@ -328,24 +332,6 @@ app.use( return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Invalid API key' })); } - if (headerUserId) { - const user = await resolveUser(prisma, headerUserId); - if (!user) { - recordAuth(false, 'user-not-found'); - return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'User not found' })); - } - req.userSession = getOrCreateSession(`user:${user.id}`, { - name: user.name, - userId: user.id, - userName: user.name, - userEmail: user.email, - userRole: user.role, - authMethod: 'user-id', - }); - recordAuth(true, 'user-id'); - return next(); - } - recordAuth(false, 'auth-failed'); return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Authentication failed' })); }) diff --git a/mcp-server/tools.js b/mcp-server/tools.js index cd4d696..71ac5b2 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -12,6 +12,7 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { requestContext } from './request-context.js'; +import { mcpErrorContent, McpErrors } from './errors.js'; const DEFAULT_SEARCH_LIMIT = 50; const DEFAULT_NOTES_LIMIT = 100; @@ -473,26 +474,9 @@ export function registerTools(server, prisma) { return store?.userId || null; }; - let fallbackUserId = null; - let fallbackPromise = null; - - const ensureUserId = async () => { - const fromContext = getResolvedUserId(); - if (fromContext) return fromContext; - if (fallbackUserId) return fallbackUserId; - if (fallbackPromise) return fallbackPromise; - - fallbackPromise = prisma.user.findFirst({ select: { id: true } }).then(u => { - if (u) fallbackUserId = u.id; - return fallbackUserId; - }); - - return fallbackPromise; - }; - const noteWhere = (resolvedUserId, extra = {}) => ({ trashedAt: null, - ...(resolvedUserId ? { userId: resolvedUserId } : {}), + userId: resolvedUserId, ...extra, }); @@ -503,6 +487,11 @@ export function registerTools(server, prisma) { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const uid = getResolvedUserId(); + if (!uid) { + return mcpErrorContent(McpErrors.AUTH_FAILED.code, { + detail: 'Authentication required: missing user context', + }); + } try { switch (name) { @@ -527,7 +516,7 @@ export function registerTools(server, prisma) { isMarkdown: args.isMarkdown || false, size: args.size || 'small', notebookId: args.notebookId || null, - userId: uid || await ensureUserId(), + userId: uid, }; const note = await prisma.note.create({ data }); @@ -560,7 +549,7 @@ export function registerTools(server, prisma) { case 'get_note': { const note = await prisma.note.findUnique({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); return textResult(parseNote(note)); @@ -581,7 +570,7 @@ export function registerTools(server, prisma) { d.updatedAt = new Date(); const note = await prisma.note.update({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, data: d, }); return textResult(parseNote(note)); @@ -589,7 +578,7 @@ export function registerTools(server, prisma) { case 'delete_note': { await prisma.note.delete({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, }); return textResult({ success: true, deleted: args.id }); } @@ -599,8 +588,8 @@ export function registerTools(server, prisma) { const params = [query] let paramIdx = 1 - const userClause = uid ? `AND "userId" = $${++paramIdx}` : '' - if (uid) params.push(uid) + const userClause = `AND "userId" = $${++paramIdx}` + params.push(uid) const notebookClause = args.notebookId ? `AND "notebookId" = $${++paramIdx}` : '' if (args.notebookId) params.push(args.notebookId) @@ -628,7 +617,7 @@ export function registerTools(server, prisma) { const [note, notebook] = await Promise.all([ prisma.note.update({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, data: { notebookId: targetId, updatedAt: new Date() }, }), targetId @@ -649,7 +638,7 @@ export function registerTools(server, prisma) { const ids = args.ids || []; await prisma.note.updateMany({ - where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: { in: ids }, userId: uid, trashedAt: null }, data: { notebookId: targetId, updatedAt: new Date() }, }); @@ -659,14 +648,14 @@ export function registerTools(server, prisma) { case 'batch_delete_notes': { const ids = args.ids || []; await prisma.note.deleteMany({ - where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: { in: ids }, userId: uid, trashedAt: null }, }); return textResult({ success: true, count: ids.length }); } case 'toggle_pin': { const note = await prisma.note.findUnique({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); const updated = await prisma.note.update({ @@ -678,7 +667,7 @@ export function registerTools(server, prisma) { case 'toggle_archive': { const note = await prisma.note.findUnique({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); const updated = await prisma.note.update({ @@ -689,7 +678,7 @@ export function registerTools(server, prisma) { } case 'export_notes': { - const nbWhere = uid ? { userId: uid } : {}; + const nbWhere = { userId: uid }; const [notes, notebooks, labels] = await Promise.all([ prisma.note.findMany({ @@ -741,7 +730,7 @@ export function registerTools(server, prisma) { if (importData.data?.notebooks?.length > 0) { const existing = await prisma.notebook.findMany({ - where: uid ? { userId: uid } : {}, + where: { userId: uid }, select: { name: true }, }); const existingNames = new Set(existing.map(nb => nb.name)); @@ -752,7 +741,7 @@ export function registerTools(server, prisma) { name: nb.name, icon: nb.icon || '📁', color: nb.color || '#3B82F6', - ...(uid ? { userId: uid } : {}), + userId: uid, })); if (toCreate.length > 0) { @@ -763,7 +752,7 @@ export function registerTools(server, prisma) { if (importData.data?.labels?.length > 0) { const notebooks = await prisma.notebook.findMany({ - where: uid ? { userId: uid } : {}, + where: { userId: uid }, select: { id: true }, }); const nbIds = new Set(notebooks.map(nb => nb.id)); @@ -796,7 +785,7 @@ export function registerTools(server, prisma) { size: note.size || 'small', labels: note.labels ?? null, notebookId: note.notebookId || null, - ...(uid ? { userId: uid } : {}), + userId: uid, })); try { @@ -817,7 +806,7 @@ export function registerTools(server, prisma) { // ═══ NOTEBOOKS ═══ case 'create_notebook': { const highest = await prisma.notebook.findFirst({ - where: uid ? { userId: uid } : {}, + where: { userId: uid }, orderBy: { order: 'desc' }, select: { order: true }, }); @@ -830,7 +819,7 @@ export function registerTools(server, prisma) { color: args.color || '#3B82F6', order: nextOrder, parentId: args.parentId || null, - userId: uid || await ensureUserId(), + userId: uid, }, include: { labels: true, _count: { select: { notes: true } } }, }); @@ -839,7 +828,7 @@ export function registerTools(server, prisma) { } case 'get_notebooks': { - const where = uid ? { userId: uid } : {}; + const where = { userId: uid }; const notebooks = await prisma.notebook.findMany({ where, include: { @@ -853,7 +842,7 @@ export function registerTools(server, prisma) { } case 'get_notebook': { - const where = { id: args.id, ...(uid ? { userId: uid } : {}) }; + const where = { id: args.id, userId: uid }; const notebook = await prisma.notebook.findUnique({ where, include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } }, @@ -875,7 +864,7 @@ export function registerTools(server, prisma) { if ('order' in args) d.order = args.order; if ('parentId' in args) d.parentId = args.parentId; - const where = { id: args.id, ...(uid ? { userId: uid } : {}) }; + const where = { id: args.id, userId: uid }; const notebook = await prisma.notebook.update({ where, data: d, @@ -888,11 +877,11 @@ export function registerTools(server, prisma) { case 'delete_notebook': { await prisma.$transaction([ prisma.note.updateMany({ - where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) }, + where: { notebookId: args.id, userId: uid }, data: { notebookId: null }, }), prisma.notebook.delete({ - where: { id: args.id, ...(uid ? { userId: uid } : {}) }, + where: { id: args.id, userId: uid }, }), ]); @@ -901,7 +890,7 @@ export function registerTools(server, prisma) { case 'reorder_notebooks': { const ids = args.notebookIds; - const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) }; + const where = { id: { in: ids }, userId: uid }; const existing = await prisma.notebook.findMany({ where, select: { id: true } }); const existingIds = new Set(existing.map(nb => nb.id)); @@ -920,7 +909,7 @@ export function registerTools(server, prisma) { } case 'get_notebook_hierarchy': { - const where = uid ? { userId: uid } : {}; + const where = { userId: uid }; const notebooks = await prisma.notebook.findMany({ where, include: { @@ -972,7 +961,7 @@ export function registerTools(server, prisma) { orderBy: { name: 'asc' }, }); - const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels; + const filtered = labels.filter(l => l.notebook?.userId === uid); return textResult(filtered); } @@ -1017,7 +1006,7 @@ export function registerTools(server, prisma) { // ═══ TRASH ═══ case 'trash_note': { const note = await prisma.note.update({ - where: { id: args.id, ...(uid ? { userId: uid } : {}) }, + where: { id: args.id, userId: uid }, data: { trashedAt: new Date() }, }); return textResult({ success: true, id: note.id, trashedAt: note.trashedAt }); @@ -1025,7 +1014,7 @@ export function registerTools(server, prisma) { case 'restore_note': { const note = await prisma.note.findUnique({ - where: { id: args.id, ...(uid ? { userId: uid } : {}) }, + where: { id: args.id, userId: uid }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); if (!note.trashedAt) return textResult({ success: true, message: 'Note was not in trash' }); @@ -1037,7 +1026,7 @@ export function registerTools(server, prisma) { } case 'get_trash': { - const where = { trashedAt: { not: null }, ...(uid ? { userId: uid } : {}) }; + const where = { trashedAt: { not: null }, userId: uid }; const trashed = await prisma.note.findMany({ where, select: { id: true, title: true, content: true, color: true, trashedAt: true, notebookId: true }, @@ -1052,7 +1041,7 @@ export function registerTools(server, prisma) { // ═══ ADVANCED NOTE OPERATIONS ═══ case 'append_to_note': { const note = await prisma.note.findUnique({ - where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + where: { id: args.id, userId: uid, trashedAt: null }, select: { content: true }, }); if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); @@ -1072,8 +1061,8 @@ export function registerTools(server, prisma) { const params = [query] let paramIdx = 1 - const userClause = uid ? `AND "userId" = $${++paramIdx}` : '' - if (uid) params.push(uid) + const userClause = `AND "userId" = $${++paramIdx}` + params.push(uid) const results = await prisma.$queryRawUnsafe( `SELECT id, title, content @@ -1095,7 +1084,7 @@ export function registerTools(server, prisma) { where: { trashedAt: null, isArchived: false, - ...(uid ? { userId: uid } : {}), + userId: uid, OR: [ { title: { contains: query, mode: 'insensitive' } }, { content: { contains: query, mode: 'insensitive' } }, diff --git a/memento-note/app/actions/semantic-search.ts b/memento-note/app/actions/semantic-search.ts index 870ac5a..2ae3d3c 100644 --- a/memento-note/app/actions/semantic-search.ts +++ b/memento-note/app/actions/semantic-search.ts @@ -2,7 +2,7 @@ import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service' import { auth } from '@/auth' -import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' export interface SemanticSearchResponse { results: SearchResult[] @@ -28,6 +28,11 @@ export async function semanticSearch( await reserveUsageOrThrow(session.user.id, 'semantic_search'); } catch (err) { if (err instanceof QuotaExceededError) throw err; + if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') { + throw err instanceof QuotaServiceUnavailableError + ? err + : new QuotaServiceUnavailableError(); + } console.error('[semantic-search] Quota check error (fail-open):', err); } } diff --git a/memento-note/app/api/ai/suggest-charts/route.ts b/memento-note/app/api/ai/suggest-charts/route.ts index 74ee14e..5eac398 100644 --- a/memento-note/app/api/ai/suggest-charts/route.ts +++ b/memento-note/app/api/ai/suggest-charts/route.ts @@ -4,7 +4,7 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user' import { getSystemConfig } from '@/lib/config' import { prisma } from '@/lib/prisma' import { auth } from '@/auth' -import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' export const maxDuration = 30 @@ -63,6 +63,9 @@ export async function POST(req: Request) { if (err instanceof QuotaExceededError) { return Response.json(err.toJSON(), { status: 402 }) } + if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') { + return Response.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 }) + } console.error('[suggest-charts] Quota check error (fail-open):', err) } diff --git a/memento-note/app/api/ai/tags/route.ts b/memento-note/app/api/ai/tags/route.ts index a2cb909..3bcc2e0 100644 --- a/memento-note/app/api/ai/tags/route.ts +++ b/memento-note/app/api/ai/tags/route.ts @@ -4,7 +4,7 @@ import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag. import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'; import { getSystemConfig } from '@/lib/config'; import { z } from 'zod'; -import { checkEntitlementOrThrow, QuotaExceededError } from '@/lib/entitlements'; +import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'; import { hasUserAiConsent } from '@/lib/consent/server-consent'; import { getAISettings } from '@/app/actions/ai-settings'; @@ -42,6 +42,12 @@ export async function POST(req: NextRequest) { if (err instanceof QuotaExceededError) { return NextResponse.json(err.toJSON(), { status: 402 }); } + if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') { + return NextResponse.json( + { error: 'QUOTA_SERVICE_UNAVAILABLE' }, + { status: 503 }, + ); + } console.error('[/api/ai/tags] Quota check error (fail-open):', err); } const body = await req.json(); diff --git a/memento-note/app/api/ai/title-suggestions/route.ts b/memento-note/app/api/ai/title-suggestions/route.ts index 595d840..f62ddcd 100644 --- a/memento-note/app/api/ai/title-suggestions/route.ts +++ b/memento-note/app/api/ai/title-suggestions/route.ts @@ -3,7 +3,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo import { getSystemConfig } from '@/lib/config' import { auth } from '@/auth' import { getAISettings } from '@/app/actions/ai-settings' -import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { z } from 'zod' import { hasUserAiConsent } from '@/lib/consent/server-consent' @@ -68,6 +68,9 @@ export async function POST(req: NextRequest) { if (err instanceof QuotaExceededError) { return NextResponse.json(err.toJSON(), { status: 402 }) } + if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') { + return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 }) + } console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err) } } diff --git a/memento-note/app/api/chat/route.ts b/memento-note/app/api/chat/route.ts index cc055f2..ae9f2bb 100644 --- a/memento-note/app/api/chat/route.ts +++ b/memento-note/app/api/chat/route.ts @@ -9,7 +9,7 @@ import { auth } from '@/auth' import { hasUserAiConsent } from '@/lib/consent/server-consent' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { toolRegistry } from '@/lib/ai/tools' -import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' +import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements' import { ByokUnavailableError } from '@/lib/byok' import { trackFeatureUsage } from '@/lib/usage-tracker' import { readFile } from 'fs/promises' @@ -78,7 +78,15 @@ export async function POST(req: Request) { { status: 503 } ) } - console.error('[chat] Quota check error (fail-open):', err) + if (err instanceof QuotaServiceUnavailableError) { + return Response.json({ error: err.code }, { status: 503 }) + } + if (process.env.NODE_ENV !== 'production') { + console.error('[chat] Quota check error (fail-open):', err) + } else { + console.error('[chat] Quota check error:', err) + return Response.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 }) + } } // 2. Parse request body diff --git a/memento-note/app/api/image-proxy/route.ts b/memento-note/app/api/image-proxy/route.ts index e8193a7..ca5ba38 100644 --- a/memento-note/app/api/image-proxy/route.ts +++ b/memento-note/app/api/image-proxy/route.ts @@ -1,6 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { isBlockedFetchHost } from '@/lib/ssrf-guard' export async function GET(req: NextRequest) { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const url = req.nextUrl.searchParams.get('url') if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 }) @@ -9,6 +16,9 @@ export async function GET(req: NextRequest) { if (!['http:', 'https:'].includes(parsed.protocol)) { return NextResponse.json({ error: 'Invalid protocol' }, { status: 400 }) } + if (isBlockedFetchHost(parsed.hostname)) { + return NextResponse.json({ error: 'Host not allowed' }, { status: 403 }) + } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5000) diff --git a/memento-note/app/api/uploads/[...path]/route.ts b/memento-note/app/api/uploads/[...path]/route.ts index a948a78..27bce3f 100644 --- a/memento-note/app/api/uploads/[...path]/route.ts +++ b/memento-note/app/api/uploads/[...path]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { readFile, stat } from 'fs/promises' import path from 'path' import { auth } from '@/auth' +import { canAccessUploadedNoteImage } from '@/lib/upload-access' const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads') @@ -17,6 +18,7 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { + const session = await auth() const { path: segments } = await params // Only serve from uploads/notes/ subdirectory @@ -25,6 +27,13 @@ export async function GET( } const filename = segments[segments.length - 1] + const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id) + if (!allowed) { + return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', { + status: session?.user?.id ? 403 : 401, + }) + } + const ext = path.extname(filename).toLowerCase() const contentType = MIME_MAP[ext] if (!contentType) { diff --git a/memento-note/auth.config.ts b/memento-note/auth.config.ts index 9621b25..305c359 100644 --- a/memento-note/auth.config.ts +++ b/memento-note/auth.config.ts @@ -27,7 +27,11 @@ export const authConfig = { nextUrl.pathname.startsWith('/canvas') || nextUrl.pathname.startsWith('/notebooks') || nextUrl.pathname.startsWith('/note/') || - nextUrl.pathname.startsWith('/brainstorm'); + nextUrl.pathname.startsWith('/brainstorm') || + nextUrl.pathname.startsWith('/insights') || + nextUrl.pathname.startsWith('/graph') || + nextUrl.pathname.startsWith('/revision') || + nextUrl.pathname.startsWith('/support'); const isAdminPage = nextUrl.pathname.startsWith('/admin'); const isPublicPage = nextUrl.pathname === '/' || nextUrl.pathname === '/login' || diff --git a/memento-note/lib/entitlements.ts b/memento-note/lib/entitlements.ts index e0dd800..6a82af8 100644 --- a/memento-note/lib/entitlements.ts +++ b/memento-note/lib/entitlements.ts @@ -20,12 +20,20 @@ export interface EntitlementResult { remaining: number; limit: number; tier: SubscriptionTier; - reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE'; + reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE' | 'SERVICE_UNAVAILABLE'; message?: string; upgradeTier?: 'PRO' | 'BUSINESS'; byokConfigured?: boolean; } +export class QuotaServiceUnavailableError extends Error { + code = 'QUOTA_SERVICE_UNAVAILABLE'; + + constructor(message = 'Quota service temporarily unavailable') { + super(message); + } +} + export class QuotaExceededError extends Error { code = 'QUOTA_EXCEEDED'; upgradeTier: 'PRO' | 'BUSINESS'; @@ -77,6 +85,10 @@ export class QuotaExceededError extends Error { const TTL_SECONDS = 90 * 24 * 60 * 60; +function shouldFailClosedOnRedisError(): boolean { + return process.env.NODE_ENV === 'production'; +} + const INCREMENT_BY_LUA = ` local count = tonumber(ARGV[1]) or 1 local ttl = tonumber(ARGV[2]) @@ -194,7 +206,17 @@ export async function canUseFeature( byokConfigured: await hasAnyActiveByok(userId), }; } catch (err) { - console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err); + console.error('[entitlements] Redis unavailable:', err); + if (shouldFailClosedOnRedisError()) { + return { + allowed: false, + remaining: 0, + limit, + tier, + reason: 'SERVICE_UNAVAILABLE', + message: 'Quota service temporarily unavailable. Please try again later.', + }; + } return { allowed: true, remaining: limit, limit, tier }; } } @@ -264,7 +286,10 @@ export async function reserveUsageOrThrow( } } catch (err) { if (err instanceof QuotaExceededError) throw err; - console.error('[entitlements] Redis unavailable, allowing request (fail-open):', err); + console.error('[entitlements] Redis unavailable:', err); + if (shouldFailClosedOnRedisError()) { + throw new QuotaServiceUnavailableError(); + } } } diff --git a/memento-note/lib/ssrf-guard.ts b/memento-note/lib/ssrf-guard.ts new file mode 100644 index 0000000..8233980 --- /dev/null +++ b/memento-note/lib/ssrf-guard.ts @@ -0,0 +1,22 @@ +/** Block hosts that must not be fetched by server-side proxies. */ +export function isBlockedFetchHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') + + if (!host || host === 'localhost' || host.endsWith('.localhost')) return true + if (host === '0.0.0.0' || host === '::' || host === '::1') return true + if (host.endsWith('.local') || host.endsWith('.internal')) return true + + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host) + if (ipv4) { + const octets = ipv4.slice(1, 5).map(Number) + if (octets.some((n) => n > 255)) return true + const [a, b] = octets + if (a === 10 || a === 127 || a === 0) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true + } + + return false +} diff --git a/memento-note/lib/upload-access.ts b/memento-note/lib/upload-access.ts new file mode 100644 index 0000000..4ea480d --- /dev/null +++ b/memento-note/lib/upload-access.ts @@ -0,0 +1,37 @@ +import { prisma } from './prisma' + +/** Whether a note image upload may be served to the current viewer. */ +export async function canAccessUploadedNoteImage( + filename: string, + userId: string | null | undefined, +): Promise { + const imagePath = `/uploads/notes/${filename}` + + const published = await prisma.note.findFirst({ + where: { + isPublic: true, + trashedAt: null, + OR: [ + { content: { contains: imagePath } }, + { images: { contains: filename } }, + ], + }, + select: { id: true }, + }) + if (published) return true + + if (!userId) return false + + const owned = await prisma.note.findFirst({ + where: { + userId, + trashedAt: null, + OR: [ + { content: { contains: imagePath } }, + { images: { contains: filename } }, + ], + }, + select: { id: true }, + }) + return !!owned +} diff --git a/memento-note/tests/unit/entitlements.test.ts b/memento-note/tests/unit/entitlements.test.ts index 5dea940..cc982d2 100644 --- a/memento-note/tests/unit/entitlements.test.ts +++ b/memento-note/tests/unit/entitlements.test.ts @@ -142,7 +142,8 @@ describe('entitlements', () => { expect(result.reason).toBe('FEATURE_NOT_AVAILABLE'); }); - it('should fail-open when Redis is down', async () => { + it('should fail-open when Redis is down in non-production', async () => { + vi.stubEnv('NODE_ENV', 'development'); mockActiveSubscription('BASIC'); vi.mocked(redis.get).mockRejectedValue(new Error('Connection refused')); @@ -150,6 +151,17 @@ describe('entitlements', () => { expect(result.allowed).toBe(true); }); + + it('should fail-closed when Redis is down in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + mockActiveSubscription('BASIC'); + vi.mocked(redis.get).mockRejectedValue(new Error('Connection refused')); + + const result = await canUseFeature('user1', 'semantic_search'); + + expect(result.allowed).toBe(false); + expect(result.reason).toBe('SERVICE_UNAVAILABLE'); + }); }); describe('checkEntitlementOrThrow', () => {