1
mirror of https://github.com/jakejarvis/careful-downloader.git synced 2025-09-18 13:45:32 -04:00

Stricter paths to prevent traversal & remove custom tempDir option

This commit is contained in:
2021-10-07 11:07:31 -04:00
parent 211ccd401d
commit 8e89d84eda
5 changed files with 45 additions and 47 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules/ node_modules/
test/temp/

View File

@@ -72,19 +72,12 @@ Default: `false`
Use [`decompress`](https://github.com/kevva/decompress) to extract the final download to the destination directory (assuming it's a `.zip`, `.tar`, `.tar.gz`, etc.). Use [`decompress`](https://github.com/kevva/decompress) to extract the final download to the destination directory (assuming it's a `.zip`, `.tar`, `.tar.gz`, etc.).
##### tempDir
Type: `string`\
Default: [`tempy.directory()`](https://github.com/sindresorhus/tempy#tempydirectoryoptions)
Path to temporary directory for unverified and/or unextracted downloads. Automatically generated if not set (recommended). If set manually, the directory isn't purged upon finishing for security reasons.
##### destDir ##### destDir
Type: `string`\ Type: `string`\
Default: `"./downloads"` Default: `"./downloads"`
Full path or directory name relative to module to store the validated download. Directory path relative to module where the validated download is saved or extracted. **Must be located within `process.cwd()` for security reasons.**
##### cleanDestDir ##### cleanDestDir

12
index.d.ts vendored
View File

@@ -16,16 +16,8 @@ export interface Options {
readonly extract?: boolean; readonly extract?: boolean;
/** /**
* Path to temporary directory for unverified and/or unextracted downloads. * Directory path relative to module where the validated download is saved or
* Automatically generated if not set (recommended). * extracted. Must be located within `process.cwd()` for security reasons.
*
* @default `tempy.directory()`
*/
readonly tempDir?: string;
/**
* Full path or directory name relative to module to store the validated
* download.
* *
* @default "./downloads" * @default "./downloads"
*/ */

View File

@@ -12,34 +12,35 @@ export default async function downloader(downloadUrl, checksumUrl, options) {
// intialize options if none are set // intialize options if none are set
options = options || {}; options = options || {};
// don't delete the temp dir if set manually and dir exists
let deleteTempDir = true;
if (options.tempDir && fs.pathExistsSync(options.tempDir)) {
deleteTempDir = false;
}
// normalize options and set defaults // normalize options and set defaults
options = { options = {
filename: options.filename || urlParse(downloadUrl).pathname.split("/").pop(), filename: options.filename || urlParse(downloadUrl).pathname.split("/").pop(),
extract: !!options.extract, extract: !!options.extract,
tempDir: options.tempDir ? path.resolve(process.cwd(), options.tempDir) : tempy.directory(), destDir: options.destDir ? path.resolve(process.cwd(), options.destDir) : path.resolve(process.cwd(), "downloads"),
destDir: options.destDir ? path.resolve(process.cwd(), options.destDir) : path.resolve(process.cwd(), "download"),
cleanDestDir: !!options.cleanDestDir, cleanDestDir: !!options.cleanDestDir,
algorithm: options.algorithm || "sha256", algorithm: options.algorithm || "sha256",
encoding: options.encoding || "binary", encoding: options.encoding || "binary",
}; };
// throw an error if destDir is outside of the module to prevent path traversal for security reasons
if (!options.destDir.startsWith(process.cwd())) {
throw new Error(`destDir must be located within '${process.cwd()}', it's currently set to '${options.destDir}'.`);
}
// initialize temporary directory
const tempDir = tempy.directory();
try { try {
// simultaneously download the desired file and its checksums // simultaneously download the desired file and its checksums
await Promise.all([ await Promise.all([
downloadFile(downloadUrl, path.join(options.tempDir, options.filename)), downloadFile(downloadUrl, path.join(tempDir, options.filename)),
downloadFile(checksumUrl, path.join(options.tempDir, "checksums.txt")), downloadFile(checksumUrl, path.join(tempDir, "checksums.txt")),
]); ]);
// validate the checksum of the download // validate the checksum of the download
if (await checkChecksum(options.tempDir, options.filename, "checksums.txt", options.algorithm, options.encoding)) { if (await checkChecksum(tempDir, options.filename, "checksums.txt", options.algorithm, options.encoding)) {
// optionally clear the target directory of existing files // optionally clear the target directory of existing files
if (options.cleanDestDir) { if (options.cleanDestDir && fs.existsSync(options.destDir)) {
await fs.remove(options.destDir); await fs.remove(options.destDir);
} }
@@ -48,21 +49,19 @@ export default async function downloader(downloadUrl, checksumUrl, options) {
if (options.extract) { if (options.extract) {
// decompress download and move resulting files to final destination // decompress download and move resulting files to final destination
await decompress(path.join(options.tempDir, options.filename), options.destDir); await decompress(path.join(tempDir, options.filename), options.destDir);
return options.destDir; return options.destDir;
} else { } else {
// move verified download to final destination as-is // move verified download to final destination as-is
await fs.copy(path.join(options.tempDir, options.filename), path.join(options.destDir, options.filename)); await fs.copy(path.join(tempDir, options.filename), path.join(options.destDir, options.filename));
return path.join(options.destDir, options.filename); return path.join(options.destDir, options.filename);
} }
} else { } else {
throw new Error(`Invalid checksum for ${options.filename}.`); throw new Error(`Invalid checksum for ${options.filename}.`);
} }
} finally { } finally {
// delete temporary directory (except for edge cases above) // delete temporary directory
if (deleteTempDir) { await fs.remove(tempDir);
await fs.remove(options.tempDir);
}
} }
} }

View File

@@ -1,50 +1,63 @@
/* eslint-env mocha */ /* eslint-env mocha */
import fs from "fs-extra"; import fs from "fs-extra";
import path from "path"; import path from "path";
import tempy from "tempy"; import { fileURLToPath } from "url";
import { expect } from "chai"; import { expect } from "chai";
import downloader from "../index.js"; import downloader from "../index.js";
// https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#what-do-i-use-instead-of-__dirname-and-__filename
const __dirname = path.dirname(fileURLToPath(import.meta.url));
it("verified checksum, hugo.exe was extracted", async function () { it("verified checksum, hugo.exe was extracted", async function () {
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
const outDir = path.join(tempy.directory());
await downloader( await downloader(
"https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_extended_0.88.1_Windows-64bit.zip", "https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_extended_0.88.1_Windows-64bit.zip",
"https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_checksums.txt", "https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_checksums.txt",
{ {
destDir: outDir, destDir: path.join(__dirname, "temp"),
algorithm: "sha256", algorithm: "sha256",
encoding: "binary", encoding: "binary",
extract: true, extract: true,
}, },
); );
expect(fs.existsSync(path.join(outDir, "hugo.exe"))).to.be.true; expect(fs.existsSync(path.join(__dirname, "temp", "hugo.exe"))).to.be.true;
fs.removeSync(outDir); // clean up
fs.removeSync(path.join(__dirname, "temp"));
}); });
it("incorrect checksum, not extracted", async function () { it("incorrect checksum, not extracted", async function () {
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
const outDir = path.join(tempy.directory());
expect(async () => downloader( expect(async () => downloader(
// download mismatching versions to trigger error // download mismatching versions to trigger error
"https://github.com/gohugoio/hugo/releases/download/v0.88.0/hugo_0.88.0_Windows-64bit.zip", "https://github.com/gohugoio/hugo/releases/download/v0.88.0/hugo_0.88.0_Windows-64bit.zip",
"https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_checksums.txt", "https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_checksums.txt",
{ {
destDir: outDir, destDir: path.join(__dirname, "temp"),
algorithm: "sha256", algorithm: "sha256",
encoding: "binary", encoding: "binary",
extract: false, extract: false,
}, },
)).to.throw; )).to.throw;
expect(fs.existsSync(path.join(outDir, "hugo.exe"))).to.be.false; expect(fs.existsSync(path.join(__dirname, "temp", "hugo.exe"))).to.be.false;
fs.removeSync(outDir); // clean up
fs.removeSync(path.join(__dirname, "temp"));
});
it("destDir located outside of module, throw error", async function () {
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
expect(async () => downloader(
"https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_Windows-64bit.zip",
"https://github.com/gohugoio/hugo/releases/download/v0.88.1/hugo_0.88.1_checksums.txt",
{
destDir: "../vendor", // invalid path
},
)).to.throw;
}); });