mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix: SVG file support in mothership chat and file serving
- Send SVGs as document/text-xml to Claude instead of unsupported image/svg+xml, so the mothership can actually read SVG content - Serve SVGs inline with proper content type and CSP sandbox so chat previews render correctly - Add SVG preview support in file viewer (sandboxed iframe) - Derive IMAGE_MIME_TYPES from MIME_TYPE_MAPPING to reduce duplication - Add missing webp to contentTypeMap, SAFE_INLINE_TYPES, binaryExtensions - Consolidate PREVIEWABLE_EXTENSIONS into preview-panel exports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,9 +170,7 @@ describe('extractFilename', () => {
|
||||
'inline; filename="safe-image.png"'
|
||||
)
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
expect(response.headers.get('Content-Security-Policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
)
|
||||
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should serve PDFs inline safely', () => {
|
||||
@@ -203,33 +201,31 @@ describe('extractFilename', () => {
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
})
|
||||
|
||||
it('should force attachment for SVG files to prevent XSS', () => {
|
||||
it('should serve SVG files inline with CSP sandbox protection', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from(
|
||||
'<svg onload="alert(\'XSS\')" xmlns="http://www.w3.org/2000/svg"></svg>'
|
||||
),
|
||||
contentType: 'image/svg+xml',
|
||||
filename: 'malicious.svg',
|
||||
filename: 'image.svg',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="malicious.svg"'
|
||||
expect(response.headers.get('Content-Type')).toBe('image/svg+xml')
|
||||
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.svg"')
|
||||
expect(response.headers.get('Content-Security-Policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
)
|
||||
})
|
||||
|
||||
it('should override dangerous content types to safe alternatives', () => {
|
||||
it('should not apply CSP sandbox to non-SVG files', () => {
|
||||
const response = createFileResponse({
|
||||
buffer: Buffer.from('<svg>safe content</svg>'),
|
||||
contentType: 'image/svg+xml',
|
||||
filename: 'image.png', // Extension doesn't match content-type
|
||||
buffer: Buffer.from('hello'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'readme.txt',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// Should override SVG content type to plain text for safety
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain')
|
||||
expect(response.headers.get('Content-Disposition')).toBe('inline; filename="image.png"')
|
||||
expect(response.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should force attachment for JavaScript files', () => {
|
||||
@@ -302,15 +298,22 @@ describe('extractFilename', () => {
|
||||
})
|
||||
|
||||
describe('Content Security Policy', () => {
|
||||
it('should include CSP header in all responses', () => {
|
||||
const response = createFileResponse({
|
||||
it('should include CSP header only for SVG responses', () => {
|
||||
const svgResponse = createFileResponse({
|
||||
buffer: Buffer.from('<svg></svg>'),
|
||||
contentType: 'image/svg+xml',
|
||||
filename: 'icon.svg',
|
||||
})
|
||||
expect(svgResponse.headers.get('Content-Security-Policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
)
|
||||
|
||||
const txtResponse = createFileResponse({
|
||||
buffer: Buffer.from('test'),
|
||||
contentType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
})
|
||||
|
||||
const csp = response.headers.get('Content-Security-Policy')
|
||||
expect(csp).toBe("default-src 'none'; style-src 'unsafe-inline'; sandbox;")
|
||||
expect(txtResponse.headers.get('Content-Security-Policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('should include X-Content-Type-Options header', () => {
|
||||
|
||||
@@ -61,6 +61,8 @@ export const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
zip: 'application/zip',
|
||||
googleFolder: 'application/vnd.google-apps.folder',
|
||||
}
|
||||
@@ -77,6 +79,7 @@ export const binaryExtensions = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
]
|
||||
|
||||
@@ -204,13 +207,15 @@ const SAFE_INLINE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
])
|
||||
|
||||
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'svg', 'js', 'css', 'xml'])
|
||||
const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml'])
|
||||
|
||||
function getSecureFileHeaders(filename: string, originalContentType: string) {
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || ''
|
||||
@@ -224,7 +229,7 @@ function getSecureFileHeaders(filename: string, originalContentType: string) {
|
||||
|
||||
let safeContentType = originalContentType
|
||||
|
||||
if (originalContentType === 'text/html' || originalContentType === 'image/svg+xml') {
|
||||
if (originalContentType === 'text/html') {
|
||||
safeContentType = 'text/plain'
|
||||
}
|
||||
|
||||
@@ -253,16 +258,18 @@ function encodeFilenameForHeader(storageKey: string): string {
|
||||
export function createFileResponse(file: FileResponse): NextResponse {
|
||||
const { contentType, disposition } = getSecureFileHeaders(file.filename, file.contentType)
|
||||
|
||||
return new NextResponse(file.buffer as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
|
||||
'Cache-Control': file.cacheControl || 'public, max-age=31536000',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox;",
|
||||
},
|
||||
})
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(file.filename)}`,
|
||||
'Cache-Control': file.cacheControl || 'public, max-age=31536000',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
|
||||
if (contentType === 'image/svg+xml') {
|
||||
headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;"
|
||||
}
|
||||
|
||||
return new NextResponse(file.buffer as BodyInit, { status: 200, headers })
|
||||
}
|
||||
|
||||
export function createErrorResponse(error: Error, status = 500): NextResponse {
|
||||
|
||||
@@ -26,9 +26,20 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([
|
||||
'application/x-yaml',
|
||||
'text/csv',
|
||||
'text/html',
|
||||
'image/svg+xml',
|
||||
])
|
||||
|
||||
const TEXT_EDITABLE_EXTENSIONS = new Set(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm'])
|
||||
const TEXT_EDITABLE_EXTENSIONS = new Set([
|
||||
'md',
|
||||
'txt',
|
||||
'json',
|
||||
'yaml',
|
||||
'yml',
|
||||
'csv',
|
||||
'html',
|
||||
'htm',
|
||||
'svg',
|
||||
])
|
||||
|
||||
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
|
||||
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export type { PreviewMode } from './file-viewer'
|
||||
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
|
||||
export { PREVIEW_ONLY_EXTENSIONS, RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
|
||||
|
||||
@@ -6,12 +6,13 @@ import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | null
|
||||
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
|
||||
|
||||
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
|
||||
'text/markdown': 'markdown',
|
||||
'text/html': 'html',
|
||||
'text/csv': 'csv',
|
||||
'image/svg+xml': 'svg',
|
||||
}
|
||||
|
||||
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
@@ -19,8 +20,15 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
csv: 'csv',
|
||||
svg: 'svg',
|
||||
}
|
||||
|
||||
/** Extensions that should default to rendered preview (no raw editor). */
|
||||
export const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm', 'svg'])
|
||||
|
||||
/** All extensions that have a rich preview renderer. */
|
||||
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
|
||||
|
||||
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
|
||||
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
|
||||
const ext = getFileExtension(filename)
|
||||
@@ -39,6 +47,7 @@ export function PreviewPanel({ content, mimeType, filename }: PreviewPanelProps)
|
||||
if (previewType === 'markdown') return <MarkdownPreview content={content} />
|
||||
if (previewType === 'html') return <HtmlPreview content={content} />
|
||||
if (previewType === 'csv') return <CsvPreview content={content} />
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -175,6 +184,25 @@ function HtmlPreview({ content }: { content: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SvgPreview({ content }: { content: string }) {
|
||||
const wrappedContent = useMemo(
|
||||
() =>
|
||||
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
|
||||
[content]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
<iframe
|
||||
srcDoc={wrappedContent}
|
||||
sandbox=''
|
||||
title='SVG Preview'
|
||||
className='h-full w-full border-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CsvPreview({ content }: { content: string }) {
|
||||
const { headers, rows } = useMemo(() => parseCsv(content), [content])
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import {
|
||||
PREVIEW_ONLY_EXTENSIONS,
|
||||
RICH_PREVIEWABLE_EXTENSIONS,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { ResourceActions, ResourceContent, ResourceTabs } from './components'
|
||||
|
||||
const PREVIEWABLE_EXTENSIONS = new Set(['md', 'html', 'htm', 'csv'])
|
||||
const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm'])
|
||||
|
||||
const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
|
||||
editor: 'split',
|
||||
split: 'preview',
|
||||
@@ -57,7 +58,7 @@ export function MothershipView({
|
||||
}, [active?.id])
|
||||
|
||||
const isActivePreviewable =
|
||||
active?.type === 'file' && PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
|
||||
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MIME_TYPE_MAPPING: Record<string, 'image' | 'document' | 'audio' |
|
||||
'image/png': 'image',
|
||||
'image/gif': 'image',
|
||||
'image/webp': 'image',
|
||||
'image/svg+xml': 'image',
|
||||
// SVG is XML text, not a raster image — handled separately in createFileContent
|
||||
|
||||
// Documents
|
||||
'application/pdf': 'document',
|
||||
@@ -97,16 +97,14 @@ export function isSupportedFileType(mimeType: string): boolean {
|
||||
/**
|
||||
* Check if a MIME type is an image type (for copilot uploads)
|
||||
*/
|
||||
const IMAGE_MIME_TYPES = new Set(
|
||||
Object.entries(MIME_TYPE_MAPPING)
|
||||
.filter(([, v]) => v === 'image')
|
||||
.map(([k]) => k)
|
||||
)
|
||||
|
||||
export function isImageFileType(mimeType: string): boolean {
|
||||
const imageTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]
|
||||
return imageTypes.includes(mimeType.toLowerCase())
|
||||
return IMAGE_MIME_TYPES.has(mimeType.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +140,19 @@ export function bufferToBase64(buffer: Buffer): string {
|
||||
* Create message content from file data
|
||||
*/
|
||||
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
|
||||
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
|
||||
// so send SVGs as an XML document instead
|
||||
if (mimeType.toLowerCase() === 'image/svg+xml') {
|
||||
return {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'text/xml',
|
||||
data: bufferToBase64(fileBuffer),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = getContentType(mimeType)
|
||||
if (!contentType) {
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user