diff --git a/multimodal/tarko/agent-server-next/src/services/ShareService.ts b/multimodal/tarko/agent-server-next/src/services/ShareService.ts index ca585e3473..d60efa458a 100644 --- a/multimodal/tarko/agent-server-next/src/services/ShareService.ts +++ b/multimodal/tarko/agent-server-next/src/services/ShareService.ts @@ -31,6 +31,34 @@ export class ShareService { private server?: AgentServer, ) {} + private resolveWorkspaceImagePath(workspace: string, relativePath: string): string | null { + if (!relativePath || path.isAbsolute(relativePath)) { + return null; + } + + const normalized = path.normalize(relativePath); + if (normalized.split(path.sep).includes('..')) { + return null; + } + + const candidatePath = path.resolve(workspace, normalized); + if (!fs.existsSync(candidatePath)) { + return null; + } + + try { + const realWorkspace = fs.realpathSync(workspace); + const realCandidate = fs.realpathSync(candidatePath); + const relative = path.relative(realWorkspace, realCandidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return realCandidate; + } catch (error) { + return null; + } + } + /** * Share a session * @param sessionId Session ID to share @@ -81,21 +109,21 @@ export class ShareService { processedEvents = await this.processWorkspaceImages(keyFrameEvents, metadata.workspace); } - if(!this.appConfig.webui) { + if (!this.appConfig.webui) { throw new Error('Cannot found webui config'); } - if (this.appConfig.webui?.type === 'static' && !this.appConfig.webui?.staticPath ) { + if (this.appConfig.webui?.type === 'static' && !this.appConfig.webui?.staticPath) { throw new Error('Cannot found static path.'); } - if(this.appConfig.webui?.type === 'remote' && !this.appConfig.webui?.remoteUrl) { + if (this.appConfig.webui?.type === 'remote' && !this.appConfig.webui?.remoteUrl) { throw new Error('Cannot found remote url.'); } // Merge web UI config with agent constructor config const mergedWebUIConfig = mergeWebUIConfig(this.appConfig.webui, this.server); - + const builder = new AgentUIBuilder({ events: keyFrameEvents, sessionInfo: metadata, @@ -243,8 +271,11 @@ export class ShareService { } try { - // Resolve absolute path - const absolutePath = path.resolve(workspace, relativePath); + // Resolve absolute path with workspace boundary checks + const absolutePath = this.resolveWorkspaceImagePath(workspace, relativePath); + if (!absolutePath) { + continue; + } // Check if file exists and is an image if (fs.existsSync(absolutePath) && this.isImageFile(absolutePath)) { diff --git a/multimodal/tarko/agent-server-next/tests/share-path-traversal.test.ts b/multimodal/tarko/agent-server-next/tests/share-path-traversal.test.ts new file mode 100644 index 0000000000..351327f092 --- /dev/null +++ b/multimodal/tarko/agent-server-next/tests/share-path-traversal.test.ts @@ -0,0 +1,85 @@ +/** + * Target path: multimodal/tarko/agent-server-next/tests/share-path-traversal.test.ts + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ShareService } from '../src/services/ShareService'; + +const createWorkspace = () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tars-workspace-')); + const imagesDir = path.join(workspace, 'images'); + fs.mkdirSync(imagesDir, { recursive: true }); + const insideImage = path.join(imagesDir, 'inside.png'); + fs.writeFileSync(insideImage, 'inside'); + + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tars-outside-')); + const outsideImage = path.join(outsideDir, 'outside.png'); + fs.writeFileSync(outsideImage, 'outside'); + + const cleanup = () => { + fs.rmSync(workspace, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + }; + + return { workspace, insideImage, outsideImage, cleanup }; +}; + +const buildService = () => new ShareService({} as any, null as any, undefined); + +describe('ShareService.resolveWorkspaceImagePath', () => { + it('allows workspace relative paths', () => { + const { workspace, insideImage, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, 'images/inside.png'); + expect(resolved).toBe(insideImage); + } finally { + cleanup(); + } + }); + + it('rejects traversal paths', () => { + const { workspace, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, '../outside.png'); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); + + it('rejects absolute paths', () => { + const { workspace, insideImage, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, insideImage); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); + + it('rejects symlink escape when supported', () => { + const { workspace, outsideImage, cleanup } = createWorkspace(); + const linkPath = path.join(workspace, 'images', 'link.png'); + try { + try { + fs.symlinkSync(outsideImage, linkPath); + } catch { + return; + } + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, 'images/link.png'); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); +}); diff --git a/multimodal/tarko/agent-server/src/services/ShareService.ts b/multimodal/tarko/agent-server/src/services/ShareService.ts index 11bb86fec6..154fe81d58 100644 --- a/multimodal/tarko/agent-server/src/services/ShareService.ts +++ b/multimodal/tarko/agent-server/src/services/ShareService.ts @@ -31,6 +31,34 @@ export class ShareService { private server?: AgentServer, ) {} + private resolveWorkspaceImagePath(workspace: string, relativePath: string): string | null { + if (!relativePath || path.isAbsolute(relativePath)) { + return null; + } + + const normalized = path.normalize(relativePath); + if (normalized.split(path.sep).includes('..')) { + return null; + } + + const candidatePath = path.resolve(workspace, normalized); + if (!fs.existsSync(candidatePath)) { + return null; + } + + try { + const realWorkspace = fs.realpathSync(workspace); + const realCandidate = fs.realpathSync(candidatePath); + const relative = path.relative(realWorkspace, realCandidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return realCandidate; + } catch (error) { + return null; + } + } + /** * Share a session * @param sessionId Session ID to share @@ -239,8 +267,11 @@ export class ShareService { } try { - // Resolve absolute path - const absolutePath = path.resolve(workspace, relativePath); + // Resolve absolute path with workspace boundary checks + const absolutePath = this.resolveWorkspaceImagePath(workspace, relativePath); + if (!absolutePath) { + continue; + } // Check if file exists and is an image if (fs.existsSync(absolutePath) && this.isImageFile(absolutePath)) { diff --git a/multimodal/tarko/agent-server/tests/share-path-traversal.test.ts b/multimodal/tarko/agent-server/tests/share-path-traversal.test.ts new file mode 100644 index 0000000000..8cbab79ca7 --- /dev/null +++ b/multimodal/tarko/agent-server/tests/share-path-traversal.test.ts @@ -0,0 +1,85 @@ +/** + * Target path: multimodal/tarko/agent-server/tests/share-path-traversal.test.ts + */ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ShareService } from '../src/services/ShareService'; + +const createWorkspace = () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tars-workspace-')); + const imagesDir = path.join(workspace, 'images'); + fs.mkdirSync(imagesDir, { recursive: true }); + const insideImage = path.join(imagesDir, 'inside.png'); + fs.writeFileSync(insideImage, 'inside'); + + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tars-outside-')); + const outsideImage = path.join(outsideDir, 'outside.png'); + fs.writeFileSync(outsideImage, 'outside'); + + const cleanup = () => { + fs.rmSync(workspace, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + }; + + return { workspace, insideImage, outsideImage, cleanup }; +}; + +const buildService = () => new ShareService({} as any, null as any, undefined); + +describe('ShareService.resolveWorkspaceImagePath', () => { + it('allows workspace relative paths', () => { + const { workspace, insideImage, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, 'images/inside.png'); + expect(resolved).toBe(insideImage); + } finally { + cleanup(); + } + }); + + it('rejects traversal paths', () => { + const { workspace, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, '../outside.png'); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); + + it('rejects absolute paths', () => { + const { workspace, insideImage, cleanup } = createWorkspace(); + try { + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, insideImage); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); + + it('rejects symlink escape when supported', () => { + const { workspace, outsideImage, cleanup } = createWorkspace(); + const linkPath = path.join(workspace, 'images', 'link.png'); + try { + try { + fs.symlinkSync(outsideImage, linkPath); + } catch { + return; + } + const service = buildService(); + const resolvePath = (service as any).resolveWorkspaceImagePath.bind(service); + const resolved = resolvePath(workspace, 'images/link.png'); + expect(resolved).toBeNull(); + } finally { + cleanup(); + } + }); +});