fix(download): harden HF download path validation, fix WebSocket leak, add URL detection tests (#965, #977)

Security hardening:
- Validate repo format with strict regex (reject .. traversal)
- Validate filename rejects path separators and ..
- Validate relative_path rejects absolute paths and ..
- Verify model_root is within configured scanner roots using
  realpath + os.sep guard to prevent prefix-match bypass
- Add realpath-based escape detection for final dest_path

Bug fixes:
- Fix WebSocket leak in _downloadHfSingle: wrap ws.close() in
  try/finally so it closes even if downloadHfModel() throws
- Same fix for batch HF download per-file WebSocket loop

Frontend hardening:
- Tighten HF repo regex: require huggingface.co for full URLs,
  reject bare .. patterns
- Add 12 unit tests for detectUrlType() covering HF resolve,
  HF repo, CivitAI, CivArchive, direct HTTP, edge cases
This commit is contained in:
Will Miao
2026-07-01 05:51:58 +08:00
parent 7cf785b72f
commit 8348a0cef8
3 changed files with 233 additions and 66 deletions

View File

@@ -481,8 +481,18 @@ export class DownloadManager {
}
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
const hfRepoMatch = trimmed.match(/(?:https?:\/\/huggingface\.co\/)?([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/);
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
const hfRepoMatch = trimmed.match(
trimmed.includes('://')
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
);
if (hfRepoMatch) {
// Reject path-traversal patterns like "../.." or "user/.."
const parts = hfRepoMatch[1].split('/');
if (parts.some(p => p === '.' || p === '..')) {
return null;
}
return {
type: 'hf-repo',
repo: hfRepoMatch[1],
@@ -987,42 +997,44 @@ export class DownloadManager {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
try {
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Capture completed count at WS creation time so progress
// updates arriving after completedDownloads increments still
// show the correct "N / total" position.
const snapshotCompleted = completedDownloads;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, filename, metrics);
// Capture completed count at WS creation time so progress
// updates arriving after completedDownloads increments still
// show the correct "N / total" position.
const snapshotCompleted = completedDownloads;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, filename, metrics);
}
};
const response = await this.apiClient.downloadHfModel({
repo: this.hfRepoId,
filename,
revision: 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
}
};
const response = await this.apiClient.downloadHfModel({
repo: this.hfRepoId,
filename,
revision: 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
ws.close();
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
} finally {
ws.close();
}
}
@@ -1401,33 +1413,36 @@ export class DownloadManager {
// Per-file WebSocket for real-time progress
const downloadId = Date.now().toString() + '_hf_' + i;
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
await new Promise((resolve, reject) => {
wsHf.onopen = resolve;
wsHf.onerror = reject;
});
const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, name, metrics);
}
};
try {
await new Promise((resolve, reject) => {
wsHf.onopen = resolve;
wsHf.onerror = reject;
});
const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second,
};
updateProgress(data.progress, snapshotCompleted, name, metrics);
}
};
response = await this.apiClient.downloadHfModel({
repo: item.repo,
filename: item.filename,
revision: item.revision || 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
wsHf.close();
response = await this.apiClient.downloadHfModel({
repo: item.repo,
filename: item.filename,
revision: item.revision || 'main',
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
});
} finally {
wsHf.close();
}
} else {
response = await this.apiClient.downloadModel(
item.modelId,