Compare commits

...
Author SHA1 Message Date
Josh Palmer d7e495fc4a 🤖 fix: add security headers
- add Nitro middleware to set CSP + security headers for HTML responses
- normalize raw file CSP string literal for Biome

Tests: bun run lint:biome; bun run lint:oxlint
2026-01-28 21:42:33 +01:00
Josh Palmer 7c5b9d6bcc 🤖 fix: block svg skill files
- reject svg extensions/content types in isTextFile validation
- add tests covering svg rejection in text file detection

Tests: not run (bun not available)
2026-01-28 21:28:56 +01:00
Josh Palmer b34a05080c 🤖 fix: harden raw file responses
- force text/plain for raw file downloads and add nosniff/CSP headers
- add regression coverage for sanitized headers on skill/soul file endpoints

Tests: not run (bun not available)
2026-01-28 21:28:56 +01:00
5 changed files with 99 additions and 20 deletions
+51 -2
View File
@@ -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')
})
+16 -18
View File
@@ -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<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 []
@@ -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 })
}
+2
View File
@@ -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', () => {
+2
View File
@@ -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
}
+28
View File
@@ -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')
})