Skip to content

Commit 652a5ca

Browse files
committed
Merge branch 'feat/vibe-files-prompt' into feat/vibe-generate-publish
2 parents 66aad2d + 1e8a7f0 commit 652a5ca

File tree

2 files changed

+218
-18
lines changed

2 files changed

+218
-18
lines changed

lib/vibe-utils.js

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,16 @@ export const verboseLog = (prefix, message, verbose) => {
327327
* Fetch with timeout support using AbortController.
328328
* Prevents fetch() from hanging indefinitely when server doesn't respond.
329329
*
330+
* SECURITY: Properly combines timeout signal with caller-provided signals
331+
* using AbortSignal.any(). This ensures external cancellation (e.g., user
332+
* aborting an in-flight request) is respected alongside the timeout.
333+
*
330334
* @param {string} url - URL to fetch from
331-
* @param {RequestInit} [init] - Fetch options (signal will be merged)
335+
* @param {RequestInit} [init] - Fetch options (signal will be merged, not overwritten)
332336
* @param {number} [timeoutMs=30000] - Timeout in milliseconds
333337
* @returns {Promise<Response>} Fetch Response object
334338
* @throws {Error} FETCH_TIMEOUT if request exceeds timeout
339+
* @throws {Error} AbortError if caller's signal triggered abort (rethrown as-is)
335340
*
336341
* @example
337342
* try {
@@ -341,30 +346,62 @@ export const verboseLog = (prefix, message, verbose) => {
341346
* console.log('Request timed out');
342347
* }
343348
* }
349+
*
350+
* @example
351+
* // With external abort signal
352+
* const controller = new AbortController();
353+
* setTimeout(() => controller.abort(), 1000); // User cancels after 1s
354+
* try {
355+
* await fetchWithTimeout("https://api.example.com/slow", { signal: controller.signal }, 30000);
356+
* } catch (err) {
357+
* if (err.name === 'AbortError') {
358+
* console.log('Request was cancelled by user');
359+
* }
360+
* }
344361
*/
345362
export const fetchWithTimeout = async (
346363
url,
347364
init = {},
348365
timeoutMs = defaultTimeoutMs,
349366
) => {
350-
const controller = new AbortController();
351-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
367+
// Create timeout-specific controller to track if timeout triggered the abort
368+
const timeoutController = new AbortController();
369+
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
370+
371+
// Combine timeout signal with any caller-provided signal
372+
// This ensures external cancellation is respected alongside timeout
373+
const signals = [timeoutController.signal];
374+
if (init.signal) {
375+
signals.push(init.signal);
376+
}
377+
const combinedSignal = AbortSignal.any(signals);
352378

353379
try {
380+
// Check if external signal is already aborted before starting fetch
381+
if (init.signal?.aborted) {
382+
throw init.signal.reason ?? new DOMException("Aborted", "AbortError");
383+
}
384+
354385
const response = await fetch(url, {
355386
...init,
356-
signal: controller.signal,
387+
signal: combinedSignal,
357388
});
358389
return response;
359390
} catch (err) {
360-
// AbortError is thrown when AbortController.abort() is called
391+
// AbortError is thrown when any signal aborts
361392
if (err.name === "AbortError") {
362-
throw createError({
363-
...FetchTimeout,
364-
message: `Request timed out after ${timeoutMs}ms`,
365-
url,
366-
timeoutMs,
367-
});
393+
// Check if OUR timeout triggered the abort
394+
if (timeoutController.signal.aborted) {
395+
throw createError({
396+
...FetchTimeout,
397+
message: `Request timed out after ${timeoutMs}ms`,
398+
url,
399+
timeoutMs,
400+
});
401+
}
402+
// External signal triggered abort - rethrow as-is for caller to handle
403+
// This preserves the original abort reason and allows proper cancellation flow
404+
throw err;
368405
}
369406
const originalCode = err && typeof err.code === "string" ? err.code : null;
370407
const retryable =
@@ -859,11 +896,15 @@ export const isPathSafe = (filePath) => {
859896
const normalizedPath = normalizePathForSecurity(filePath);
860897

861898
// Reject absolute paths (check both original and normalized)
899+
// SECURITY: Includes Windows root-relative (\path) and UNC paths (\\server\share)
900+
// Both are absolute paths that could escape the working directory
862901
if (
863902
filePath.startsWith("/") ||
864903
normalizedPath.startsWith("/") ||
865904
/^[A-Za-z]:/.test(filePath) ||
866-
/^[A-Za-z]:/.test(normalizedPath)
905+
/^[A-Za-z]:/.test(normalizedPath) ||
906+
filePath.startsWith("\\") ||
907+
normalizedPath.startsWith("\\")
867908
) {
868909
return { safe: false, reason: "Absolute paths are not allowed" };
869910
}

lib/vibe-utils.test.js

Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,19 +306,24 @@ describe("fetchWithTimeout", () => {
306306
});
307307

308308
test("throws FETCH_TIMEOUT on abort", async () => {
309-
// Simulate a request that gets aborted due to timeout
309+
// Simulate a slow request that will be aborted by our timeout
310+
// The mock listens to the signal and rejects when aborted
310311
mockFetch.mockImplementationOnce(
311-
() =>
312+
(url, options) =>
312313
new Promise((_, reject) => {
313-
// Immediately simulate abort behavior
314-
const error = new Error("Aborted");
315-
error.name = "AbortError";
316-
reject(error);
314+
// When the signal aborts (from our timeout), reject with AbortError
315+
options.signal.addEventListener("abort", () => {
316+
const error = new Error("Aborted");
317+
error.name = "AbortError";
318+
reject(error);
319+
});
320+
// This promise would never resolve on its own - simulates slow server
317321
}),
318322
);
319323

320324
let error;
321325
try {
326+
// Short timeout so test runs fast
322327
await fetchWithTimeout("https://api.test.com/slow", {}, 50);
323328
} catch (e) {
324329
error = e;
@@ -340,6 +345,91 @@ describe("fetchWithTimeout", () => {
340345
});
341346
});
342347

348+
test("respects external AbortSignal for cancellation", async () => {
349+
// Create an external controller that will abort
350+
const externalController = new AbortController();
351+
352+
// Mock fetch to simulate a pending request that gets aborted externally
353+
mockFetch.mockImplementationOnce(
354+
(url, options) =>
355+
new Promise((_, reject) => {
356+
// Listen for abort on the combined signal
357+
options.signal.addEventListener("abort", () => {
358+
const error = new Error("Aborted");
359+
error.name = "AbortError";
360+
reject(error);
361+
});
362+
}),
363+
);
364+
365+
// Start the fetch, then abort via external signal
366+
const fetchPromise = fetchWithTimeout(
367+
"https://api.test.com/slow",
368+
{ signal: externalController.signal },
369+
30000, // Long timeout so it won't timeout first
370+
);
371+
372+
// Abort via external signal
373+
externalController.abort();
374+
375+
let error;
376+
try {
377+
await fetchPromise;
378+
} catch (e) {
379+
error = e;
380+
}
381+
382+
// External abort should NOT be wrapped as FETCH_TIMEOUT
383+
// It should be rethrown as-is (AbortError)
384+
assert({
385+
given: "external signal aborts request",
386+
should: "throw AbortError (not FETCH_TIMEOUT)",
387+
actual: error?.name,
388+
expected: "AbortError",
389+
});
390+
391+
// Should NOT have FETCH_TIMEOUT code
392+
assert({
393+
given: "external signal aborts request",
394+
should: "not be classified as timeout",
395+
actual: error?.cause?.code !== "FETCH_TIMEOUT",
396+
expected: true,
397+
});
398+
});
399+
400+
test("rejects immediately if external signal already aborted", async () => {
401+
// Create already-aborted signal
402+
const abortedController = new AbortController();
403+
abortedController.abort();
404+
405+
let error;
406+
try {
407+
await fetchWithTimeout(
408+
"https://api.test.com/data",
409+
{ signal: abortedController.signal },
410+
5000,
411+
);
412+
} catch (e) {
413+
error = e;
414+
}
415+
416+
// Should throw immediately without calling fetch
417+
assert({
418+
given: "already-aborted signal",
419+
should: "throw AbortError immediately",
420+
actual: error?.name,
421+
expected: "AbortError",
422+
});
423+
424+
// Fetch should not have been called
425+
assert({
426+
given: "already-aborted signal",
427+
should: "not call fetch",
428+
actual: mockFetch.mock.calls.length,
429+
expected: 0,
430+
});
431+
});
432+
343433
test("passes through non-timeout errors with wrapping", async () => {
344434
const networkError = new Error("Network failure");
345435
networkError.code = "ECONNREFUSED";
@@ -879,6 +969,75 @@ describe("isPathSafe", () => {
879969
expected: true,
880970
});
881971
});
972+
973+
test("rejects UNC paths", () => {
974+
const result = isPathSafe("\\\\server\\share\\file.txt");
975+
976+
assert({
977+
given: "UNC path (Windows network path)",
978+
should: "return safe false",
979+
actual: result.safe,
980+
expected: false,
981+
});
982+
983+
assert({
984+
given: "UNC path rejection",
985+
should: "give absolute path reason",
986+
actual: result.reason,
987+
expected: "Absolute paths are not allowed",
988+
});
989+
});
990+
991+
test("rejects Windows root-relative paths (single backslash)", () => {
992+
// \Windows\System32 is root-relative on Windows (resolves to current drive root)
993+
// This is an absolute path that should be rejected
994+
const result = isPathSafe("\\Windows\\System32");
995+
996+
assert({
997+
given: "Windows root-relative path (single backslash)",
998+
should: "return safe false",
999+
actual: result.safe,
1000+
expected: false,
1001+
});
1002+
1003+
assert({
1004+
given: "root-relative path rejection",
1005+
should: "give absolute path reason",
1006+
actual: result.reason,
1007+
expected: "Absolute paths are not allowed",
1008+
});
1009+
});
1010+
1011+
test("rejects paths starting with single backslash", () => {
1012+
// Various single-backslash absolute path patterns
1013+
const testCases = [
1014+
"\\temp\\file.txt", // \temp\file.txt
1015+
"\\Users\\data.json", // \Users\data.json
1016+
"\\", // Just root
1017+
];
1018+
1019+
for (const testPath of testCases) {
1020+
const result = isPathSafe(testPath);
1021+
assert({
1022+
given: `path starting with backslash: ${testPath}`,
1023+
should: "return safe false",
1024+
actual: result.safe,
1025+
expected: false,
1026+
});
1027+
}
1028+
});
1029+
1030+
test("rejects UNC paths with extended prefix", () => {
1031+
// \\?\C:\ is Windows extended-length path prefix
1032+
const result = isPathSafe("\\\\?\\C:\\Windows\\System32");
1033+
1034+
assert({
1035+
given: "UNC extended-length path",
1036+
should: "return safe false",
1037+
actual: result.safe,
1038+
expected: false,
1039+
});
1040+
});
8821041
});
8831042

8841043
// =============================================================================

0 commit comments

Comments
 (0)