Skip to content

Commit 113d05c

Browse files
thiyaguk09pearigee
andauthored
feat(storage): Implement robust path validation and structured skip reporting (#7546)
* feat: implement secure path validation for downloadManyFiles - Adds protection against path traversal (../) using normalized path resolution. - Prevents Windows-style drive letter injection while allowing GCS timestamps. - Implements directory jail logic to ensure absolute-style paths are relative to destination. - Preserves backward compatibility by returning an augmented DownloadResponse array. - Automates recursive directory creation for validated nested files. - Adds comprehensive 13-scenario test suite for edge-case parity. * fix double-assignment * feat(storage): secure path resolution and preserve result parity - Implemented "jail" logic using path.resolve to prevent traversal. - Neutralized leading slashes in GCS object names. - Pre-allocated results array to maintain 1:1 input/output index parity. - Added automatic recursive directory creation for nested local paths. - Fixed prioritization of destination options in downloadManyFiles. * feat(storage): prioritize drive check for Windows compatibility - Reordered security checks to catch illegal drive letters before path resolution. - Fixed SkipReason mismatch (Illegal Character vs Path Traversal) on Windows. - Ensured absolute Windows paths are neutralized before traversal validation. * fix(storage): storage downloadManyFiles scoping and path traversal safety Isolated async loop variables to fix path leakage, decoupled prefix from destination logic, and added cross-platform traversal checks for both forward and backslashes. * fix(storage): ensure unique path resolution and cross-platform safety Updated Parity Check tests with platform-conditional logic to handle OS-specific backslash resolution. * fix(storage): address logical escapes in stripPrefix * fix: clarify downloadManyFiles docs * build(storage): suppress punycode deprecation errors in tests Node.js 22+ treats the deprecation of the built-in 'punycode' module as a fatal error during the test load phase. Since this originates deep within nested dependencies (node-fetch > whatwg-url), this change adds --no-deprecation to NODE_OPTIONS in the test script to allow the suite to run on modern Node versions. * build(storage): fix CI test detection and Node 22 compatibility - Added 'cross-env' to devDependencies for Windows compatibility. - Used 'cross-env' to pass '--no-deprecation' to the test runner. - This prevents 'punycode' deprecation warnings from crashing the suite. --------- Co-authored-by: Gabe Pearhill <86282859+pearigee@users.noreply.github.com>
1 parent 75c76e1 commit 113d05c

4 files changed

Lines changed: 356 additions & 37 deletions

File tree

handwritten/storage/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"samples-test": "npm link && cd samples/ && npm link ../ && npm test && cd ../",
7474
"system-test:esm": "mocha build/esm/system-test --timeout 600000 --exit",
7575
"system-test": "mocha build/cjs/system-test --timeout 600000 --exit",
76-
"test": "c8 mocha build/cjs/test"
76+
"test": "cross-env NODE_OPTIONS='--no-deprecation' c8 mocha build/cjs/test"
7777
},
7878
"dependencies": {
7979
"@google-cloud/paginator": "^5.0.0",
@@ -130,7 +130,8 @@
130130
"path-to-regexp": "6.3.0",
131131
"tmp": "^0.2.0",
132132
"typescript": "^5.1.6",
133-
"yargs": "^17.3.1"
133+
"yargs": "^17.3.1",
134+
"cross-env": "^7.0.3"
134135
},
135136
"homepage": "https://github.com/googleapis/google-cloud-node/tree/main/handwritten/storage"
136137
}

handwritten/storage/src/file.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,22 @@ export interface CopyCallback {
396396

397397
export type DownloadResponse = [Buffer];
398398

399+
export type DownloadResponseWithStatus = [Buffer] & {
400+
skipped?: boolean;
401+
reason?: SkipReason;
402+
fileName?: string;
403+
localPath?: string;
404+
message?: string;
405+
error?: Error;
406+
};
407+
408+
export enum SkipReason {
409+
PATH_TRAVERSAL = 'PATH_TRAVERSAL',
410+
ILLEGAL_CHARACTER = 'ILLEGAL_CHARACTER',
411+
ALREADY_EXISTS = 'ALREADY_EXISTS',
412+
DOWNLOAD_ERROR = 'DOWNLOAD_ERROR',
413+
}
414+
399415
export type DownloadCallback = (
400416
err: RequestError | null,
401417
contents: Buffer,

handwritten/storage/src/transfer-manager.ts

Lines changed: 123 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import {Bucket, UploadOptions, UploadResponse} from './bucket.js';
1818
import {
1919
DownloadOptions,
2020
DownloadResponse,
21+
DownloadResponseWithStatus,
2122
File,
2223
FileExceptionMessages,
2324
RequestError,
25+
SkipReason,
2426
} from './file.js';
2527
import pLimit from 'p-limit';
2628
import * as path from 'path';
@@ -540,6 +542,34 @@ export class TransferManager {
540542
* instead of being returned as a buffer.
541543
* @returns {Promise<DownloadResponse[]>}
542544
*
545+
* @behavior
546+
* **Return shape change (breaking/observable behavior):**
547+
* - Previously, the returned array only contained entries for files that were successfully downloaded.
548+
* - This meant the response length could be smaller than the number of requested input files.
549+
* - Now, the returned array always has the same length and ordering as the input file list.
550+
* - Each index in the response corresponds directly to the same index in the input.
551+
* - Files that are skipped or fail will still have an entry in the result with:
552+
* - `skipped = true`
553+
* - `reason` populated with a {@link SkipReason}
554+
*
555+
* **New guarantees:**
556+
* - Response length === number of requested files
557+
* - Stable positional mapping between input and output
558+
* - All outcomes (success, skipped, error) are explicitly represented
559+
*
560+
* @security
561+
* **Path traversal protection (new):**
562+
* - File paths are resolved relative to the configured destination directory.
563+
* - Any file whose resolved path escapes the base destination directory is rejected.
564+
* - This prevents directory traversal attacks (e.g. `../../etc/passwd`).
565+
* - Such files are not downloaded and instead return:
566+
* - `skipped = true`
567+
* - `reason = SkipReason.PATH_TRAVERSAL`
568+
*
569+
* **Additional validation:**
570+
* - File names containing illegal drive prefixes (e.g. `C:\`) are skipped
571+
* to prevent unintended writes on host systems.
572+
*
543573
* @example
544574
* ```
545575
* const {Storage} = require('@google-cloud/storage');
@@ -554,12 +584,15 @@ export class TransferManager {
554584
* // The following files have been downloaded:
555585
* // - "file1.txt" (with the contents from my-bucket.file1.txt)
556586
* // - "file2.txt" (with the contents from my-bucket.file2.txt)
587+
* // response.length === 2 (always matches input length)
588+
* // Each entry corresponds to the respective input file
557589
* const response = await transferManager.downloadManyFiles([bucket.File('file1.txt'), bucket.File('file2.txt')]);
558590
* // The following files have been downloaded:
559591
* // - "file1.txt" (with the contents from my-bucket.file1.txt)
560592
* // - "file2.txt" (with the contents from my-bucket.file2.txt)
561593
* const response = await transferManager.downloadManyFiles('test-folder');
562-
* // All files with GCS prefix of 'test-folder' have been downloaded.
594+
* // All files with GCS prefix of 'test-folder' have been processed.
595+
* // Skipped or failed files are still included in the response.
563596
* ```
564597
*
565598
*/
@@ -570,9 +603,13 @@ export class TransferManager {
570603
const limit = pLimit(
571604
options.concurrencyLimit || DEFAULT_PARALLEL_DOWNLOAD_LIMIT,
572605
);
573-
const promises: Promise<DownloadResponse>[] = [];
606+
const promises: Promise<void>[] = [];
574607
let files: File[] = [];
575608

609+
const baseDestination = path.resolve(
610+
options.passthroughOptions?.destination || '.'
611+
);
612+
576613
if (!Array.isArray(filesOrFolder)) {
577614
const directoryFiles = await this.bucket.getFiles({
578615
prefix: filesOrFolder,
@@ -592,45 +629,102 @@ export class TransferManager {
592629
: EMPTY_REGEX;
593630
const regex = new RegExp(stripRegexString, 'g');
594631

595-
for (const file of files) {
596-
const passThroughOptionsCopy = {
597-
...options.passthroughOptions,
598-
[GCCL_GCS_CMD_KEY]: GCCL_GCS_CMD_FEATURE.DOWNLOAD_MANY,
599-
};
632+
const finalResults: DownloadResponseWithStatus[] = new Array(files.length);
600633

601-
if (options.prefix || passThroughOptionsCopy.destination) {
602-
passThroughOptionsCopy.destination = path.join(
603-
options.prefix || '',
604-
passThroughOptionsCopy.destination || '',
605-
file.name,
606-
);
634+
for (let i = 0; i < files.length; i++) {
635+
const file = files[i];
636+
637+
const hasIllegalDrive = /^[a-zA-Z]:/.test(file.name);
638+
if (hasIllegalDrive) {
639+
const skippedResult = [Buffer.alloc(0)] as DownloadResponseWithStatus;
640+
skippedResult.skipped = true;
641+
skippedResult.reason = SkipReason.ILLEGAL_CHARACTER;
642+
skippedResult.fileName = file.name;
643+
finalResults[i] = skippedResult;
644+
continue;
607645
}
608-
if (options.stripPrefix) {
609-
passThroughOptionsCopy.destination = file.name.replace(regex, '');
646+
647+
const fileName = options.stripPrefix
648+
? file.name.replace(regex, '')
649+
: file.name;
650+
651+
const dest = fileName.replace(/^[\\/]+/, '');
652+
653+
const resolvedPath = path.resolve(baseDestination, dest);
654+
const relativeFromBase = path.relative(baseDestination, resolvedPath);
655+
656+
const isOutside =
657+
path.isAbsolute(relativeFromBase) ||
658+
relativeFromBase.split(/[\\/]/).includes('..');
659+
660+
if (isOutside) {
661+
const skippedResult = [Buffer.alloc(0)] as DownloadResponseWithStatus;
662+
skippedResult.skipped = true;
663+
skippedResult.reason = SkipReason.PATH_TRAVERSAL;
664+
skippedResult.fileName = file.name;
665+
skippedResult.localPath = resolvedPath;
666+
finalResults[i] = skippedResult;
667+
continue;
610668
}
611-
if (
612-
options.skipIfExists &&
613-
existsSync(passThroughOptionsCopy.destination || '')
614-
) {
669+
670+
if (options.skipIfExists && existsSync(resolvedPath)) {
671+
const skippedResult = [Buffer.alloc(0)] as DownloadResponseWithStatus;
672+
skippedResult.skipped = true;
673+
skippedResult.reason = SkipReason.ALREADY_EXISTS;
674+
skippedResult.fileName = file.name;
675+
skippedResult.localPath = resolvedPath;
676+
finalResults[i] = skippedResult;
615677
continue;
616678
}
617679

618680
promises.push(
619681
limit(async () => {
620-
const destination = passThroughOptionsCopy.destination;
621-
if (destination && destination.endsWith(path.sep)) {
622-
await fsp.mkdir(destination, {recursive: true});
623-
return Promise.resolve([
624-
Buffer.alloc(0),
625-
]) as Promise<DownloadResponse>;
682+
const passThroughOptionsCopy = {
683+
...options.passthroughOptions,
684+
destination: resolvedPath,
685+
[GCCL_GCS_CMD_KEY]: GCCL_GCS_CMD_FEATURE.DOWNLOAD_MANY,
686+
};
687+
688+
try {
689+
const destination = passThroughOptionsCopy.destination!;
690+
691+
if (destination.endsWith(path.sep) || destination.endsWith('/')) {
692+
await fsp.mkdir(destination, {recursive: true});
693+
const dirResp = [Buffer.alloc(0)] as DownloadResponseWithStatus;
694+
dirResp.skipped = false;
695+
dirResp.fileName = file.name;
696+
dirResp.localPath = destination;
697+
finalResults[i] = dirResp;
698+
return;
699+
}
700+
701+
await fsp.mkdir(path.dirname(destination), {recursive: true});
702+
703+
const resp = (await file.download(
704+
passThroughOptionsCopy
705+
)) as DownloadResponseWithStatus;
706+
707+
finalResults[i] = {
708+
...resp,
709+
skipped: false,
710+
fileName: file.name,
711+
localPath: destination,
712+
} as DownloadResponse;
713+
} catch (err) {
714+
const errorResp = [Buffer.alloc(0)] as DownloadResponseWithStatus;
715+
errorResp.skipped = true;
716+
errorResp.reason = SkipReason.DOWNLOAD_ERROR;
717+
errorResp.fileName = file.name;
718+
errorResp.localPath = resolvedPath;
719+
errorResp.error = err as Error;
720+
finalResults[i] = errorResp;
626721
}
627-
628-
return file.download(passThroughOptionsCopy);
629-
}),
722+
})
630723
);
631724
}
632725

633-
return Promise.all(promises);
726+
await Promise.all(promises);
727+
return finalResults;
634728
}
635729

636730
/**

0 commit comments

Comments
 (0)