mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e495fc4a | ||
|
|
7c5b9d6bcc | ||
|
|
b34a05080c |
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user