mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
fix(security): patch dependency CVEs and close SSRF/XSS gaps in MCP image upload
Bumps deps to clear all 69 pnpm audit findings (nodemailer 8->9 for the raw-option SSRF/arbitrary-file-read CVE; svelte, hono/express/qs chain, linkify-it, and the rest resolved within their existing semver ranges via a lockfile refresh). Caps the previously-unbounded cookie override at <1 so it can't silently jump to the API-incompatible 2.x line again. Hardens the MCP upload_image tool, which fetched an arbitrary caller- supplied URL and stored whatever content-type came back with no checks: - validates every resolved DNS record (not just the first) against private/loopback/link-local/multicast ranges, then pins the actual connection to the validated address so a second, unvalidated lookup at connect time can't be used for DNS-rebinding SSRF - rejects non-image content-types and enforces the same 10MB cap as the manual upload endpoint - adds X-Content-Type-Options: nosniff to both attachment-serving routes as defense in depth Also validates the admin role field server-side (previously trusted the client-supplied string), and fixes two incidental regressions surfaced by the dependency bump: StarterKit now bundles its own Link/Underline extensions (disabled to avoid duplicate registration), and FormattingToolbar could call methods on an already-destroyed editor instance during the markdown-mode toggle race. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
97523ea087
commit
d4ed358ba3
+40
-40
@@ -34,64 +34,64 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"markdown-it": "^14.3.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.63.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.5",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.6",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.2.7",
|
||||
"workbox-window": "^7.4.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -102,7 +102,7 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"cookie": ">=0.7.0 <1",
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
|
||||
Generated
+1372
-1936
File diff suppressed because it is too large
Load Diff
@@ -37,11 +37,14 @@
|
||||
|
||||
let { editor, tick }: Props = $props();
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
||||
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
||||
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
||||
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
||||
function canRedo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().redo().run()) ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({ link: false, underline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
|
||||
const MAX_IMAGE_REDIRECTS = 5;
|
||||
|
||||
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
|
||||
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
|
||||
// upload_image can't be used to reach internal network services.
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
if (address.includes(':')) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fe80:')) return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
|
||||
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
|
||||
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
|
||||
return false;
|
||||
}
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
||||
const [a, b] = parts;
|
||||
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;
|
||||
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
|
||||
// (not just the first), so a validated hostname can't have a private record hiding
|
||||
// behind a public one in the same DNS answer.
|
||||
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
const records = await lookup(hostname, { all: true });
|
||||
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
|
||||
for (const record of records) {
|
||||
if (isPrivateOrReservedIp(record.address)) {
|
||||
throw new Error('URL resolves to a private or reserved address');
|
||||
}
|
||||
}
|
||||
return records[0];
|
||||
}
|
||||
|
||||
interface PinnedResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(name: string): string | null };
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
// Issues the request against the exact address validated by resolveValidatedAddress,
|
||||
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
|
||||
// could serve a public address to the validator and a private one to the real connect.
|
||||
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
servername: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
|
||||
callback(null, address, family);
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
req.destroy(new Error('Image too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const status = res.statusCode ?? 0;
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const value = res.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
},
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
|
||||
});
|
||||
});
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
|
||||
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
|
||||
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
|
||||
let currentUrl = new URL(imageUrl);
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
|
||||
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
}
|
||||
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
|
||||
const response = await requestPinnedAddress(currentUrl, address, family);
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response;
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const response = await fetchPublicImage(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (contentLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
if (body.role !== 'admin' && body.role !== 'user') {
|
||||
throw error(400, 'Invalid role');
|
||||
}
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own role');
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const GET: RequestHandler = async ({ params, url, getClientAddress }) =>
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.27.1",
|
||||
"version": "0.31.0",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
|
||||
Reference in New Issue
Block a user