Skip to content
Merged
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
17 changes: 14 additions & 3 deletions src/server/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createReadStream } from 'node:fs'
import { createServer, type Server } from 'node:http'
import { stat } from 'node:fs/promises'
import { extname, resolve } from 'node:path'
import { extname, relative, resolve, sep } from 'node:path'
import { handleProjectRequest } from './handlers'

export interface ServerOptions {
Expand Down Expand Up @@ -39,11 +39,13 @@ export async function startCreatPptServer(options: ServerOptions): Promise<{ ser
}

async function serveClient(urlValue: string, response: import('node:http').ServerResponse, clientDir: string) {
const pathname = decodeURIComponent(new URL(urlValue, 'http://127.0.0.1').pathname)
// 先解码原始路径,避免 URL 解析器提前折叠编码后的 '..' 段而绕过根目录检查。
const rawPathname = urlValue.split('?', 1)[0] || '/'
const pathname = decodeURIComponent(rawPathname)
const requested = pathname === '/' ? 'index.html' : pathname.slice(1)
const target = resolve(clientDir, requested)
const root = resolve(clientDir)
if (!target.startsWith(`${root}/`) && target !== root) {
if (!isWithinRoot(root, target)) {
response.statusCode = 403
response.end('Forbidden')
return
Expand All @@ -65,6 +67,15 @@ async function serveClient(urlValue: string, response: import('node:http').Serve
createReadStream(finalTarget).pipe(response)
}

/**
* 判断解析后的请求路径是否仍位于静态资源根目录内,兼容各操作系统的路径分隔符。
* 使用 relative 而不是字符串前缀,避免 Windows 反斜杠导致合法路径被误判为越界。
*/
export function isWithinRoot(root: string, target: string): boolean {
const relativePath = relative(root, target)
return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith(sep))
}

function clientMimeType(path: string): string {
const types: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
Expand Down
4 changes: 3 additions & 1 deletion tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const tsconfig = resolve('tsconfig.json')
const cliSource = resolve('src/cli.ts')

function runTsx(args: string[], input?: string, cwd = resolve('.')) {
return spawnSync(tsxCommand, [tsxCli, '--tsconfig', tsconfig, ...args], { cwd, encoding: 'utf8', input })
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }
delete env.FORCE_COLOR
return spawnSync(tsxCommand, [tsxCli, '--tsconfig', tsconfig, ...args], { cwd, encoding: 'utf8', input, env })
}

describe('Agent CLI', () => {
Expand Down
32 changes: 32 additions & 0 deletions tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createStarterDeck } from '@/demo/starter'
import { startCreatPptServer, isWithinRoot } from '@/server/server'
import { handleProjectRequest } from '@/server/handlers'

describe('static client path boundaries', () => {
it('allows the root and descendants while rejecting traversal', () => {
const root = resolve('/tmp/creatppt-client')
expect(isWithinRoot(root, resolve(root, 'index.html'))).toBe(true)
expect(isWithinRoot(root, resolve(root, 'assets/app.js'))).toBe(true)
expect(isWithinRoot(root, resolve(root, '..', 'outside.html'))).toBe(false)
expect(isWithinRoot(root, resolve('/tmp/creatppt-client-elsewhere'))).toBe(false)
})
})

describe('local project API', () => {
it('validates saves and blocks asset traversal', async () => {
const root = await mkdtemp(resolve(tmpdir(), 'creatppt-server-'))
Expand Down Expand Up @@ -44,6 +55,27 @@ describe('local project API', () => {
})
})

describe('static client serving', () => {
it('serves the macOS root route, assets, and SPA fallback', async () => {
const root = await mkdtemp(resolve(tmpdir(), 'creatppt-client-'))
await mkdir(resolve(root, 'assets'), { recursive: true })
await writeFile(resolve(root, 'index.html'), '<!doctype html><title>CreatPPT</title>', 'utf8')
await writeFile(resolve(root, 'assets', 'app.js'), 'console.log(1)', 'utf8')
const { server, url } = await startCreatPptServer({ projectDir: root, clientDir: root, port: 0 })
const port = Number(new URL(url).port)

try {
expect((await requestText(port, '/')).status).toBe(200)
expect((await requestText(port, '/assets/app.js')).status).toBe(200)
expect((await requestText(port, '/editor')).status).toBe(200)
expect((await requestText(port, '/%2e%2e/%2e%2e/etc/passwd')).status).toBe(403)
}
finally {
await new Promise<void>(resolveClose => server.close(() => resolveClose()))
}
})
})

async function requestText(port: number, path: string, options: { method?: string; body?: string } = {}) {
return new Promise<{ status: number; body: string }>((resolveResponse, reject) => {
const request = httpRequest({ hostname: '127.0.0.1', port, path, method: options.method ?? 'GET', headers: options.body ? { 'Content-Type': 'application/json' } : undefined }, response => {
Expand Down