Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ describe('httpApiV1 handlers', () => {
size: 5,
storageId: 'storage:1',
sha256: 'abcd',
contentType: 'text/plain',
contentType: 'image/svg+xml',
},
],
softDeletedAt: undefined,
Expand All @@ -327,14 +327,63 @@ 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 }),
new Request('https://example.com/api/v1/skills/demo/file?path=SKILL.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')
})

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')
})

Expand Down
34 changes: 16 additions & 18 deletions convex/httpApiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}

Expand Down Expand Up @@ -743,6 +737,18 @@ function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
}

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 []
Expand Down Expand Up @@ -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 })
}

Expand Down
2 changes: 2 additions & 0 deletions convex/lib/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions convex/lib/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions server/middleware/securityHeaders.ts
Original file line number Diff line number Diff line change
@@ -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')
})