@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,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeToolPaths } from './sanitize-tool-paths.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Each block pairs an "attack blocked" case with a "benign allowed" case, per
|
|
6
|
+
* the field shape the tool uses. `sanitizeToolPaths` throws on the first
|
|
7
|
+
* offending path and is a silent no-op otherwise.
|
|
8
|
+
*/
|
|
9
|
+
describe('sanitize-tool-paths — scalar path fields', () => {
|
|
10
|
+
it('blocks a leading-dash outputPath (flag injection)', () => {
|
|
11
|
+
expect(() =>
|
|
12
|
+
sanitizeToolPaths('crop_video', { inputPath: 'in.mp4', outputPath: '-y' }),
|
|
13
|
+
).toThrow(/outputPath.*flag/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('blocks a leading-dash inputPath', () => {
|
|
17
|
+
expect(() =>
|
|
18
|
+
sanitizeToolPaths('adjust_video_speed', { inputPath: '-i', outputPath: 'out.mp4' }),
|
|
19
|
+
).toThrow(/inputPath.*flag/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('blocks a NUL byte in a path', () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4\0-i', outputPath: 'b.mp3' }),
|
|
25
|
+
).toThrow(/null byte/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('allows ordinary input/output paths', () => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
sanitizeToolPaths('apply_color_grade', {
|
|
31
|
+
inputPath: '/home/user/clip.mp4',
|
|
32
|
+
outputPath: 'relative/out.mp4',
|
|
33
|
+
}),
|
|
34
|
+
).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('skips omitted optional paths (unchanged benign behaviour)', () => {
|
|
38
|
+
// outputPath is optional for many tools — undefined must not throw.
|
|
39
|
+
expect(() => sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4' })).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('validates every scalar field declared for a tool', () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
sanitizeToolPaths('burn_subtitles', {
|
|
45
|
+
inputPath: 'in.mp4',
|
|
46
|
+
outputPath: 'out.mp4',
|
|
47
|
+
subtitlePath: '-attach',
|
|
48
|
+
}),
|
|
49
|
+
).toThrow(/subtitlePath.*flag/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('sanitize-tool-paths — string-array path fields (sync_to_beat clips)', () => {
|
|
54
|
+
it('blocks a malicious entry inside the clips array', () => {
|
|
55
|
+
expect(() =>
|
|
56
|
+
sanitizeToolPaths('sync_to_beat', {
|
|
57
|
+
audioPath: 'beat.mp3',
|
|
58
|
+
outputPath: 'out.mp4',
|
|
59
|
+
clips: ['ok1.mp4', '-f', 'ok2.mp4'],
|
|
60
|
+
}),
|
|
61
|
+
).toThrow(/clips\[1\].*flag/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows a clean clips array', () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
sanitizeToolPaths('sync_to_beat', {
|
|
67
|
+
audioPath: 'beat.mp3',
|
|
68
|
+
outputPath: 'out.mp4',
|
|
69
|
+
clips: ['a.mp4', 'b.mp4'],
|
|
70
|
+
}),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('sanitize-tool-paths — object-array path fields (concat clips / mixer tracks)', () => {
|
|
76
|
+
it('blocks a malicious clip path in concatenate_videos', () => {
|
|
77
|
+
expect(() =>
|
|
78
|
+
sanitizeToolPaths('concatenate_videos', {
|
|
79
|
+
outputPath: 'out.mp4',
|
|
80
|
+
clips: [{ path: 'good.mp4' }, { path: '-i' }],
|
|
81
|
+
}),
|
|
82
|
+
).toThrow(/clips\[1\]\.path.*flag/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('blocks a malicious track path in mix_audio_tracks', () => {
|
|
86
|
+
expect(() =>
|
|
87
|
+
sanitizeToolPaths('mix_audio_tracks', {
|
|
88
|
+
outputPath: 'out.aac',
|
|
89
|
+
tracks: [{ path: 'voice.mp3' }, { path: '-protocol_whitelist' }],
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(/tracks\[1\]\.path.*flag/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('allows clean object-array paths', () => {
|
|
95
|
+
expect(() =>
|
|
96
|
+
sanitizeToolPaths('concatenate_videos', {
|
|
97
|
+
outputPath: 'out.mp4',
|
|
98
|
+
clips: [{ path: 'a.mp4', trimStart: 1 }, { path: 'b.mp4' }],
|
|
99
|
+
}),
|
|
100
|
+
).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('sanitize-tool-paths — record path fields (render_template clips)', () => {
|
|
105
|
+
it('blocks a malicious value in the clips record', () => {
|
|
106
|
+
expect(() =>
|
|
107
|
+
sanitizeToolPaths('render_template', {
|
|
108
|
+
templateId: 'social-reel',
|
|
109
|
+
outputPath: 'out.mp4',
|
|
110
|
+
clips: { intro: 'good.mp4', main: '-y' },
|
|
111
|
+
}),
|
|
112
|
+
).toThrow(/clips\.main.*flag/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('allows a clean clips record', () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
sanitizeToolPaths('render_template', {
|
|
118
|
+
templateId: 'social-reel',
|
|
119
|
+
outputPath: 'out.mp4',
|
|
120
|
+
clips: { intro: 'a.mp4', main: 'b.mp4' },
|
|
121
|
+
}),
|
|
122
|
+
).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('sanitize-tool-paths — chroma-key background (path OR hex colour)', () => {
|
|
127
|
+
it('blocks a leading-dash background path', () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
sanitizeToolPaths('apply_chroma_key', {
|
|
130
|
+
inputPath: 'in.mp4',
|
|
131
|
+
outputPath: 'out.mp4',
|
|
132
|
+
background: '-f',
|
|
133
|
+
}),
|
|
134
|
+
).toThrow(/background.*flag/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('allows a bare 6-digit hex colour as background', () => {
|
|
138
|
+
for (const hex of ['00FF00', '#0000FF', '0x000000']) {
|
|
139
|
+
expect(() =>
|
|
140
|
+
sanitizeToolPaths('apply_chroma_key', {
|
|
141
|
+
inputPath: 'in.mp4',
|
|
142
|
+
outputPath: 'out.mp4',
|
|
143
|
+
background: hex,
|
|
144
|
+
}),
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('allows a real background file path', () => {
|
|
150
|
+
expect(() =>
|
|
151
|
+
sanitizeToolPaths('apply_chroma_key', {
|
|
152
|
+
inputPath: 'in.mp4',
|
|
153
|
+
outputPath: 'out.mp4',
|
|
154
|
+
background: 'backgrounds/beach.png',
|
|
155
|
+
}),
|
|
156
|
+
).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('sanitize-tool-paths — registry boundaries', () => {
|
|
161
|
+
it('is a no-op for tools without path fields', () => {
|
|
162
|
+
expect(() => sanitizeToolPaths('list_voices', {})).not.toThrow();
|
|
163
|
+
expect(() => sanitizeToolPaths('list_video_templates', { category: 'promo' })).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('is a no-op for unknown tools', () => {
|
|
167
|
+
expect(() => sanitizeToolPaths('nonexistent_tool', { outputPath: '-y' })).not.toThrow();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('tolerates non-object args', () => {
|
|
171
|
+
expect(() => sanitizeToolPaths('crop_video', null)).not.toThrow();
|
|
172
|
+
expect(() => sanitizeToolPaths('crop_video', undefined)).not.toThrow();
|
|
173
|
+
expect(() => sanitizeToolPaths('crop_video', 'not-an-object')).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('ignores malformed array entries instead of crashing', () => {
|
|
177
|
+
// objectArray entries that are not objects-with-path are skipped.
|
|
178
|
+
expect(() =>
|
|
179
|
+
sanitizeToolPaths('concatenate_videos', {
|
|
180
|
+
outputPath: 'out.mp4',
|
|
181
|
+
clips: [null, 'string-entry', { noPath: true }],
|
|
182
|
+
}),
|
|
183
|
+
).not.toThrow();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central path-argument sanitizer for MCP tool handlers.
|
|
3
|
+
*
|
|
4
|
+
* The threat (already documented in ffmpeg-safety.ts) is real: every tool
|
|
5
|
+
* that shells out to ffmpeg/ffprobe passes user-supplied path strings as
|
|
6
|
+
* positional arguments. ffmpeg treats any argument that begins with `-` as
|
|
7
|
+
* a *flag*, not a filename — so a caller (or a confused LLM following an
|
|
8
|
+
* injected instruction) that sets `outputPath` to `-y`, `-f`, or
|
|
9
|
+
* `-protocol_whitelist` can rewrite the command instead of naming a file.
|
|
10
|
+
* Strings containing NUL bytes are equally dangerous (C-string truncation).
|
|
11
|
+
*
|
|
12
|
+
* `validateFfmpegPath` was written precisely to stop this, but until now it
|
|
13
|
+
* was wired into a single engine (narrated-video). The engines validate
|
|
14
|
+
* *input* existence (`assertExists`) which incidentally blocks some flag
|
|
15
|
+
* inputs, but they never check *output* paths — and several inputs flow
|
|
16
|
+
* through `fs.copyFileSync` / the concat demuxer without an existence check
|
|
17
|
+
* at all. The defense therefore had a wide bypass.
|
|
18
|
+
*
|
|
19
|
+
* This module closes the gap at the handler boundary — the one place every
|
|
20
|
+
* untrusted MCP argument enters — so no engine logic (filter graph building,
|
|
21
|
+
* arg ordering) has to change. Each tool declares which argument keys are
|
|
22
|
+
* path-like; nested arrays of paths (concat clips, audio-mixer tracks) are
|
|
23
|
+
* walked too.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { validateFfmpegPath } from './ffmpeg-safety.js';
|
|
27
|
+
|
|
28
|
+
/** Describes where path-like values live in a tool's argument object. */
|
|
29
|
+
interface PathFieldSpec {
|
|
30
|
+
/** Top-level keys whose value is a single path string. */
|
|
31
|
+
readonly scalar?: readonly string[];
|
|
32
|
+
/** Keys whose value is an array of path strings (e.g. beat-sync `clips`). */
|
|
33
|
+
readonly stringArray?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Keys whose value is an array of objects each carrying a `path` field
|
|
36
|
+
* (e.g. concatenate_videos `clips`, mix_audio_tracks `tracks`).
|
|
37
|
+
*/
|
|
38
|
+
readonly objectArrayPath?: readonly string[];
|
|
39
|
+
/** Keys whose value is a record of name → path (template-renderer `clips`). */
|
|
40
|
+
readonly recordValues?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Keys that are a path OR a benign non-path token (chroma-key `background`
|
|
43
|
+
* may be a 6-digit hex colour). Only validated when the value looks like a
|
|
44
|
+
* filesystem path rather than the allowed alternative.
|
|
45
|
+
*/
|
|
46
|
+
readonly pathOrHex?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-tool path-field registry. Only ffmpeg/ffprobe-shelling tools are listed
|
|
51
|
+
* here — tools that merely write via fs/Playwright (generate_speech,
|
|
52
|
+
* screenshot_element) are handled separately because a leading-`-` there is a
|
|
53
|
+
* file-write, not flag injection. Keep this in sync with the handler args.
|
|
54
|
+
*/
|
|
55
|
+
const TOOL_PATH_FIELDS: Record<string, PathFieldSpec> = {
|
|
56
|
+
// post-production
|
|
57
|
+
add_background_music: { scalar: ['videoPath', 'musicPath', 'outputPath'] },
|
|
58
|
+
concatenate_videos: { scalar: ['outputPath'], objectArrayPath: ['clips'] },
|
|
59
|
+
generate_intro: { scalar: ['outputPath'] },
|
|
60
|
+
convert_social_format: { scalar: ['inputPath', 'outputPath'] },
|
|
61
|
+
convert_all_social_formats: { scalar: ['inputPath', 'outputDir'] },
|
|
62
|
+
add_text_overlay: { scalar: ['inputPath', 'outputPath'] },
|
|
63
|
+
|
|
64
|
+
// editing
|
|
65
|
+
adjust_video_speed: { scalar: ['inputPath', 'outputPath'] },
|
|
66
|
+
apply_color_grade: { scalar: ['inputPath', 'outputPath'] },
|
|
67
|
+
apply_video_effect: { scalar: ['inputPath', 'outputPath'] },
|
|
68
|
+
crop_video: { scalar: ['inputPath', 'outputPath'] },
|
|
69
|
+
reverse_clip: { scalar: ['inputPath', 'outputPath'] },
|
|
70
|
+
extract_audio: { scalar: ['inputPath', 'outputPath'] },
|
|
71
|
+
burn_subtitles: { scalar: ['inputPath', 'outputPath', 'subtitlePath'] },
|
|
72
|
+
auto_caption: { scalar: ['inputPath', 'outputPath'] },
|
|
73
|
+
add_keyframe_animation: { scalar: ['inputPath', 'outputPath'] },
|
|
74
|
+
compose_picture_in_pip: { scalar: ['mainVideo', 'overlayVideo', 'outputPath'] },
|
|
75
|
+
add_audio_ducking: { scalar: ['inputPath', 'outputPath'] },
|
|
76
|
+
|
|
77
|
+
// capcut-tier
|
|
78
|
+
apply_lut_preset: { scalar: ['inputPath', 'outputPath'] },
|
|
79
|
+
apply_voice_effect: { scalar: ['inputPath', 'outputPath'] },
|
|
80
|
+
apply_chroma_key: { scalar: ['inputPath', 'outputPath'], pathOrHex: ['background'] },
|
|
81
|
+
sync_to_beat: { scalar: ['audioPath', 'outputPath'], stringArray: ['clips'] },
|
|
82
|
+
animate_text: { scalar: ['inputPath', 'outputPath'] },
|
|
83
|
+
mix_audio_tracks: { scalar: ['outputPath'], objectArrayPath: ['tracks'] },
|
|
84
|
+
render_template: { scalar: ['outputPath', 'musicPath'], recordValues: ['clips'] },
|
|
85
|
+
|
|
86
|
+
// tts (narrated video also shells out via the recording + concat pipeline)
|
|
87
|
+
create_narrated_video: { scalar: ['outputPath', 'backgroundMusicPath'] },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const HEX_COLOR = /^(?:#|0x)?[0-9a-fA-F]{6}$/;
|
|
91
|
+
|
|
92
|
+
/** A value is "present" for validation if it is a non-empty string. */
|
|
93
|
+
function isPresentString(v: unknown): v is string {
|
|
94
|
+
return typeof v === 'string' && v.length > 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate every path-like argument for the named tool. Throws the same
|
|
99
|
+
* error class `validateFfmpegPath` throws (TypeError / Error) on the first
|
|
100
|
+
* offending value, which the handler's try/catch turns into a structured
|
|
101
|
+
* tool error. Tools not in the registry are left untouched.
|
|
102
|
+
*
|
|
103
|
+
* Only *present* values are checked — optional paths that the caller omitted
|
|
104
|
+
* (and the handler will default) are skipped so behaviour is unchanged for
|
|
105
|
+
* benign callers.
|
|
106
|
+
*/
|
|
107
|
+
export function sanitizeToolPaths(toolName: string, args: unknown): void {
|
|
108
|
+
const spec = TOOL_PATH_FIELDS[toolName];
|
|
109
|
+
if (!spec) return;
|
|
110
|
+
if (typeof args !== 'object' || args === null) return;
|
|
111
|
+
const record = args as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
for (const key of spec.scalar ?? []) {
|
|
114
|
+
const value = record[key];
|
|
115
|
+
if (isPresentString(value)) validateFfmpegPath(value, key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const key of spec.stringArray ?? []) {
|
|
119
|
+
const value = record[key];
|
|
120
|
+
if (!Array.isArray(value)) continue;
|
|
121
|
+
value.forEach((entry, i) => {
|
|
122
|
+
if (isPresentString(entry)) validateFfmpegPath(entry, `${key}[${i}]`);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const key of spec.objectArrayPath ?? []) {
|
|
127
|
+
const value = record[key];
|
|
128
|
+
if (!Array.isArray(value)) continue;
|
|
129
|
+
value.forEach((entry, i) => {
|
|
130
|
+
if (entry && typeof entry === 'object' && 'path' in entry) {
|
|
131
|
+
const p = (entry as { path: unknown }).path;
|
|
132
|
+
if (isPresentString(p)) validateFfmpegPath(p, `${key}[${i}].path`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const key of spec.recordValues ?? []) {
|
|
138
|
+
const value = record[key];
|
|
139
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
140
|
+
for (const [slot, p] of Object.entries(value as Record<string, unknown>)) {
|
|
141
|
+
if (isPresentString(p)) validateFfmpegPath(p, `${key}.${slot}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const key of spec.pathOrHex ?? []) {
|
|
146
|
+
const value = record[key];
|
|
147
|
+
// A bare 6-digit hex colour (e.g. "00FF00") is a legitimate non-path
|
|
148
|
+
// value for chroma-key backgrounds — skip those. Anything else is treated
|
|
149
|
+
// as a path and must pass the flag-injection / NUL-byte check.
|
|
150
|
+
if (isPresentString(value) && !HEX_COLOR.test(value)) {
|
|
151
|
+
validateFfmpegPath(value, key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
const lookupMock = vi.hoisted(() => vi.fn());
|
|
16
|
+
|
|
17
|
+
vi.mock('node:dns/promises', () => ({ lookup: lookupMock }));
|
|
18
|
+
|
|
19
|
+
// Import AFTER the mock so the binding is the mock.
|
|
20
|
+
import { resolveAndGuardUrl } from './url-guard.js';
|
|
21
|
+
|
|
22
|
+
const ORIGINAL_ALLOW_INTERNAL = process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
lookupMock.mockReset();
|
|
26
|
+
delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (ORIGINAL_ALLOW_INTERNAL === undefined) delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
|
|
30
|
+
else process.env.MCP_VIDEO_ALLOW_INTERNAL = ORIGINAL_ALLOW_INTERNAL;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('resolveAndGuardUrl — DNS-rebinding defense', () => {
|
|
34
|
+
it('blocks a public hostname that resolves to a loopback address', async () => {
|
|
35
|
+
lookupMock.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
|
|
36
|
+
const res = await resolveAndGuardUrl('https://rebind.evil.example/');
|
|
37
|
+
expect(res.ok).toBe(false);
|
|
38
|
+
if (!res.ok) expect(res.reason).toMatch(/resolves to private address 127\.0\.0\.1/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('blocks a hostname resolving to the cloud-metadata IP (169.254.169.254)', async () => {
|
|
42
|
+
lookupMock.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }]);
|
|
43
|
+
const res = await resolveAndGuardUrl('https://metadata.evil.example/');
|
|
44
|
+
expect(res.ok).toBe(false);
|
|
45
|
+
if (!res.ok) expect(res.reason).toMatch(/169\.254\.169\.254/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('blocks when ANY resolved address is internal (multi-A record)', async () => {
|
|
49
|
+
lookupMock.mockResolvedValueOnce([
|
|
50
|
+
{ address: '93.184.216.34', family: 4 }, // public
|
|
51
|
+
{ address: '10.0.0.5', family: 4 }, // private — must trip the guard
|
|
52
|
+
]);
|
|
53
|
+
const res = await resolveAndGuardUrl('https://mixed.evil.example/');
|
|
54
|
+
expect(res.ok).toBe(false);
|
|
55
|
+
if (!res.ok) expect(res.reason).toMatch(/10\.0\.0\.5/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('blocks an internal IPv6 resolution (unique-local fc00::/7)', async () => {
|
|
59
|
+
lookupMock.mockResolvedValueOnce([{ address: 'fc00::1', family: 6 }]);
|
|
60
|
+
const res = await resolveAndGuardUrl('https://v6.evil.example/');
|
|
61
|
+
expect(res.ok).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows a hostname that resolves to a public address', async () => {
|
|
65
|
+
lookupMock.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
|
|
66
|
+
const res = await resolveAndGuardUrl('https://example.com/page');
|
|
67
|
+
expect(res.ok).toBe(true);
|
|
68
|
+
if (res.ok) expect(res.url).toContain('example.com');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns a clean failure when DNS resolution itself fails', async () => {
|
|
72
|
+
lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
|
73
|
+
const res = await resolveAndGuardUrl('https://nxdomain.invalid/');
|
|
74
|
+
expect(res.ok).toBe(false);
|
|
75
|
+
if (!res.ok) expect(res.reason).toMatch(/DNS lookup failed/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('resolveAndGuardUrl — short-circuits (no DNS call)', () => {
|
|
80
|
+
it('rejects a non-http scheme before any lookup', async () => {
|
|
81
|
+
const res = await resolveAndGuardUrl('file:///etc/passwd');
|
|
82
|
+
expect(res.ok).toBe(false);
|
|
83
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a literal internal IP via the sync layer (no lookup needed)', async () => {
|
|
87
|
+
const res = await resolveAndGuardUrl('http://127.0.0.1/');
|
|
88
|
+
expect(res.ok).toBe(false);
|
|
89
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not resolve a literal public IPv4 (already host-checked)', async () => {
|
|
93
|
+
const res = await resolveAndGuardUrl('https://93.184.216.34/');
|
|
94
|
+
expect(res.ok).toBe(true);
|
|
95
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not resolve a literal IPv6 host', async () => {
|
|
99
|
+
const res = await resolveAndGuardUrl('https://[2606:2800:220:1:248:1893:25c8:1946]/');
|
|
100
|
+
expect(res.ok).toBe(true);
|
|
101
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips DNS entirely when MCP_VIDEO_ALLOW_INTERNAL=1', async () => {
|
|
105
|
+
process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
|
|
106
|
+
const res = await resolveAndGuardUrl('https://internal.dev.example/');
|
|
107
|
+
expect(res.ok).toBe(true);
|
|
108
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects a non-string input without resolving', async () => {
|
|
112
|
+
const res = await resolveAndGuardUrl(undefined);
|
|
113
|
+
expect(res.ok).toBe(false);
|
|
114
|
+
expect(lookupMock).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|