@studiomeyer/mcp-video 1.0.0 → 1.0.1

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.
Files changed (121) hide show
  1. package/.github/FUNDING.yml +2 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +9 -0
  3. package/.github/dependabot.yml +46 -0
  4. package/.github/workflows/ci.yml +2 -2
  5. package/CHANGELOG.md +157 -0
  6. package/CODE_OF_CONDUCT.md +7 -0
  7. package/ECOSYSTEM.md +35 -0
  8. package/README.md +26 -2
  9. package/SECURITY.md +11 -0
  10. package/dist/handlers/smart-screenshot.js +10 -3
  11. package/dist/handlers/smart-screenshot.js.map +1 -1
  12. package/dist/handlers/tts.js +6 -2
  13. package/dist/handlers/tts.js.map +1 -1
  14. package/dist/handlers/video.js +17 -7
  15. package/dist/handlers/video.js.map +1 -1
  16. package/dist/lib/error-sanitizer.d.ts +26 -0
  17. package/dist/lib/error-sanitizer.js +58 -0
  18. package/dist/lib/error-sanitizer.js.map +1 -0
  19. package/dist/lib/error-sanitizer.test.d.ts +1 -0
  20. package/dist/lib/error-sanitizer.test.js +73 -0
  21. package/dist/lib/error-sanitizer.test.js.map +1 -0
  22. package/dist/lib/ffmpeg-bin.d.ts +64 -0
  23. package/dist/lib/ffmpeg-bin.js +96 -0
  24. package/dist/lib/ffmpeg-bin.js.map +1 -0
  25. package/dist/lib/ffmpeg-bin.test.d.ts +18 -0
  26. package/dist/lib/ffmpeg-bin.test.js +169 -0
  27. package/dist/lib/ffmpeg-bin.test.js.map +1 -0
  28. package/dist/lib/ffmpeg-run.d.ts +43 -0
  29. package/dist/lib/ffmpeg-run.js +67 -0
  30. package/dist/lib/ffmpeg-run.js.map +1 -0
  31. package/dist/lib/ffmpeg-run.test.d.ts +1 -0
  32. package/dist/lib/ffmpeg-run.test.js +66 -0
  33. package/dist/lib/ffmpeg-run.test.js.map +1 -0
  34. package/dist/lib/ffmpeg-safety.d.ts +37 -0
  35. package/dist/lib/ffmpeg-safety.js +67 -0
  36. package/dist/lib/ffmpeg-safety.js.map +1 -0
  37. package/dist/lib/ffmpeg-safety.test.d.ts +1 -0
  38. package/dist/lib/ffmpeg-safety.test.js +72 -0
  39. package/dist/lib/ffmpeg-safety.test.js.map +1 -0
  40. package/dist/lib/temp-dir.d.ts +24 -0
  41. package/dist/lib/temp-dir.js +53 -0
  42. package/dist/lib/temp-dir.js.map +1 -0
  43. package/dist/lib/temp-dir.test.d.ts +1 -0
  44. package/dist/lib/temp-dir.test.js +68 -0
  45. package/dist/lib/temp-dir.test.js.map +1 -0
  46. package/dist/lib/url-guard.d.ts +41 -0
  47. package/dist/lib/url-guard.js +134 -0
  48. package/dist/lib/url-guard.js.map +1 -0
  49. package/dist/lib/url-guard.test.d.ts +10 -0
  50. package/dist/lib/url-guard.test.js +231 -0
  51. package/dist/lib/url-guard.test.js.map +1 -0
  52. package/dist/server.js +9 -4
  53. package/dist/server.js.map +1 -1
  54. package/dist/tools/engine/audio-mixer.js +5 -20
  55. package/dist/tools/engine/audio-mixer.js.map +1 -1
  56. package/dist/tools/engine/audio.js +3 -19
  57. package/dist/tools/engine/audio.js.map +1 -1
  58. package/dist/tools/engine/beat-sync.js +7 -30
  59. package/dist/tools/engine/beat-sync.js.map +1 -1
  60. package/dist/tools/engine/capture.js +7 -0
  61. package/dist/tools/engine/capture.js.map +1 -1
  62. package/dist/tools/engine/chroma-key.js +2 -11
  63. package/dist/tools/engine/chroma-key.js.map +1 -1
  64. package/dist/tools/engine/concat.js +2 -11
  65. package/dist/tools/engine/concat.js.map +1 -1
  66. package/dist/tools/engine/editing.js +12 -35
  67. package/dist/tools/engine/editing.js.map +1 -1
  68. package/dist/tools/engine/encoder.js +2 -12
  69. package/dist/tools/engine/encoder.js.map +1 -1
  70. package/dist/tools/engine/lut-presets.js +2 -11
  71. package/dist/tools/engine/lut-presets.js.map +1 -1
  72. package/dist/tools/engine/narrated-video.js +30 -39
  73. package/dist/tools/engine/narrated-video.js.map +1 -1
  74. package/dist/tools/engine/smart-screenshot.js +7 -0
  75. package/dist/tools/engine/smart-screenshot.js.map +1 -1
  76. package/dist/tools/engine/social-format.js +2 -11
  77. package/dist/tools/engine/social-format.js.map +1 -1
  78. package/dist/tools/engine/template-renderer.js +2 -11
  79. package/dist/tools/engine/template-renderer.js.map +1 -1
  80. package/dist/tools/engine/text-animations.js +2 -11
  81. package/dist/tools/engine/text-animations.js.map +1 -1
  82. package/dist/tools/engine/text-overlay.js +2 -11
  83. package/dist/tools/engine/text-overlay.js.map +1 -1
  84. package/dist/tools/engine/tts.js +11 -6
  85. package/dist/tools/engine/tts.js.map +1 -1
  86. package/dist/tools/engine/voice-effects.js +3 -20
  87. package/dist/tools/engine/voice-effects.js.map +1 -1
  88. package/package.json +6 -6
  89. package/src/handlers/smart-screenshot.ts +8 -3
  90. package/src/handlers/tts.ts +6 -2
  91. package/src/handlers/video.ts +14 -7
  92. package/src/lib/error-sanitizer.test.ts +88 -0
  93. package/src/lib/error-sanitizer.ts +66 -0
  94. package/src/lib/ffmpeg-bin.test.ts +192 -0
  95. package/src/lib/ffmpeg-bin.ts +111 -0
  96. package/src/lib/ffmpeg-run.test.ts +76 -0
  97. package/src/lib/ffmpeg-run.ts +110 -0
  98. package/src/lib/ffmpeg-safety.test.ts +88 -0
  99. package/src/lib/ffmpeg-safety.ts +79 -0
  100. package/src/lib/temp-dir.test.ts +75 -0
  101. package/src/lib/temp-dir.ts +58 -0
  102. package/src/lib/url-guard.test.ts +261 -0
  103. package/src/lib/url-guard.ts +143 -0
  104. package/src/server.ts +10 -5
  105. package/src/tools/engine/audio-mixer.ts +8 -21
  106. package/src/tools/engine/audio.ts +6 -21
  107. package/src/tools/engine/beat-sync.ts +10 -31
  108. package/src/tools/engine/capture.ts +8 -0
  109. package/src/tools/engine/chroma-key.ts +2 -11
  110. package/src/tools/engine/concat.ts +2 -11
  111. package/src/tools/engine/editing.ts +17 -34
  112. package/src/tools/engine/encoder.ts +2 -12
  113. package/src/tools/engine/lut-presets.ts +2 -11
  114. package/src/tools/engine/narrated-video.ts +26 -38
  115. package/src/tools/engine/smart-screenshot.ts +8 -0
  116. package/src/tools/engine/social-format.ts +2 -11
  117. package/src/tools/engine/template-renderer.ts +2 -11
  118. package/src/tools/engine/text-animations.ts +2 -11
  119. package/src/tools/engine/text-overlay.ts +2 -11
  120. package/src/tools/engine/tts.ts +15 -6
  121. package/src/tools/engine/voice-effects.ts +3 -17
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Central ffmpeg runner — every engine should use this instead of calling
3
+ * `execFile('ffmpeg', args, ...)` directly. Two reasons:
4
+ *
5
+ * 1. We inject `-protocol_whitelist` on every call (see ffmpeg-safety.ts).
6
+ * Without it, an HLS playlist — or a user-provided "inputPath" that is
7
+ * secretly an `https://` URL — lets ffmpeg fetch any protocol it was
8
+ * built against, including `file://`, which means SSRF to the local
9
+ * filesystem or to AWS/GCP metadata endpoints.
10
+ *
11
+ * 2. We sanitize stderr before it lands in logs or thrown messages.
12
+ * ffmpeg will happily echo signed URLs, Authorization headers and
13
+ * API keys from failed HTTP fetches.
14
+ *
15
+ * Callers pass their own `maxBuffer`/`timeout`/`protocols` as needed.
16
+ */
17
+
18
+ import { execFile } from 'node:child_process';
19
+ import { logger } from './logger.js';
20
+ import { buildFfmpegArgs, type FfmpegProtocolSet } from './ffmpeg-safety.js';
21
+ import { sanitizeErrorMessage } from './error-sanitizer.js';
22
+ import { resolveFfmpegBin } from './ffmpeg-bin.js';
23
+
24
+ export interface FfmpegRunOptions {
25
+ /** Bytes of stdout+stderr buffered before killing the process. Default: 50 MB. */
26
+ maxBuffer?: number;
27
+ /** Kill-after timeout in ms. Default: no timeout (ffmpeg exits on its own). */
28
+ timeoutMs?: number;
29
+ /** Which ffmpeg protocols to permit. Default: 'local-only' (file + pipe). */
30
+ protocols?: FfmpegProtocolSet;
31
+ /** Optional label used in the rejection reason, e.g. "lut-preset" */
32
+ label?: string;
33
+ }
34
+
35
+ /**
36
+ * Some callers (beat-sync, filter pipelines) need stderr because ffmpeg
37
+ * prints filter-graph info and stream metadata there. Pass `'stderr'` as
38
+ * the third arg to resolve with stderr instead of stdout. Defaults to stdout.
39
+ */
40
+ export function runFfmpeg(
41
+ args: string[],
42
+ opts: FfmpegRunOptions = {},
43
+ resolver: 'stdout' | 'stderr' = 'stdout'
44
+ ): Promise<string> {
45
+ const {
46
+ maxBuffer = 50 * 1024 * 1024,
47
+ timeoutMs,
48
+ protocols = 'local-only',
49
+ label = 'ffmpeg',
50
+ } = opts;
51
+ const safeArgs = buildFfmpegArgs(args, protocols);
52
+
53
+ return new Promise((resolve, reject) => {
54
+ execFile(
55
+ resolveFfmpegBin('ffmpeg'),
56
+ safeArgs,
57
+ { maxBuffer, timeout: timeoutMs, windowsHide: true },
58
+ (error, stdout, stderr) => {
59
+ if (error) {
60
+ const safeMsg = sanitizeErrorMessage(stderr || error.message);
61
+ logger.error(`${label} failed: ${safeMsg}`);
62
+ reject(new Error(`${label} failed: ${safeMsg}`));
63
+ return;
64
+ }
65
+ resolve(resolver === 'stderr' ? stderr : stdout);
66
+ }
67
+ );
68
+ });
69
+ }
70
+
71
+ /**
72
+ * ffprobe runner — same protocol-whitelist + stderr-sanitize discipline as
73
+ * runFfmpeg. ffprobe silently follows HLS/DASH playlists the same way ffmpeg
74
+ * does, so every `execFile('ffprobe', args, ...)` must go through here or
75
+ * the SSRF hardening has a bypass via "just probe the file first".
76
+ *
77
+ * Default resolver is stdout because ffprobe's `-show_entries …` + `-of …`
78
+ * output is what callers need. stderr is error-only.
79
+ */
80
+ export function runFfprobe(
81
+ args: string[],
82
+ opts: FfmpegRunOptions = {},
83
+ resolver: 'stdout' | 'stderr' = 'stdout'
84
+ ): Promise<string> {
85
+ const {
86
+ maxBuffer = 10 * 1024 * 1024,
87
+ timeoutMs,
88
+ protocols = 'local-only',
89
+ label = 'ffprobe',
90
+ } = opts;
91
+ // ffprobe honours the same -protocol_whitelist flag as ffmpeg.
92
+ const safeArgs = buildFfmpegArgs(args, protocols);
93
+
94
+ return new Promise((resolve, reject) => {
95
+ execFile(
96
+ resolveFfmpegBin('ffprobe'),
97
+ safeArgs,
98
+ { maxBuffer, timeout: timeoutMs, windowsHide: true },
99
+ (error, stdout, stderr) => {
100
+ if (error) {
101
+ const safeMsg = sanitizeErrorMessage(stderr || error.message);
102
+ logger.error(`${label} failed: ${safeMsg}`);
103
+ reject(new Error(`${label} failed: ${safeMsg}`));
104
+ return;
105
+ }
106
+ resolve(resolver === 'stderr' ? stderr : stdout);
107
+ }
108
+ );
109
+ });
110
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ buildFfmpegArgs,
4
+ validateFfmpegPath,
5
+ validateFfmpegPaths,
6
+ } from './ffmpeg-safety.js';
7
+
8
+ describe('ffmpeg-safety — buildFfmpegArgs', () => {
9
+ it('prepends -protocol_whitelist local-only by default', () => {
10
+ const out = buildFfmpegArgs(['-i', 'a.mp4', 'b.mp4']);
11
+ expect(out[0]).toBe('-protocol_whitelist');
12
+ expect(out[1]).toBe('file,pipe,crypto,cache,fd');
13
+ expect(out.slice(2)).toEqual(['-i', 'a.mp4', 'b.mp4']);
14
+ });
15
+
16
+ it('supports https-input protocol set', () => {
17
+ const out = buildFfmpegArgs(['-i', 'https://example.com/s.m3u8'], 'https-input');
18
+ expect(out[1]).toBe('file,pipe,crypto,cache,fd,https,tls,tcp');
19
+ });
20
+
21
+ it('supports https-and-hls protocol set', () => {
22
+ const out = buildFfmpegArgs(['-i', 'a.m3u8'], 'https-and-hls');
23
+ expect(out[1]).toContain('hls');
24
+ expect(out[1]).toContain('applehttp');
25
+ });
26
+
27
+ it('never includes http:// (plain-text) in any protocol set', () => {
28
+ // Explicit check: mixing http would re-open SSRF to 169.254.x.x
29
+ for (const set of ['local-only', 'https-input', 'https-and-hls'] as const) {
30
+ const out = buildFfmpegArgs([], set);
31
+ const protocols = out[1].split(',');
32
+ expect(protocols).not.toContain('http');
33
+ expect(protocols).not.toContain('rtmp');
34
+ expect(protocols).not.toContain('rtsp');
35
+ expect(protocols).not.toContain('ftp');
36
+ expect(protocols).not.toContain('sftp');
37
+ }
38
+ });
39
+
40
+ it('throws if args is not an array', () => {
41
+ // @ts-expect-error intentional
42
+ expect(() => buildFfmpegArgs('not-an-array')).toThrow(/array/);
43
+ });
44
+ });
45
+
46
+ describe('ffmpeg-safety — validateFfmpegPath', () => {
47
+ it('accepts normal file paths', () => {
48
+ expect(validateFfmpegPath('/home/user/x.mp4')).toBe('/home/user/x.mp4');
49
+ expect(validateFfmpegPath('relative/file.mov')).toBe('relative/file.mov');
50
+ });
51
+
52
+ it('rejects empty strings', () => {
53
+ expect(() => validateFfmpegPath('')).toThrow(/non-empty/);
54
+ });
55
+
56
+ it('rejects non-string values', () => {
57
+ expect(() => validateFfmpegPath(null)).toThrow();
58
+ expect(() => validateFfmpegPath(undefined)).toThrow();
59
+ expect(() => validateFfmpegPath(42)).toThrow();
60
+ });
61
+
62
+ it('rejects paths starting with "-" (flag injection)', () => {
63
+ expect(() => validateFfmpegPath('-i')).toThrow(/flag/);
64
+ expect(() => validateFfmpegPath('-protocol_whitelist')).toThrow(/flag/);
65
+ expect(() => validateFfmpegPath('-help')).toThrow(/flag/);
66
+ });
67
+
68
+ it('rejects paths containing NUL bytes', () => {
69
+ expect(() => validateFfmpegPath('safe\0-i /etc/passwd')).toThrow(/null byte/);
70
+ });
71
+
72
+ it('includes the label in error messages', () => {
73
+ expect(() => validateFfmpegPath('-bad', 'input')).toThrow(/input/);
74
+ });
75
+ });
76
+
77
+ describe('ffmpeg-safety — validateFfmpegPaths', () => {
78
+ it('validates only the indices passed', () => {
79
+ const args = ['-y', '-i', 'valid.mp4', '-c:v', 'libx264', '-foo'];
80
+ // Only index 2 is a user-controlled path; -foo at index 5 is a built-in flag
81
+ validateFfmpegPaths(args, [2]);
82
+ expect(() => validateFfmpegPaths(args, [5])).toThrow(/flag/);
83
+ });
84
+
85
+ it('throws when any validated arg is empty', () => {
86
+ expect(() => validateFfmpegPaths(['', 'x'], [0])).toThrow(/non-empty/);
87
+ });
88
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ffmpeg hardening — protocol whitelist + argument validation.
3
+ *
4
+ * Two distinct threats:
5
+ * 1. ffmpeg can follow URLs inside HLS/DASH playlists and fetch any
6
+ * protocol ffmpeg was built with (file://, http, rtsp, tcp, udp...).
7
+ * A playlist from an attacker-controlled HTTPS host can reference
8
+ * `http://169.254.169.254/latest/meta-data/` and exfiltrate cloud
9
+ * credentials. We counter by passing -protocol_whitelist on every
10
+ * invocation, restricting ffmpeg to the smallest set of protocols
11
+ * the caller actually needs.
12
+ * 2. Any user-controlled path string that starts with `-` is treated
13
+ * by ffmpeg as a flag, not a filename. A caller passing
14
+ * `-i /etc/passwd -frames:v 1 -f image2` as "filename" can hijack
15
+ * the command. We forbid leading `-` on any input/output path.
16
+ */
17
+
18
+ export type FfmpegProtocolSet = 'local-only' | 'https-input' | 'https-and-hls';
19
+
20
+ const PROTOCOL_SETS: Record<FfmpegProtocolSet, string> = {
21
+ // Pure file-to-file work: editing, color, concat, audio mix, chroma.
22
+ 'local-only': 'file,pipe,crypto,cache,fd',
23
+ // ffmpeg can fetch the top-level https input but cannot follow HLS segment
24
+ // lists or reference any other protocol. Use when ONE https URL is the input.
25
+ 'https-input': 'file,pipe,crypto,cache,fd,https,tls,tcp',
26
+ // HLS master+segment playback. Still refuses http (only https), file schemes
27
+ // and 169.254.x.x metadata (those need to be caught upstream by url-guard).
28
+ 'https-and-hls': 'file,pipe,crypto,cache,fd,https,tls,tcp,hls,applehttp',
29
+ };
30
+
31
+ /**
32
+ * Prepend `-protocol_whitelist <set>` to ffmpeg args.
33
+ *
34
+ * Callers should use 'local-only' unless they genuinely need network.
35
+ * Position matters: ffmpeg only honours -protocol_whitelist when it appears
36
+ * before any `-i` input that would use it, so we always prepend.
37
+ */
38
+ export function buildFfmpegArgs(
39
+ userArgs: string[],
40
+ protocols: FfmpegProtocolSet = 'local-only'
41
+ ): string[] {
42
+ if (!Array.isArray(userArgs)) {
43
+ throw new TypeError('ffmpeg args must be an array of strings');
44
+ }
45
+ return ['-protocol_whitelist', PROTOCOL_SETS[protocols], ...userArgs];
46
+ }
47
+
48
+ /**
49
+ * Validate a user-supplied path that will be passed to ffmpeg as -i or output.
50
+ * Returns the sanitized path or throws. Rejects leading `-` (flag injection),
51
+ * empty strings, and values containing NUL bytes.
52
+ */
53
+ export function validateFfmpegPath(p: unknown, label = 'path'): string {
54
+ if (typeof p !== 'string' || p.length === 0) {
55
+ throw new TypeError(`${label} must be a non-empty string`);
56
+ }
57
+ if (p.startsWith('-')) {
58
+ throw new Error(`${label} must not start with "-" (looks like a flag)`);
59
+ }
60
+ if (p.includes('\0')) {
61
+ throw new Error(`${label} must not contain null bytes`);
62
+ }
63
+ return p;
64
+ }
65
+
66
+ /**
67
+ * Validate every entry in an args array used as ffmpeg filename-like tokens.
68
+ * Pass the list of indices that are user-controlled paths; other args
69
+ * (built by the caller with known flags) are not touched.
70
+ */
71
+ export function validateFfmpegPaths(
72
+ args: string[],
73
+ userControlledIndices: number[],
74
+ label = 'path'
75
+ ): void {
76
+ for (const i of userControlledIndices) {
77
+ validateFfmpegPath(args[i], `${label}[${i}]`);
78
+ }
79
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as fsSync from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { withTempDir, makeTempDir } from './temp-dir.js';
7
+
8
+ describe('temp-dir — withTempDir', () => {
9
+ it('creates a unique directory per call', async () => {
10
+ const dirs = await Promise.all([
11
+ withTempDir('parallel-', async (d) => d),
12
+ withTempDir('parallel-', async (d) => d),
13
+ withTempDir('parallel-', async (d) => d),
14
+ ]);
15
+ expect(new Set(dirs).size).toBe(3);
16
+ for (const d of dirs) {
17
+ expect(fsSync.existsSync(d)).toBe(false); // already cleaned
18
+ }
19
+ });
20
+
21
+ it('cleans up after the callback resolves', async () => {
22
+ let captured = '';
23
+ await withTempDir('cleanup-ok-', async (dir) => {
24
+ captured = dir;
25
+ expect(fsSync.existsSync(dir)).toBe(true);
26
+ await fs.writeFile(join(dir, 'x.txt'), 'hi');
27
+ });
28
+ expect(fsSync.existsSync(captured)).toBe(false);
29
+ });
30
+
31
+ it('cleans up even when the callback throws', async () => {
32
+ let captured = '';
33
+ await expect(
34
+ withTempDir('cleanup-fail-', async (dir) => {
35
+ captured = dir;
36
+ await fs.writeFile(join(dir, 'y.txt'), 'bye');
37
+ throw new Error('simulated');
38
+ })
39
+ ).rejects.toThrow('simulated');
40
+ expect(fsSync.existsSync(captured)).toBe(false);
41
+ });
42
+
43
+ it('creates a subdirectory under os.tmpdir()', async () => {
44
+ await withTempDir('under-tmpdir-', async (dir) => {
45
+ expect(dir.startsWith(tmpdir())).toBe(true);
46
+ });
47
+ });
48
+
49
+ it('sanitizes unsafe characters in the prefix (no traversal literal)', async () => {
50
+ await withTempDir('../../etc/passwd', async (dir) => {
51
+ // mkdtemp + join(tmpdir(), ...) already prevent real traversal; we
52
+ // additionally scrub `..` out of the literal segment for tidy
53
+ // audit logs.
54
+ expect(dir.startsWith(tmpdir())).toBe(true);
55
+ expect(dir.includes('..')).toBe(false);
56
+ expect(dir).toMatch(/etc-passwd-/);
57
+ });
58
+ });
59
+
60
+ it('returns the value from the callback', async () => {
61
+ const result = await withTempDir('value-', async () => 'ok');
62
+ expect(result).toBe('ok');
63
+ });
64
+ });
65
+
66
+ describe('temp-dir — makeTempDir', () => {
67
+ it('returns a path that exists and can be manually cleaned', async () => {
68
+ const dir = await makeTempDir('manual-');
69
+ try {
70
+ expect(fsSync.existsSync(dir)).toBe(true);
71
+ } finally {
72
+ await fs.rm(dir, { recursive: true, force: true });
73
+ }
74
+ });
75
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Safe temp directory helper.
3
+ *
4
+ * Motivation: many engines create predictable paths like
5
+ * `/tmp/narrated-video-${Date.now()}` which (a) race when two invocations
6
+ * hit the same millisecond, (b) leak state when the process crashes before
7
+ * the manual cleanup runs, and (c) are trivially overwritable by a local
8
+ * attacker who can guess the pattern.
9
+ *
10
+ * `withTempDir` uses `fs.mkdtemp` (unique suffix from the kernel) and
11
+ * always cleans up via try/finally.
12
+ */
13
+
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+ import { logger } from './logger.js';
18
+
19
+ /**
20
+ * Create a unique temp directory, pass it to `fn`, then clean up.
21
+ * Cleanup runs even if `fn` throws. Cleanup errors are logged but never
22
+ * re-thrown — the original error from `fn` always wins.
23
+ */
24
+ export async function withTempDir<T>(
25
+ prefix: string,
26
+ fn: (dir: string) => Promise<T>
27
+ ): Promise<T> {
28
+ const base = join(tmpdir(), sanitizePrefix(prefix));
29
+ const dir = await mkdtemp(base);
30
+ try {
31
+ return await fn(dir);
32
+ } finally {
33
+ await rm(dir, { recursive: true, force: true }).catch((err) => {
34
+ logger.warn(`temp-dir cleanup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);
35
+ });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Low-level: just create a unique temp directory, return the path.
41
+ * The caller is responsible for cleanup — prefer `withTempDir` when
42
+ * the lifetime is scoped to one function.
43
+ */
44
+ export async function makeTempDir(prefix: string): Promise<string> {
45
+ const base = join(tmpdir(), sanitizePrefix(prefix));
46
+ return mkdtemp(base);
47
+ }
48
+
49
+ function sanitizePrefix(prefix: string): string {
50
+ // Kill `..` sequences first so a caller can't build a traversal-looking
51
+ // literal segment (the join() to tmpdir() already makes traversal
52
+ // impossible, but we still prefer tidy paths for ops + audit logs).
53
+ const noTraversal = prefix.replace(/\.{2,}/g, '-');
54
+ const safe = noTraversal.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 32);
55
+ // mkdtemp appends 6 random chars; ensure we end with a `-` so the
56
+ // generated suffix stays visually separated.
57
+ return safe.endsWith('-') ? safe : `${safe}-`;
58
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Tests for the URL safety guard.
3
+ *
4
+ * The guard is the single chokepoint for any tool that navigates a
5
+ * user-supplied URL (Playwright page.goto, ffmpeg -i http://…). A bug here
6
+ * lets an AI assistant coerce the server to probe localhost, cloud metadata
7
+ * endpoints, or internal RFC1918 addresses, so this file exercises every
8
+ * branch of the reject rules and the MCP_VIDEO_ALLOW_INTERNAL escape hatch.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import { guardUrl } from './url-guard.js';
13
+
14
+ const ORIGINAL_ALLOW_INTERNAL = process.env.MCP_VIDEO_ALLOW_INTERNAL;
15
+
16
+ beforeEach(() => {
17
+ delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
18
+ });
19
+ afterEach(() => {
20
+ if (ORIGINAL_ALLOW_INTERNAL === undefined) {
21
+ delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
22
+ } else {
23
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = ORIGINAL_ALLOW_INTERNAL;
24
+ }
25
+ });
26
+
27
+ describe('guardUrl — input validation', () => {
28
+ it('rejects a non-string input (number)', () => {
29
+ const result = guardUrl(42 as unknown);
30
+ expect(result.ok).toBe(false);
31
+ if (!result.ok) expect(result.reason).toMatch(/non-empty string/);
32
+ });
33
+
34
+ it('rejects null and undefined', () => {
35
+ expect(guardUrl(null).ok).toBe(false);
36
+ expect(guardUrl(undefined).ok).toBe(false);
37
+ });
38
+
39
+ it('rejects an empty string', () => {
40
+ const result = guardUrl('');
41
+ expect(result.ok).toBe(false);
42
+ if (!result.ok) expect(result.reason).toMatch(/non-empty string/);
43
+ });
44
+
45
+ it('rejects a malformed URL', () => {
46
+ const result = guardUrl('not a url at all');
47
+ expect(result.ok).toBe(false);
48
+ if (!result.ok) expect(result.reason).toMatch(/not a valid URL/);
49
+ });
50
+ });
51
+
52
+ describe('guardUrl — scheme rules', () => {
53
+ it('allows https:// to a public host', () => {
54
+ const result = guardUrl('https://example.com/path');
55
+ expect(result.ok).toBe(true);
56
+ if (result.ok) expect(result.url).toBe('https://example.com/path');
57
+ });
58
+
59
+ it('allows http:// to a public host', () => {
60
+ const result = guardUrl('http://example.com');
61
+ expect(result.ok).toBe(true);
62
+ });
63
+
64
+ it('rejects file://', () => {
65
+ const result = guardUrl('file:///etc/passwd');
66
+ expect(result.ok).toBe(false);
67
+ if (!result.ok) expect(result.reason).toMatch(/scheme .* not allowed/);
68
+ });
69
+
70
+ it('rejects ftp://', () => {
71
+ const result = guardUrl('ftp://example.com/file.txt');
72
+ expect(result.ok).toBe(false);
73
+ if (!result.ok) expect(result.reason).toMatch(/scheme ftp/);
74
+ });
75
+
76
+ it('rejects gopher:// (Redis-SSRF vector)', () => {
77
+ const result = guardUrl('gopher://evil.example/_redis');
78
+ expect(result.ok).toBe(false);
79
+ if (!result.ok) expect(result.reason).toMatch(/scheme gopher/);
80
+ });
81
+
82
+ it('rejects data: URLs', () => {
83
+ const result = guardUrl('data:text/html,<script>alert(1)</script>');
84
+ expect(result.ok).toBe(false);
85
+ });
86
+
87
+ it('rejects javascript: URLs', () => {
88
+ const result = guardUrl('javascript:alert(1)');
89
+ expect(result.ok).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('guardUrl — private-network blocks', () => {
94
+ it('blocks localhost by name', () => {
95
+ const result = guardUrl('http://localhost:8080/admin');
96
+ expect(result.ok).toBe(false);
97
+ if (!result.ok) expect(result.reason).toMatch(/private or loopback/);
98
+ });
99
+
100
+ it('blocks 127.0.0.1 (IPv4 loopback)', () => {
101
+ expect(guardUrl('http://127.0.0.1/').ok).toBe(false);
102
+ expect(guardUrl('http://127.5.6.7/').ok).toBe(false);
103
+ });
104
+
105
+ it('blocks IPv6 loopback ::1', () => {
106
+ // URL spec writes [::1] for IPv6 literals; parser normalises to [::1].
107
+ const result = guardUrl('http://[::1]/');
108
+ expect(result.ok).toBe(false);
109
+ });
110
+
111
+ it('blocks the 10/8 private range', () => {
112
+ expect(guardUrl('http://10.0.0.1/').ok).toBe(false);
113
+ expect(guardUrl('http://10.255.255.255/').ok).toBe(false);
114
+ });
115
+
116
+ it('blocks the 192.168/16 private range', () => {
117
+ expect(guardUrl('http://192.168.1.1/').ok).toBe(false);
118
+ expect(guardUrl('http://192.168.255.255/').ok).toBe(false);
119
+ });
120
+
121
+ it('blocks the 172.16/12 private range (CIDR edge cases)', () => {
122
+ expect(guardUrl('http://172.16.0.1/').ok).toBe(false);
123
+ expect(guardUrl('http://172.20.1.1/').ok).toBe(false);
124
+ expect(guardUrl('http://172.31.255.255/').ok).toBe(false);
125
+ });
126
+
127
+ it('does NOT block addresses just outside 172.16/12', () => {
128
+ // 172.15.x.x and 172.32.x.x are public.
129
+ expect(guardUrl('http://172.15.0.1/').ok).toBe(true);
130
+ expect(guardUrl('http://172.32.0.1/').ok).toBe(true);
131
+ });
132
+
133
+ it('blocks 169.254/16 link-local (covers AWS/GCP/Azure metadata at 169.254.169.254)', () => {
134
+ const aws = guardUrl('http://169.254.169.254/latest/meta-data/');
135
+ expect(aws.ok).toBe(false);
136
+ if (!aws.ok) expect(aws.reason).toMatch(/private or loopback/);
137
+ expect(guardUrl('http://169.254.0.1/').ok).toBe(false);
138
+ });
139
+
140
+ it('blocks 0.0.0.0 / 0.x.x.x', () => {
141
+ expect(guardUrl('http://0.0.0.0/').ok).toBe(false);
142
+ expect(guardUrl('http://0.1.2.3/').ok).toBe(false);
143
+ });
144
+
145
+ it('blocks IPv6 unique-local (fcXX::)', () => {
146
+ expect(guardUrl('http://[fc00::1]/').ok).toBe(false);
147
+ expect(guardUrl('http://[fd12::1]/').ok).toBe(false);
148
+ });
149
+
150
+ it('blocks IPv6 link-local (fe80::)', () => {
151
+ expect(guardUrl('http://[fe80::1]/').ok).toBe(false);
152
+ });
153
+
154
+ // ── Bypass-vector coverage (Critic round 2, Session 839) ──
155
+ // These encodings are classic SSRF-filter evasion. They work against
156
+ // regex-only guards that look for the literal string "127.0.0.1".
157
+ // Node's WHATWG URL parser normalises all of them, so our dotted-decimal
158
+ // + bracketed-IPv6 patterns catch every form — we test here to lock it in.
159
+
160
+ it('blocks IPv6-mapped-IPv4 literals ([::ffff:127.0.0.1])', () => {
161
+ // URL parser normalises ::ffff:127.0.0.1 → ::ffff:7f00:1 (compact form).
162
+ // Our /^\[/ generic-IPv6-literal pattern catches both, regardless of
163
+ // whether anyone embedded a v4 address in it.
164
+ expect(guardUrl('http://[::ffff:127.0.0.1]/').ok).toBe(false);
165
+ expect(guardUrl('http://[::ffff:7f00:1]/').ok).toBe(false);
166
+ expect(guardUrl('http://[0:0:0:0:0:ffff:7f00:1]/').ok).toBe(false);
167
+ });
168
+
169
+ it('blocks decimal-encoded IPv4 (URL parser normalises to dotted)', () => {
170
+ // 2130706433 == 0x7F000001 == 127.0.0.1.
171
+ expect(guardUrl('http://2130706433/').ok).toBe(false);
172
+ });
173
+
174
+ it('blocks hex-encoded IPv4 (URL parser normalises to dotted)', () => {
175
+ expect(guardUrl('http://0x7f000001/').ok).toBe(false);
176
+ });
177
+
178
+ it('blocks octal-encoded IPv4 (URL parser normalises to dotted)', () => {
179
+ // 0177.0.0.1 (octal for 127) → 127.0.0.1 after parsing.
180
+ expect(guardUrl('http://0177.0.0.1/').ok).toBe(false);
181
+ });
182
+
183
+ it('blocks short-form IPv4 (http://127.1/ → 127.0.0.1)', () => {
184
+ expect(guardUrl('http://127.1/').ok).toBe(false);
185
+ });
186
+
187
+ it('blocks bare "http://0/" (URL parser expands to 0.0.0.0)', () => {
188
+ expect(guardUrl('http://0/').ok).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe('guardUrl — public hosts pass through', () => {
193
+ it('allows a typical public domain', () => {
194
+ expect(guardUrl('https://www.example.com/').ok).toBe(true);
195
+ });
196
+
197
+ it('allows a public IPv4 (8.8.8.8)', () => {
198
+ expect(guardUrl('http://8.8.8.8/').ok).toBe(true);
199
+ });
200
+
201
+ it('allows URLs with query strings and ports', () => {
202
+ const result = guardUrl('https://api.example.com:8443/v1/data?x=1&y=2');
203
+ expect(result.ok).toBe(true);
204
+ });
205
+
206
+ it('normalises URL formatting via WHATWG URL', () => {
207
+ // URL parser lowercases the scheme + host, appends default path.
208
+ const result = guardUrl('HTTPS://Example.COM');
209
+ expect(result.ok).toBe(true);
210
+ if (result.ok) expect(result.url).toBe('https://example.com/');
211
+ });
212
+ });
213
+
214
+ describe('guardUrl — MCP_VIDEO_ALLOW_INTERNAL escape hatch', () => {
215
+ it('allows localhost when MCP_VIDEO_ALLOW_INTERNAL=1', () => {
216
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
217
+ expect(guardUrl('http://localhost:3000/').ok).toBe(true);
218
+ });
219
+
220
+ it('allows RFC1918 addresses when the flag is on', () => {
221
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
222
+ expect(guardUrl('http://192.168.1.1/').ok).toBe(true);
223
+ expect(guardUrl('http://10.0.0.1/').ok).toBe(true);
224
+ });
225
+
226
+ it('still rejects non-http(s) schemes even with the flag on', () => {
227
+ // Flag opens the private-network door, NOT the scheme door.
228
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
229
+ expect(guardUrl('file:///etc/passwd').ok).toBe(false);
230
+ });
231
+
232
+ it('does NOT treat arbitrary truthy values as "on" — only the string "1"', () => {
233
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = 'true';
234
+ expect(guardUrl('http://localhost/').ok).toBe(false);
235
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = 'yes';
236
+ expect(guardUrl('http://localhost/').ok).toBe(false);
237
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = '0';
238
+ expect(guardUrl('http://localhost/').ok).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe('guardUrl — return-type narrowing', () => {
243
+ it('on success, result.url is the normalised absolute URL', () => {
244
+ const result = guardUrl('https://example.com');
245
+ expect(result.ok).toBe(true);
246
+ if (result.ok) {
247
+ // Type narrowing: result.url exists, result.reason does not.
248
+ expect(typeof result.url).toBe('string');
249
+ expect(result.url.startsWith('https://')).toBe(true);
250
+ }
251
+ });
252
+
253
+ it('on failure, result.reason is a non-empty string', () => {
254
+ const result = guardUrl('ftp://example.com');
255
+ expect(result.ok).toBe(false);
256
+ if (!result.ok) {
257
+ expect(typeof result.reason).toBe('string');
258
+ expect(result.reason.length).toBeGreaterThan(0);
259
+ }
260
+ });
261
+ });