@studiomeyer/mcp-video 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -0
- package/dist/handlers/dispatch.test.d.ts +1 -0
- package/dist/handlers/dispatch.test.js +49 -0
- package/dist/handlers/dispatch.test.js.map +1 -0
- package/dist/handlers/index.js +5 -0
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/smart-screenshot.js +3 -3
- package/dist/handlers/smart-screenshot.js.map +1 -1
- package/dist/handlers/tts.js +2 -2
- package/dist/handlers/tts.js.map +1 -1
- package/dist/handlers/video.js +4 -4
- package/dist/handlers/video.js.map +1 -1
- package/dist/lib/ffmpeg-run.test.js +44 -1
- package/dist/lib/ffmpeg-run.test.js.map +1 -1
- package/dist/lib/sanitize-tool-paths.d.ts +35 -0
- package/dist/lib/sanitize-tool-paths.js +130 -0
- package/dist/lib/sanitize-tool-paths.js.map +1 -0
- package/dist/lib/sanitize-tool-paths.test.d.ts +1 -0
- package/dist/lib/sanitize-tool-paths.test.js +134 -0
- package/dist/lib/sanitize-tool-paths.test.js.map +1 -0
- package/dist/lib/url-guard-resolve.test.d.ts +12 -0
- package/dist/lib/url-guard-resolve.test.js +106 -0
- package/dist/lib/url-guard-resolve.test.js.map +1 -0
- package/package.json +3 -3
- package/src/handlers/dispatch.test.ts +55 -0
- package/src/handlers/index.ts +5 -0
- package/src/handlers/smart-screenshot.ts +3 -3
- package/src/handlers/tts.ts +2 -2
- package/src/handlers/video.ts +4 -4
- package/src/lib/ffmpeg-run.test.ts +50 -1
- package/src/lib/sanitize-tool-paths.test.ts +185 -0
- package/src/lib/sanitize-tool-paths.ts +154 -0
- package/src/lib/url-guard-resolve.test.ts +116 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeToolPaths } from './sanitize-tool-paths.js';
|
|
3
|
+
/**
|
|
4
|
+
* Each block pairs an "attack blocked" case with a "benign allowed" case, per
|
|
5
|
+
* the field shape the tool uses. `sanitizeToolPaths` throws on the first
|
|
6
|
+
* offending path and is a silent no-op otherwise.
|
|
7
|
+
*/
|
|
8
|
+
describe('sanitize-tool-paths — scalar path fields', () => {
|
|
9
|
+
it('blocks a leading-dash outputPath (flag injection)', () => {
|
|
10
|
+
expect(() => sanitizeToolPaths('crop_video', { inputPath: 'in.mp4', outputPath: '-y' })).toThrow(/outputPath.*flag/);
|
|
11
|
+
});
|
|
12
|
+
it('blocks a leading-dash inputPath', () => {
|
|
13
|
+
expect(() => sanitizeToolPaths('adjust_video_speed', { inputPath: '-i', outputPath: 'out.mp4' })).toThrow(/inputPath.*flag/);
|
|
14
|
+
});
|
|
15
|
+
it('blocks a NUL byte in a path', () => {
|
|
16
|
+
expect(() => sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4\0-i', outputPath: 'b.mp3' })).toThrow(/null byte/);
|
|
17
|
+
});
|
|
18
|
+
it('allows ordinary input/output paths', () => {
|
|
19
|
+
expect(() => sanitizeToolPaths('apply_color_grade', {
|
|
20
|
+
inputPath: '/home/user/clip.mp4',
|
|
21
|
+
outputPath: 'relative/out.mp4',
|
|
22
|
+
})).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
it('skips omitted optional paths (unchanged benign behaviour)', () => {
|
|
25
|
+
// outputPath is optional for many tools — undefined must not throw.
|
|
26
|
+
expect(() => sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4' })).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
it('validates every scalar field declared for a tool', () => {
|
|
29
|
+
expect(() => sanitizeToolPaths('burn_subtitles', {
|
|
30
|
+
inputPath: 'in.mp4',
|
|
31
|
+
outputPath: 'out.mp4',
|
|
32
|
+
subtitlePath: '-attach',
|
|
33
|
+
})).toThrow(/subtitlePath.*flag/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('sanitize-tool-paths — string-array path fields (sync_to_beat clips)', () => {
|
|
37
|
+
it('blocks a malicious entry inside the clips array', () => {
|
|
38
|
+
expect(() => sanitizeToolPaths('sync_to_beat', {
|
|
39
|
+
audioPath: 'beat.mp3',
|
|
40
|
+
outputPath: 'out.mp4',
|
|
41
|
+
clips: ['ok1.mp4', '-f', 'ok2.mp4'],
|
|
42
|
+
})).toThrow(/clips\[1\].*flag/);
|
|
43
|
+
});
|
|
44
|
+
it('allows a clean clips array', () => {
|
|
45
|
+
expect(() => sanitizeToolPaths('sync_to_beat', {
|
|
46
|
+
audioPath: 'beat.mp3',
|
|
47
|
+
outputPath: 'out.mp4',
|
|
48
|
+
clips: ['a.mp4', 'b.mp4'],
|
|
49
|
+
})).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('sanitize-tool-paths — object-array path fields (concat clips / mixer tracks)', () => {
|
|
53
|
+
it('blocks a malicious clip path in concatenate_videos', () => {
|
|
54
|
+
expect(() => sanitizeToolPaths('concatenate_videos', {
|
|
55
|
+
outputPath: 'out.mp4',
|
|
56
|
+
clips: [{ path: 'good.mp4' }, { path: '-i' }],
|
|
57
|
+
})).toThrow(/clips\[1\]\.path.*flag/);
|
|
58
|
+
});
|
|
59
|
+
it('blocks a malicious track path in mix_audio_tracks', () => {
|
|
60
|
+
expect(() => sanitizeToolPaths('mix_audio_tracks', {
|
|
61
|
+
outputPath: 'out.aac',
|
|
62
|
+
tracks: [{ path: 'voice.mp3' }, { path: '-protocol_whitelist' }],
|
|
63
|
+
})).toThrow(/tracks\[1\]\.path.*flag/);
|
|
64
|
+
});
|
|
65
|
+
it('allows clean object-array paths', () => {
|
|
66
|
+
expect(() => sanitizeToolPaths('concatenate_videos', {
|
|
67
|
+
outputPath: 'out.mp4',
|
|
68
|
+
clips: [{ path: 'a.mp4', trimStart: 1 }, { path: 'b.mp4' }],
|
|
69
|
+
})).not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('sanitize-tool-paths — record path fields (render_template clips)', () => {
|
|
73
|
+
it('blocks a malicious value in the clips record', () => {
|
|
74
|
+
expect(() => sanitizeToolPaths('render_template', {
|
|
75
|
+
templateId: 'social-reel',
|
|
76
|
+
outputPath: 'out.mp4',
|
|
77
|
+
clips: { intro: 'good.mp4', main: '-y' },
|
|
78
|
+
})).toThrow(/clips\.main.*flag/);
|
|
79
|
+
});
|
|
80
|
+
it('allows a clean clips record', () => {
|
|
81
|
+
expect(() => sanitizeToolPaths('render_template', {
|
|
82
|
+
templateId: 'social-reel',
|
|
83
|
+
outputPath: 'out.mp4',
|
|
84
|
+
clips: { intro: 'a.mp4', main: 'b.mp4' },
|
|
85
|
+
})).not.toThrow();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('sanitize-tool-paths — chroma-key background (path OR hex colour)', () => {
|
|
89
|
+
it('blocks a leading-dash background path', () => {
|
|
90
|
+
expect(() => sanitizeToolPaths('apply_chroma_key', {
|
|
91
|
+
inputPath: 'in.mp4',
|
|
92
|
+
outputPath: 'out.mp4',
|
|
93
|
+
background: '-f',
|
|
94
|
+
})).toThrow(/background.*flag/);
|
|
95
|
+
});
|
|
96
|
+
it('allows a bare 6-digit hex colour as background', () => {
|
|
97
|
+
for (const hex of ['00FF00', '#0000FF', '0x000000']) {
|
|
98
|
+
expect(() => sanitizeToolPaths('apply_chroma_key', {
|
|
99
|
+
inputPath: 'in.mp4',
|
|
100
|
+
outputPath: 'out.mp4',
|
|
101
|
+
background: hex,
|
|
102
|
+
})).not.toThrow();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
it('allows a real background file path', () => {
|
|
106
|
+
expect(() => sanitizeToolPaths('apply_chroma_key', {
|
|
107
|
+
inputPath: 'in.mp4',
|
|
108
|
+
outputPath: 'out.mp4',
|
|
109
|
+
background: 'backgrounds/beach.png',
|
|
110
|
+
})).not.toThrow();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('sanitize-tool-paths — registry boundaries', () => {
|
|
114
|
+
it('is a no-op for tools without path fields', () => {
|
|
115
|
+
expect(() => sanitizeToolPaths('list_voices', {})).not.toThrow();
|
|
116
|
+
expect(() => sanitizeToolPaths('list_video_templates', { category: 'promo' })).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
it('is a no-op for unknown tools', () => {
|
|
119
|
+
expect(() => sanitizeToolPaths('nonexistent_tool', { outputPath: '-y' })).not.toThrow();
|
|
120
|
+
});
|
|
121
|
+
it('tolerates non-object args', () => {
|
|
122
|
+
expect(() => sanitizeToolPaths('crop_video', null)).not.toThrow();
|
|
123
|
+
expect(() => sanitizeToolPaths('crop_video', undefined)).not.toThrow();
|
|
124
|
+
expect(() => sanitizeToolPaths('crop_video', 'not-an-object')).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
it('ignores malformed array entries instead of crashing', () => {
|
|
127
|
+
// objectArray entries that are not objects-with-path are skipped.
|
|
128
|
+
expect(() => sanitizeToolPaths('concatenate_videos', {
|
|
129
|
+
outputPath: 'out.mp4',
|
|
130
|
+
clips: [null, 'string-entry', { noPath: true }],
|
|
131
|
+
})).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
//# sourceMappingURL=sanitize-tool-paths.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-tool-paths.test.js","sourceRoot":"","sources":["../../src/lib/sanitize-tool-paths.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the async, DNS-resolving URL guard `resolveAndGuardUrl`.
|
|
3
|
+
*
|
|
4
|
+
* This is the strong SSRF check now used by every URL-taking handler
|
|
5
|
+
* (record_website_*, create_narrated_video, screenshot_element,
|
|
6
|
+
* detect_page_features). Where the sync `guardUrl` only inspects the literal
|
|
7
|
+
* host, this one resolves the hostname and rejects when it points at a
|
|
8
|
+
* loopback / RFC1918 / cloud-metadata address — the classic DNS-rebinding
|
|
9
|
+
* bypass. `node:dns/promises.lookup` is mocked so the suite is hermetic and
|
|
10
|
+
* never hits the network.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the async, DNS-resolving URL guard `resolveAndGuardUrl`.
|
|
3
|
+
*
|
|
4
|
+
* This is the strong SSRF check now used by every URL-taking handler
|
|
5
|
+
* (record_website_*, create_narrated_video, screenshot_element,
|
|
6
|
+
* detect_page_features). Where the sync `guardUrl` only inspects the literal
|
|
7
|
+
* host, this one resolves the hostname and rejects when it points at a
|
|
8
|
+
* loopback / RFC1918 / cloud-metadata address — the classic DNS-rebinding
|
|
9
|
+
* bypass. `node:dns/promises.lookup` is mocked so the suite is hermetic and
|
|
10
|
+
* never hits the network.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
13
|
+
const lookupMock = vi.hoisted(() => vi.fn());
|
|
14
|
+
vi.mock('node:dns/promises', () => ({ lookup: lookupMock }));
|
|
15
|
+
// Import AFTER the mock so the binding is the mock.
|
|
16
|
+
import { resolveAndGuardUrl } from './url-guard.js';
|
|
17
|
+
const ORIGINAL_ALLOW_INTERNAL = process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
lookupMock.mockReset();
|
|
20
|
+
delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (ORIGINAL_ALLOW_INTERNAL === undefined)
|
|
24
|
+
delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
25
|
+
else
|
|
26
|
+
process.env.MCP_VIDEO_ALLOW_INTERNAL = ORIGINAL_ALLOW_INTERNAL;
|
|
27
|
+
});
|
|
28
|
+
describe('resolveAndGuardUrl — DNS-rebinding defense', () => {
|
|
29
|
+
it('blocks a public hostname that resolves to a loopback address', async () => {
|
|
30
|
+
lookupMock.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
|
|
31
|
+
const res = await resolveAndGuardUrl('https://rebind.evil.example/');
|
|
32
|
+
expect(res.ok).toBe(false);
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
expect(res.reason).toMatch(/resolves to private address 127\.0\.0\.1/);
|
|
35
|
+
});
|
|
36
|
+
it('blocks a hostname resolving to the cloud-metadata IP (169.254.169.254)', async () => {
|
|
37
|
+
lookupMock.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }]);
|
|
38
|
+
const res = await resolveAndGuardUrl('https://metadata.evil.example/');
|
|
39
|
+
expect(res.ok).toBe(false);
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
expect(res.reason).toMatch(/169\.254\.169\.254/);
|
|
42
|
+
});
|
|
43
|
+
it('blocks when ANY resolved address is internal (multi-A record)', async () => {
|
|
44
|
+
lookupMock.mockResolvedValueOnce([
|
|
45
|
+
{ address: '93.184.216.34', family: 4 }, // public
|
|
46
|
+
{ address: '10.0.0.5', family: 4 }, // private — must trip the guard
|
|
47
|
+
]);
|
|
48
|
+
const res = await resolveAndGuardUrl('https://mixed.evil.example/');
|
|
49
|
+
expect(res.ok).toBe(false);
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
expect(res.reason).toMatch(/10\.0\.0\.5/);
|
|
52
|
+
});
|
|
53
|
+
it('blocks an internal IPv6 resolution (unique-local fc00::/7)', async () => {
|
|
54
|
+
lookupMock.mockResolvedValueOnce([{ address: 'fc00::1', family: 6 }]);
|
|
55
|
+
const res = await resolveAndGuardUrl('https://v6.evil.example/');
|
|
56
|
+
expect(res.ok).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('allows a hostname that resolves to a public address', async () => {
|
|
59
|
+
lookupMock.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
|
|
60
|
+
const res = await resolveAndGuardUrl('https://example.com/page');
|
|
61
|
+
expect(res.ok).toBe(true);
|
|
62
|
+
if (res.ok)
|
|
63
|
+
expect(res.url).toContain('example.com');
|
|
64
|
+
});
|
|
65
|
+
it('returns a clean failure when DNS resolution itself fails', async () => {
|
|
66
|
+
lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
|
67
|
+
const res = await resolveAndGuardUrl('https://nxdomain.invalid/');
|
|
68
|
+
expect(res.ok).toBe(false);
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
expect(res.reason).toMatch(/DNS lookup failed/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('resolveAndGuardUrl — short-circuits (no DNS call)', () => {
|
|
74
|
+
it('rejects a non-http scheme before any lookup', async () => {
|
|
75
|
+
const res = await resolveAndGuardUrl('file:///etc/passwd');
|
|
76
|
+
expect(res.ok).toBe(false);
|
|
77
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
it('rejects a literal internal IP via the sync layer (no lookup needed)', async () => {
|
|
80
|
+
const res = await resolveAndGuardUrl('http://127.0.0.1/');
|
|
81
|
+
expect(res.ok).toBe(false);
|
|
82
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
it('does not resolve a literal public IPv4 (already host-checked)', async () => {
|
|
85
|
+
const res = await resolveAndGuardUrl('https://93.184.216.34/');
|
|
86
|
+
expect(res.ok).toBe(true);
|
|
87
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
it('does not resolve a literal IPv6 host', async () => {
|
|
90
|
+
const res = await resolveAndGuardUrl('https://[2606:2800:220:1:248:1893:25c8:1946]/');
|
|
91
|
+
expect(res.ok).toBe(true);
|
|
92
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
it('skips DNS entirely when MCP_VIDEO_ALLOW_INTERNAL=1', async () => {
|
|
95
|
+
process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
|
|
96
|
+
const res = await resolveAndGuardUrl('https://internal.dev.example/');
|
|
97
|
+
expect(res.ok).toBe(true);
|
|
98
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
it('rejects a non-string input without resolving', async () => {
|
|
101
|
+
const res = await resolveAndGuardUrl(undefined);
|
|
102
|
+
expect(res.ok).toBe(false);
|
|
103
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
//# sourceMappingURL=url-guard-resolve.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-guard-resolve.test.js","sourceRoot":"","sources":["../../src/lib/url-guard-resolve.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studiomeyer/mcp-video",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"mcpName": "io.studiomeyer/video",
|
|
5
|
-
"description": "Cinema-grade video production MCP server
|
|
5
|
+
"description": "Cinema-grade video production MCP server — 8 tools for recording, editing, effects, captions, TTS, and smart screenshots. Zero-config, works with any MCP client.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/server.js",
|
|
8
8
|
"bin": {
|
|
@@ -54,4 +54,4 @@
|
|
|
54
54
|
"typescript": "^5.3.0",
|
|
55
55
|
"vitest": "^1.6.0"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Logger writes to stderr; silence it so test output stays clean.
|
|
4
|
+
vi.mock('../lib/logger.js', () => ({
|
|
5
|
+
logger: { info: vi.fn(), logError: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { handleToolCall } from './index.js';
|
|
9
|
+
|
|
10
|
+
function parse(res: { content: Array<{ text: string }> }): Record<string, unknown> {
|
|
11
|
+
return JSON.parse(res.content[0].text) as Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('handleToolCall — path-injection choke point', () => {
|
|
15
|
+
it('blocks a flag-injection outputPath before reaching the engine', async () => {
|
|
16
|
+
const res = await handleToolCall('crop_video', {
|
|
17
|
+
inputPath: 'in.mp4',
|
|
18
|
+
outputPath: '-y',
|
|
19
|
+
width: 100,
|
|
20
|
+
height: 100,
|
|
21
|
+
});
|
|
22
|
+
expect(res.isError).toBe(true);
|
|
23
|
+
const body = parse(res);
|
|
24
|
+
expect(String(body.error)).toMatch(/outputPath.*flag/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('blocks a flag-injection clip path in concatenate_videos', async () => {
|
|
28
|
+
const res = await handleToolCall('concatenate_videos', {
|
|
29
|
+
outputPath: 'out.mp4',
|
|
30
|
+
clips: [{ path: 'a.mp4' }, { path: '-protocol_whitelist' }],
|
|
31
|
+
});
|
|
32
|
+
expect(res.isError).toBe(true);
|
|
33
|
+
expect(String(parse(res).error)).toMatch(/clips\[1\]\.path.*flag/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns a structured error (never throws) for an unknown tool', async () => {
|
|
37
|
+
const res = await handleToolCall('does_not_exist', { outputPath: '-y' });
|
|
38
|
+
expect(res.isError).toBe(true);
|
|
39
|
+
expect(res.content[0].text).toContain('Unknown tool');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('lets benign args pass the choke point (failure, if any, is not flag-injection)', async () => {
|
|
43
|
+
// No ffmpeg binary in this environment, so the engine will fail at
|
|
44
|
+
// assertExists/spawn — but crucially NOT with a flag-injection error,
|
|
45
|
+
// which proves sanitizeToolPaths did not false-positive on a valid path.
|
|
46
|
+
const res = await handleToolCall('crop_video', {
|
|
47
|
+
inputPath: 'definitely-missing-input.mp4',
|
|
48
|
+
outputPath: 'out.mp4',
|
|
49
|
+
width: 100,
|
|
50
|
+
height: 100,
|
|
51
|
+
});
|
|
52
|
+
expect(res.isError).toBe(true);
|
|
53
|
+
expect(String(parse(res).error)).not.toMatch(/flag/);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/handlers/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ToolHandler, type ToolResponse } from '../lib/types.js';
|
|
2
2
|
import { logger } from '../lib/logger.js';
|
|
3
|
+
import { sanitizeToolPaths } from '../lib/sanitize-tool-paths.js';
|
|
3
4
|
import { videoHandlers } from './video.js';
|
|
4
5
|
import { postProductionHandlers } from './post-production.js';
|
|
5
6
|
import { ttsHandlers } from './tts.js';
|
|
@@ -22,6 +23,10 @@ export async function handleToolCall(name: string, args: unknown): Promise<ToolR
|
|
|
22
23
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
23
24
|
}
|
|
24
25
|
try {
|
|
26
|
+
// Choke point: reject flag-injection / NUL-byte path arguments before any
|
|
27
|
+
// handler can forward them to ffmpeg/ffprobe. No-op for tools that take no
|
|
28
|
+
// path args. See lib/sanitize-tool-paths.ts for the threat model.
|
|
29
|
+
sanitizeToolPaths(name, args);
|
|
25
30
|
return await handler(args);
|
|
26
31
|
} catch (error) {
|
|
27
32
|
logger.logError('Tool execution failed', error, { tool: name });
|
|
@@ -6,7 +6,7 @@ import { jsonResponse, type ToolHandler } from '../lib/types.js';
|
|
|
6
6
|
import { logger } from '../lib/logger.js';
|
|
7
7
|
import { smartScreenshot } from '../tools/engine/smart-screenshot.js';
|
|
8
8
|
import type { SmartScreenshotConfig, SmartTarget } from '../tools/engine/smart-screenshot.js';
|
|
9
|
-
import {
|
|
9
|
+
import { resolveAndGuardUrl } from '../lib/url-guard.js';
|
|
10
10
|
|
|
11
11
|
export const smartScreenshotHandlers: Record<string, ToolHandler> = {
|
|
12
12
|
/**
|
|
@@ -14,7 +14,7 @@ export const smartScreenshotHandlers: Record<string, ToolHandler> = {
|
|
|
14
14
|
*/
|
|
15
15
|
screenshot_element: async (args) => {
|
|
16
16
|
try {
|
|
17
|
-
const guard =
|
|
17
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
18
18
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
19
19
|
const config: SmartScreenshotConfig = {
|
|
20
20
|
url: guard.url,
|
|
@@ -40,7 +40,7 @@ export const smartScreenshotHandlers: Record<string, ToolHandler> = {
|
|
|
40
40
|
*/
|
|
41
41
|
detect_page_features: async (args) => {
|
|
42
42
|
try {
|
|
43
|
-
const guard =
|
|
43
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
44
44
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
45
45
|
const config: SmartScreenshotConfig = {
|
|
46
46
|
url: guard.url,
|
package/src/handlers/tts.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { jsonResponse, type ToolHandler } from '../lib/types.js';
|
|
6
6
|
import { logger } from '../lib/logger.js';
|
|
7
|
-
import {
|
|
7
|
+
import { resolveAndGuardUrl } from '../lib/url-guard.js';
|
|
8
8
|
import {
|
|
9
9
|
generateSpeech,
|
|
10
10
|
listElevenLabsVoices,
|
|
@@ -67,7 +67,7 @@ export const ttsHandlers: Record<string, ToolHandler> = {
|
|
|
67
67
|
|
|
68
68
|
create_narrated_video: async (args) => {
|
|
69
69
|
try {
|
|
70
|
-
const guard =
|
|
70
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
71
71
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
72
72
|
|
|
73
73
|
const segments: NarrationSegment[] = (args.segments as Array<{
|
package/src/handlers/video.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { logger } from '../lib/logger.js';
|
|
|
7
7
|
import { recordWebsite } from '../tools/index.js';
|
|
8
8
|
import type { RecordingConfig, Scene, ViewportPreset } from '../tools/index.js';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
-
import {
|
|
10
|
+
import { resolveAndGuardUrl } from '../lib/url-guard.js';
|
|
11
11
|
|
|
12
12
|
const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ export const videoHandlers: Record<string, ToolHandler> = {
|
|
|
17
17
|
*/
|
|
18
18
|
record_website_video: async (args) => {
|
|
19
19
|
try {
|
|
20
|
-
const guard =
|
|
20
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
21
21
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
22
22
|
const config: RecordingConfig = {
|
|
23
23
|
url: guard.url,
|
|
@@ -49,7 +49,7 @@ export const videoHandlers: Record<string, ToolHandler> = {
|
|
|
49
49
|
*/
|
|
50
50
|
record_website_scroll: async (args) => {
|
|
51
51
|
try {
|
|
52
|
-
const guard =
|
|
52
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
53
53
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
54
54
|
const duration = args.duration ?? 12;
|
|
55
55
|
const easing = args.easing ?? 'showcase';
|
|
@@ -82,7 +82,7 @@ export const videoHandlers: Record<string, ToolHandler> = {
|
|
|
82
82
|
*/
|
|
83
83
|
record_multi_device: async (args) => {
|
|
84
84
|
try {
|
|
85
|
-
const guard =
|
|
85
|
+
const guard = await resolveAndGuardUrl(args.url);
|
|
86
86
|
if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
|
|
87
87
|
const devices: ViewportPreset[] = args.devices ?? ['desktop', 'tablet', 'mobile'];
|
|
88
88
|
const duration = args.duration ?? 10;
|
|
@@ -6,7 +6,7 @@ const execFileMock = vi.hoisted(() => vi.fn());
|
|
|
6
6
|
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
|
|
7
7
|
|
|
8
8
|
// IMPORTANT: import AFTER vi.mock so the mock is bound.
|
|
9
|
-
import { runFfmpeg } from './ffmpeg-run.js';
|
|
9
|
+
import { runFfmpeg, runFfprobe } from './ffmpeg-run.js';
|
|
10
10
|
|
|
11
11
|
describe('ffmpeg-run — runFfmpeg', () => {
|
|
12
12
|
beforeEach(() => {
|
|
@@ -74,3 +74,52 @@ describe('ffmpeg-run — runFfmpeg', () => {
|
|
|
74
74
|
await expect(runFfmpeg([], { label: 'lut-preset' })).rejects.toThrow(/lut-preset/);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
describe('ffmpeg-run — runFfprobe', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
execFileMock.mockReset();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('prepends -protocol_whitelist on every probe (closes the "just probe first" SSRF bypass)', async () => {
|
|
84
|
+
execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
|
|
85
|
+
cb(null, '12.34', '');
|
|
86
|
+
});
|
|
87
|
+
await runFfprobe(['-show_entries', 'format=duration', 'in.mp4']);
|
|
88
|
+
const args = execFileMock.mock.calls[0][1] as string[];
|
|
89
|
+
expect(args[0]).toBe('-protocol_whitelist');
|
|
90
|
+
expect(args[1]).toBe('file,pipe,crypto,cache,fd');
|
|
91
|
+
expect(args.slice(2)).toEqual(['-show_entries', 'format=duration', 'in.mp4']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('never includes http in the default probe whitelist', async () => {
|
|
95
|
+
execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
|
|
96
|
+
cb(null, '', '');
|
|
97
|
+
});
|
|
98
|
+
await runFfprobe(['x']);
|
|
99
|
+
const protocols = (execFileMock.mock.calls[0][1] as string[])[1].split(',');
|
|
100
|
+
expect(protocols).not.toContain('http');
|
|
101
|
+
expect(protocols).not.toContain('rtsp');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('resolves with stdout by default (probe output is on stdout)', async () => {
|
|
105
|
+
execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
|
|
106
|
+
cb(null, '1920\n1080', 'noise');
|
|
107
|
+
});
|
|
108
|
+
const out = await runFfprobe(['x']);
|
|
109
|
+
expect(out).toBe('1920\n1080');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects with a sanitized message when ffprobe fails', async () => {
|
|
113
|
+
execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
|
|
114
|
+
cb(new Error('exit 1'), '', 'x-api-key: super-secret-value');
|
|
115
|
+
});
|
|
116
|
+
await expect(runFfprobe(['x'])).rejects.toThrow(/\[REDACTED\]/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('includes the label in the rejection message', async () => {
|
|
120
|
+
execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
|
|
121
|
+
cb(new Error('x'), '', 'boom');
|
|
122
|
+
});
|
|
123
|
+
await expect(runFfprobe([], { label: 'audio-probe' })).rejects.toThrow(/audio-probe/);
|
|
124
|
+
});
|
|
125
|
+
});
|