diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index c3d9e7e..1b05a7c 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -306,7 +306,7 @@ describe('httpApiV1 handlers', () => { size: 5, storageId: 'storage:1', sha256: 'abcd', - contentType: 'text/plain', + contentType: 'image/svg+xml', }, ], softDeletedAt: undefined, @@ -327,7 +327,7 @@ describe('httpApiV1 handlers', () => { }) const runMutation = vi.fn().mockResolvedValue(okRate()) const storage = { - get: vi.fn().mockResolvedValue(new Blob(['hello'], { type: 'text/plain' })), + get: vi.fn().mockResolvedValue(new Blob(['hello'], { type: 'image/svg+xml' })), } const response = await __handlers.skillsGetRouterV1Handler( makeCtx({ runQuery, runMutation, storage }), @@ -335,6 +335,55 @@ describe('httpApiV1 handlers', () => { ) expect(response.status).toBe(200) expect(await response.text()).toBe('hello') + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(response.headers.get('Content-Security-Policy')).toBe("sandbox; default-src 'none'") + expect(response.headers.get('X-Content-SHA256')).toBe('abcd') + }) + + it('serves soul raw file content with safe headers', async () => { + const version = { + version: '1.0.0', + createdAt: 1, + changelog: 'c', + files: [ + { + path: 'SOUL.md', + size: 5, + storageId: 'storage:1', + sha256: 'abcd', + contentType: 'image/svg+xml', + }, + ], + softDeletedAt: undefined, + } + const runQuery = vi.fn().mockResolvedValue({ + soul: { + _id: 'souls:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: {}, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: version, + owner: null, + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const storage = { + get: vi.fn().mockResolvedValue(new Blob(['hello'], { type: 'image/svg+xml' })), + } + const response = await __handlers.soulsGetRouterV1Handler( + makeCtx({ runQuery, runMutation, storage }), + new Request('https://example.com/api/v1/souls/demo/file?path=SOUL.md'), + ) + expect(response.status).toBe(200) + expect(await response.text()).toBe('hello') + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(response.headers.get('Content-Security-Policy')).toBe("sandbox; default-src 'none'") expect(response.headers.get('X-Content-SHA256')).toBe('abcd') }) diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index f58d59c..c70eabd 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -14,6 +14,8 @@ const RATE_LIMITS = { write: { ip: 30, key: 120 }, } as const const MAX_RAW_FILE_BYTES = 200 * 1024 +const RAW_FILE_CONTENT_TYPE = 'text/plain; charset=utf-8' +const RAW_FILE_CSP = "sandbox; default-src 'none'" type SearchSkillEntry = { score: number @@ -375,15 +377,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) { if (!blob) return text('File missing in storage', 410, rate.headers) const textContent = await blob.text() - const headers = mergeHeaders(rate.headers, { - 'Content-Type': file.contentType - ? `${file.contentType}; charset=utf-8` - : 'text/plain; charset=utf-8', - 'Cache-Control': 'private, max-age=60', - ETag: file.sha256, - 'X-Content-SHA256': file.sha256, - 'X-Content-Size': String(file.size), - }) + const headers = buildRawFileHeaders(rate.headers, file) return new Response(textContent, { status: 200, headers }) } @@ -743,6 +737,18 @@ function mergeHeaders(base: HeadersInit, extra?: HeadersInit) { return { ...(base as Record), ...(extra as Record) } } +function buildRawFileHeaders(base: HeadersInit, file: { sha256: string; size: number }) { + return mergeHeaders(base, { + 'Content-Type': RAW_FILE_CONTENT_TYPE, + 'Cache-Control': 'private, max-age=60', + 'Content-Security-Policy': RAW_FILE_CSP, + 'X-Content-Type-Options': 'nosniff', + ETag: file.sha256, + 'X-Content-SHA256': file.sha256, + 'X-Content-Size': String(file.size), + }) +} + function getPathSegments(request: Request, prefix: string) { const pathname = new URL(request.url).pathname if (!pathname.startsWith(prefix)) return [] @@ -984,15 +990,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) { void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id }) - const headers = mergeHeaders(rate.headers, { - 'Content-Type': file.contentType - ? `${file.contentType}; charset=utf-8` - : 'text/plain; charset=utf-8', - 'Cache-Control': 'private, max-age=60', - ETag: file.sha256, - 'X-Content-SHA256': file.sha256, - 'X-Content-Size': String(file.size), - }) + const headers = buildRawFileHeaders(rate.headers, file) return new Response(textContent, { status: 200, headers }) } diff --git a/convex/lib/skills.test.ts b/convex/lib/skills.test.ts index f8254f4..4f49c44 100644 --- a/convex/lib/skills.test.ts +++ b/convex/lib/skills.test.ts @@ -150,6 +150,8 @@ describe('skills utils', () => { expect(isTextFile('note.txt', 'text/plain')).toBe(true) expect(isTextFile('data.any', 'application/json')).toBe(true) expect(isTextFile('data.json')).toBe(true) + expect(isTextFile('icon.svg')).toBe(false) + expect(isTextFile('icon.svg', 'image/svg+xml')).toBe(false) }) it('builds embedding text', () => { diff --git a/convex/lib/skills.ts b/convex/lib/skills.ts index 2dfe60c..f35ce48 100644 --- a/convex/lib/skills.ts +++ b/convex/lib/skills.ts @@ -125,6 +125,8 @@ export function isTextFile(path: string, contentType?: string | null) { if (!trimmed) return false const parts = trimmed.split('.') const extension = parts.length > 1 ? (parts.at(-1) ?? '') : '' + const normalizedContentType = contentType?.split(';', 1)[0]?.trim().toLowerCase() ?? '' + if (normalizedContentType === 'image/svg+xml' || extension === 'svg') return false if (contentType) { if (isTextContentType(contentType)) return true } diff --git a/server/middleware/securityHeaders.ts b/server/middleware/securityHeaders.ts new file mode 100644 index 0000000..74a662c --- /dev/null +++ b/server/middleware/securityHeaders.ts @@ -0,0 +1,28 @@ +import { defineEventHandler, getRequestHeader, setHeader } from 'h3' + +const CSP = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https: blob:", + "font-src 'self' data:", + "connect-src 'self' https: wss:", +].join('; ') + +export default defineEventHandler((event) => { + const accept = getRequestHeader(event, 'accept') ?? '' + const isHtml = accept.includes('text/html') + + if (isHtml && process.env.NODE_ENV === 'production') { + setHeader(event, 'Content-Security-Policy', CSP) + } + + setHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()') + setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin') + setHeader(event, 'X-Content-Type-Options', 'nosniff') + setHeader(event, 'X-Frame-Options', 'DENY') +})