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
6 changes: 3 additions & 3 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export function videoGenerateV2Endpoint(baseUrl: string): string {
}

export function videoTaskEndpoint(baseUrl: string, taskId: string): string {
return `${baseUrl}/v1/query/video_generation?task_id=${taskId}`;
return `${baseUrl}/v1/query/video_generation?task_id=${encodeURIComponent(taskId)}`;
}

export function videoTaskV2Endpoint(baseUrl: string, taskId: string): string {
return `${baseUrl}/v2/query/video_generation/${taskId}`;
return `${baseUrl}/v2/query/video_generation/${encodeURIComponent(taskId)}`;
}

export function fileRetrieveEndpoint(baseUrl: string, fileId: string): string {
return `${baseUrl}/v1/files/retrieve?file_id=${fileId}`;
return `${baseUrl}/v1/files/retrieve?file_id=${encodeURIComponent(fileId)}`;
}

export function searchEndpoint(baseUrl: string): string {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { writeFileSync } from 'fs';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { T2A_FORMATS, formatList, validateAudioFormat, validateT2AStreaming, t2aDefaultSampleRate } from '../../utils/audio-formats';
import { pipeAudioStream } from '../../utils/audio-stream';
import { safeFetch } from '../../utils/network';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { SpeechRequest, SpeechResponse } from '../../types/api';
Expand Down Expand Up @@ -126,8 +127,7 @@ export default defineCommand({
// Download and save subtitle file when --subtitles is requested
if (flags.subtitles && response.data.subtitle_file) {
try {
// Download the subtitle JSON file from the URL
const subtitleRes = await fetch(response.data.subtitle_file);
const subtitleRes = await safeFetch(response.data.subtitle_file, {}, { timeoutMs: 30000 });
if (!subtitleRes.ok) {
throw new CLIError(`Failed to download subtitle file: ${subtitleRes.status}`, ExitCode.GENERAL);
}
Expand Down
27 changes: 22 additions & 5 deletions src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,15 @@ export default defineCommand({
);
}

const isLocal = Boolean(config.baseUrl && (config.baseUrl.includes('localhost') || config.baseUrl.includes('127.0.0.1')));

// --download: save to file
if (flags.download) {
const destPath = flags.download as string;
const { size } = await downloadFile(downloadUrl, destPath, { quiet: config.quiet });
const { size } = await downloadFile(downloadUrl, destPath, {
quiet: config.quiet,
allowPrivate: isLocal,
});

if (config.quiet) {
console.log(destPath);
Expand All @@ -318,14 +323,26 @@ export default defineCommand({
}

// Default: auto-download to temp location and output local file path
// Guard against path traversal via malicious or spoofed taskId from API
if (!taskId || typeof taskId !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(taskId)) {
throw new CLIError(`Unsafe or invalid task ID received from API: "${taskId}"`, ExitCode.GENERAL);
}

const os = await import('os');
const { join } = await import('path');
const destDir = join(os.tmpdir(), 'mmx-video');
const { resolve } = await import('path');
const destDir = resolve(os.tmpdir(), 'mmx-video');
const { existsSync, mkdirSync } = await import('fs');
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
const destPath = join(destDir, `${taskId}.mp4`);
const destPath = resolve(destDir, `${taskId}.mp4`);

await downloadFile(downloadUrl, destPath, { quiet: config.quiet });
if (!destPath.startsWith(destDir)) {
throw new CLIError(`Task ID "${taskId}" would escape destination directory.`, ExitCode.GENERAL);
}

await downloadFile(downloadUrl, destPath, {
quiet: config.quiet,
allowPrivate: isLocal,
});

process.stdout.write(destPath);
process.stdout.write('\n');
Expand Down
13 changes: 10 additions & 3 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
import { randomBytes } from 'crypto';
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema';
import { ensureConfigDir, getConfigPath } from './paths';
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
Expand Down Expand Up @@ -56,9 +57,15 @@ export async function writeConfigFile(
): Promise<void> {
await ensureConfigDir();
const path = getConfigPath();
const tmp = path + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
renameWithCrossDeviceFallback(tmp, path, renameOps);
const uniqueSuffix = randomBytes(8).toString('hex');
const tmp = `${path}.${process.pid}.${Date.now()}.${uniqueSuffix}.tmp`;
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
try {
renameWithCrossDeviceFallback(tmp, path, renameOps);
} catch (err) {
try { unlinkSync(tmp); } catch { /* best effort */ }
throw err;
}
}

export function loadConfig(flags: GlobalFlags): Config {
Expand Down
30 changes: 25 additions & 5 deletions src/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WriteStream } from 'fs';
import { createProgressBar } from '../output/progress';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { validateSafeUrl, ssrfGuardedAgent } from '../utils/network';

const DEFAULT_OVERALL_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 1000;
Expand All @@ -17,6 +18,7 @@ export interface DownloadOpts {
idleTimeoutMs?: number;
maxBytes?: number;
signal?: AbortSignal;
allowPrivate?: boolean;
}

class RetryableDownloadError extends CLIError {
Expand Down Expand Up @@ -244,7 +246,7 @@ async function attemptDownload(
downloadUrl: string,
destPath: string,
attempt: number,
opts: Required<Pick<DownloadOpts, 'quiet' | 'idleTimeoutMs' | 'maxBytes'>>,
opts: Required<Pick<DownloadOpts, 'quiet' | 'idleTimeoutMs' | 'maxBytes'>> & { allowPrivate?: boolean },
overallSignal: AbortSignal,
): Promise<{ size: number }> {
const controller = new AbortController();
Expand All @@ -261,16 +263,33 @@ async function attemptDownload(

try {
try {
const fetchOpts: RequestInit & { dispatcher?: typeof ssrfGuardedAgent } = {
signal: controller.signal,
redirect: 'error',
};
if (!opts.allowPrivate && typeof process !== 'undefined') {
fetchOpts.dispatcher = ssrfGuardedAgent;
}
response = await withIdleTimeout(
fetch(downloadUrl, { signal: controller.signal }),
fetch(downloadUrl, fetchOpts as RequestInit),
controller,
opts.idleTimeoutMs,
'waiting for response headers',
);
} catch (error) {
if (error instanceof CLIError) throw error;
const msg = error instanceof Error ? error.message : String(error);
if (
msg.toLowerCase().includes('redirect') ||
msg.toLowerCase().includes('uri requested responds with a redirect')
) {
throw new CLIError(
`Redirects are disallowed for download URL "${downloadUrl}" (SSRF protection).`,
ExitCode.USAGE,
);
}
throw new RetryableDownloadError(
`Download request failed: ${error instanceof Error ? error.message : String(error)}`,
`Download request failed: ${msg}`,
);
}

Expand All @@ -296,7 +315,7 @@ async function attemptDownload(
? undefined
: declaredLength;
tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}-${attempt}-${Math.random().toString(36).slice(2)}`;
writer = createWriteStream(tmpPath);
writer = createWriteStream(tmpPath, { flags: 'wx', mode: 0o600 });
progress = expectedLength && !opts.quiet
? createProgressBar(expectedLength, 'Downloading')
: null;
Expand Down Expand Up @@ -394,6 +413,7 @@ export async function downloadFile(
): Promise<{ size: number }> {
// Alibaba Cloud OSS US East blocks HTTP from certain regions.
const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url;
await validateSafeUrl(downloadUrl, { allowPrivate: opts?.allowPrivate });
const maxRetries = nonNegativeInteger(opts?.retries ?? 3, 'retries');
const baseDelay = nonNegativeNumber(opts?.retryDelayMs ?? 1000, 'retryDelayMs');
const overallTimeoutMs = positiveNumber(
Expand Down Expand Up @@ -440,7 +460,7 @@ export async function downloadFile(
downloadUrl,
destPath,
attempt,
{ quiet, idleTimeoutMs, maxBytes },
{ quiet, idleTimeoutMs, maxBytes, allowPrivate: opts?.allowPrivate },
overallController.signal,
);
} catch (error) {
Expand Down
74 changes: 41 additions & 33 deletions src/update/self-update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createWriteStream, renameSync, chmodSync, existsSync } from 'fs';
import { createWriteStream, renameSync, chmodSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { CLIError } from '../errors/base';
Expand Down Expand Up @@ -110,7 +110,7 @@ export async function downloadFile(url: string, dest: string, onProgress?: (pct:
const total = Number(res.headers.get('content-length') ?? 0);
let received = 0;

const writer = createWriteStream(dest);
const writer = createWriteStream(dest, { flags: 'wx', mode: 0o700 });
const reader = res.body!.getReader();

try {
Expand Down Expand Up @@ -150,7 +150,10 @@ export async function downloadFile(url: string, dest: string, onProgress?: (pct:
});
}
// Don't leave a half-downloaded binary in /tmp on failure.
try { (await import('fs')).unlinkSync(dest); } catch { /* best-effort — race with concurrent cleanup is fine */ }
// If the error was EEXIST, dest already existed and was not created by this download.
if ((err as { code?: string })?.code !== 'EEXIST') {
try { (await import('fs')).unlinkSync(dest); } catch { /* best-effort — race with concurrent cleanup is fine */ }
}
throw err;
} finally {
// Always release the Web Streams reader lock — the API contract requires
Expand All @@ -177,40 +180,45 @@ export async function resolveUpdateTarget(channel: Channel): Promise<UpdateTarge
}

export async function applySelfUpdate(target: UpdateTarget, currentBin: string): Promise<void> {
const tmp = join(tmpdir(), `mmx-update-${Date.now()}`);

process.stderr.write(`Downloading ${target.version}...\n`);
let lastPct = -1;
await downloadFile(target.downloadUrl, tmp, (pct) => {
if (pct !== lastPct && pct % 10 === 0) {
process.stderr.write(` ${pct}%\r`);
lastPct = pct;
}
});
process.stderr.write(' \r');
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-update-'));
const tmp = join(tempDir, 'mmx-bin');

process.stderr.write('Verifying checksum...\n');
await verifySha256(tmp, target.checksum);
try {
process.stderr.write(`Downloading ${target.version}...\n`);
let lastPct = -1;
await downloadFile(target.downloadUrl, tmp, (pct) => {
if (pct !== lastPct && pct % 10 === 0) {
process.stderr.write(` ${pct}%\r`);
lastPct = pct;
}
});
process.stderr.write(' \r');

chmodSync(tmp, 0o755);
process.stderr.write('Verifying checksum...\n');
await verifySha256(tmp, target.checksum);

// Atomic replace: rename works on same filesystem
// If cross-device, fall back to copy+rename
try {
renameSync(tmp, currentBin);
} catch {
const { copyFileSync, unlinkSync } = await import('fs');
const backup = `${currentBin}.bak`;
copyFileSync(currentBin, backup);
chmodSync(tmp, 0o755);

// Atomic replace: rename works on same filesystem
// If cross-device, fall back to copy+rename
try {
copyFileSync(tmp, currentBin);
chmodSync(currentBin, 0o755);
unlinkSync(tmp);
if (existsSync(backup)) unlinkSync(backup);
} catch (e) {
// Restore backup
if (existsSync(backup)) renameSync(backup, currentBin);
throw e;
renameSync(tmp, currentBin);
} catch {
const { copyFileSync, unlinkSync } = await import('fs');
const backup = `${currentBin}.bak`;
copyFileSync(currentBin, backup);
try {
copyFileSync(tmp, currentBin);
chmodSync(currentBin, 0o755);
unlinkSync(tmp);
if (existsSync(backup)) unlinkSync(backup);
} catch (e) {
// Restore backup
if (existsSync(backup)) renameSync(backup, currentBin);
throw e;
}
}
} finally {
try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
}
}
42 changes: 35 additions & 7 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync, existsSync, statSync } from 'fs';
import { extname } from 'path';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { safeFetch } from './network';

export const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
Expand Down Expand Up @@ -41,18 +42,45 @@ export async function toDataUri(image: string): Promise<string> {
if (image.startsWith('data:')) return image;

if (image.startsWith('http://') || image.startsWith('https://')) {
const res = await fetch(image);
const res = await safeFetch(image, {}, { timeoutMs: 30000 });
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {

const declaredLength = res.headers.get('content-length');
if (declaredLength && Number(declaredLength) > MAX_IMAGE_SIZE_BYTES) {
throw new CLIError(
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
`Image too large (${(Number(declaredLength) / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
}
return `data:${mime};base64,${Buffer.from(buf).toString('base64')}`;

const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();

const reader = res.body?.getReader();
if (!reader) throw new CLIError('Failed to read image response body.', ExitCode.GENERAL);

const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_IMAGE_SIZE_BYTES) {
await reader.cancel();
throw new CLIError(
`Image too large (${(totalBytes / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}

const buf = Buffer.concat(chunks);
return `data:${mime};base64,${buf.toString('base64')}`;
}

if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE);
Expand Down
Loading