From bb5b852cf38e1d823e9231cebc593b103c198b07 Mon Sep 17 00:00:00 2001 From: syj54p Date: Thu, 27 Aug 2026 17:45:49 +0800 Subject: [PATCH 1/3] fix(server): accept Windows backslash paths Fixes #2 --- src/server/server.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index 0bfbf98..b0bd646 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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 { @@ -43,7 +43,9 @@ async function serveClient(urlValue: string, response: import('node:http').Serve const requested = pathname === '/' ? 'index.html' : pathname.slice(1) const target = resolve(clientDir, requested) const root = resolve(clientDir) - if (!target.startsWith(`${root}/`) && target !== root) { + const relativePath = relative(root, target) + const insideRoot = relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith(sep)) + if (!insideRoot) { response.statusCode = 403 response.end('Forbidden') return From 785bad1d8a05581f10358b6eaedc76260d980974 Mon Sep 17 00:00:00 2001 From: skyseek Date: Thu, 27 Aug 2026 18:03:00 +0800 Subject: [PATCH 2/3] fix(server): harden cross-platform client path checks --- src/server/server.ts | 17 +++++++++++++---- tests/server.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index b0bd646..d6ba491 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -39,13 +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) - const relativePath = relative(root, target) - const insideRoot = relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith(sep)) - if (!insideRoot) { + if (!isWithinRoot(root, target)) { response.statusCode = 403 response.end('Forbidden') return @@ -67,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 = { '.css': 'text/css; charset=utf-8', diff --git a/tests/server.test.ts b/tests/server.test.ts index b2da8c0..c479474 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -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-')) @@ -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'), 'CreatPPT', '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(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 => { From 224b7a0f6956fcfa01d709feb79284deb350f0a3 Mon Sep 17 00:00:00 2001 From: Codex Agent 01KY481MZBVCXXBMT3X0MSWFZ6 <01KY481MZBVCXXBMT3X0MSWFZ6@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:01:32 +0800 Subject: [PATCH 3/3] test: isolate CLI subprocess color environment --- tests/cli.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f098ffa..dc72fe2 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -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', () => {