Skip to content
Open
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
43 changes: 37 additions & 6 deletions multimodal/tarko/agent-server-next/src/services/ShareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
35 changes: 33 additions & 2 deletions multimodal/tarko/agent-server/src/services/ShareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
85 changes: 85 additions & 0 deletions multimodal/tarko/agent-server/tests/share-path-traversal.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});