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:
waleed
2026-03-13 20:39:48 -07:00
parent 7ad813b554
commit e0af69c2ef
7 changed files with 111 additions and 49 deletions

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -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'])

View File

@@ -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'

View File

@@ -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])

View File

@@ -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

View File

@@ -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