mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
feat(server): serve sandboxed workspace files via /preview-fs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0d5e7c40c
commit
36a3e4c018
@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { contentTypeForPath } from '../previewFs'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { contentTypeForPath, handlePreviewFs } from '../previewFs'
|
||||
|
||||
describe('contentTypeForPath', () => {
|
||||
it('maps common web asset extensions', () => {
|
||||
@ -14,3 +17,43 @@ describe('contentTypeForPath', () => {
|
||||
expect(contentTypeForPath('/x/file.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
function setupWorkspace() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'pfs-'))
|
||||
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
|
||||
mkdirSync(path.join(root, 'assets'))
|
||||
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
|
||||
return root
|
||||
}
|
||||
|
||||
describe('handlePreviewFs', () => {
|
||||
it('serves an in-workspace file with content-type', async () => {
|
||||
const root = setupWorkspace()
|
||||
const resolve = async (id: string) => (id === 's1' ? root : null)
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/index.html'), resolve)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
expect(await res.text()).toBe('<h1>ok</h1>')
|
||||
})
|
||||
|
||||
it('serves nested assets', async () => {
|
||||
const root = setupWorkspace()
|
||||
const resolve = async () => root
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/assets/a.css'), resolve)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8')
|
||||
})
|
||||
|
||||
it('blocks path traversal with 403', async () => {
|
||||
const root = setupWorkspace()
|
||||
const resolve = async () => root
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/../../etc/passwd'), resolve)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('404 when session has no workdir', async () => {
|
||||
const resolve = async () => null
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/sX/index.html'), resolve)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import {
|
||||
isSameOrInsidePathForPlatform,
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from '../services/windowsDrivePath.js'
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
html: 'text/html; charset=utf-8',
|
||||
@ -24,3 +29,63 @@ export function contentTypeForPath(filePath: string): string {
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase()
|
||||
return CONTENT_TYPES[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export type ResolveWorkDir = (sessionId: string) => Promise<string | null>
|
||||
|
||||
const PREFIX = '/preview-fs/'
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Serve a single file from a session's sandboxed workspace directory.
|
||||
*
|
||||
* URL shape: `/preview-fs/<sessionId>/<relPath>` where `<relPath>` may itself
|
||||
* contain `/` separators. The WHATWG URL parser collapses `..` segments before
|
||||
* this handler runs, so a traversal attempt such as
|
||||
* `/preview-fs/s1/../../etc/passwd` arrives with its pathname normalized to
|
||||
* `/etc/passwd` — i.e. the `/preview-fs/` prefix is gone. We treat any request
|
||||
* that lost the prefix as a sandbox escape and return 403. Requests that keep
|
||||
* the prefix are additionally re-validated against the resolved work-dir root.
|
||||
*/
|
||||
export async function handlePreviewFs(
|
||||
url: URL,
|
||||
resolveWorkDir: ResolveWorkDir,
|
||||
): Promise<Response> {
|
||||
if (!url.pathname.startsWith(PREFIX)) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const rest = url.pathname.slice(PREFIX.length)
|
||||
const slash = rest.indexOf('/')
|
||||
if (slash <= 0) return new Response('bad request', { status: 400 })
|
||||
|
||||
const sessionId = decodeURIComponent(rest.slice(0, slash))
|
||||
const relRaw = decodeURIComponent(rest.slice(slash + 1))
|
||||
|
||||
const workDir = await resolveWorkDir(sessionId)
|
||||
if (!workDir) return new Response('no workdir', { status: 404 })
|
||||
|
||||
const root = path.resolve(normalizeDriveRootPathForPlatform(workDir))
|
||||
const target = path.resolve(root, relRaw)
|
||||
if (!isSameOrInsidePathForPlatform(target, root)) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.statSync(target)
|
||||
} catch {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
if (!stat.isFile()) return new Response('not a file', { status: 404 })
|
||||
if (stat.size > MAX_FILE_BYTES) return new Response('too large', { status: 413 })
|
||||
|
||||
const data = fs.readFileSync(target)
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentTypeForPath(target),
|
||||
'Content-Length': String(stat.size),
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -15,6 +15,9 @@ import { handleProxyRequest } from './proxy/handler.js'
|
||||
import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
|
||||
import { handlePreviewFs } from './api/previewFs.js'
|
||||
import { sessionService } from './services/sessionService.js'
|
||||
import { conversationService } from './services/conversationService.js'
|
||||
import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
@ -275,6 +278,32 @@ export function startServer(port = PORT, host = HOST) {
|
||||
return handleHahaOpenAIOAuthCallback(url)
|
||||
}
|
||||
|
||||
// Preview filesystem — serve sandboxed workspace files for a session.
|
||||
if (url.pathname.startsWith('/preview-fs/')) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
const authError = await requireH5Token(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
} else if (forceAuth) {
|
||||
const authError = await requireAuth(req)
|
||||
if (authError) {
|
||||
return withCors(authError, cors)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await handlePreviewFs(url, async (sessionId) =>
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
null,
|
||||
)
|
||||
return withCors(response, cors)
|
||||
}
|
||||
|
||||
// REST API
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (cors.rejected) {
|
||||
@ -396,7 +425,6 @@ export function startServer(port = PORT, host = HOST) {
|
||||
}
|
||||
|
||||
// ─── Graceful shutdown: kill all CLI subprocesses on exit ────────────────────
|
||||
import { conversationService } from './services/conversationService.js'
|
||||
|
||||
function cleanupAllSessions() {
|
||||
const active = conversationService.getActiveSessions()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user