CVE-2024-48914
Description
Vendure is an open-source headless commerce platform. Prior to versions 3.0.5 and 2.3.3, a vulnerability in Vendure's asset server plugin allows an attacker to craft a request which is able to traverse the server file system and retrieve the contents of arbitrary files, including sensitive data such as configuration files, environment variables, and other critical data stored on the server. In the same code path is an additional vector for crashing the server via a malformed URI. Patches are available in versions 3.0.5 and 2.3.3. Some workarounds are also available. One may use object storage rather than the local file system, e.g. MinIO or S3, or define middleware which detects and blocks requests with urls containing /../.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@vendure/asset-server-pluginnpm | < 2.3.3 | 2.3.3 |
@vendure/asset-server-pluginnpm | >= 3.0.0, < 3.0.5 | 3.0.5 |
Patches
6483dca6a13f4ec9b08af26dce2ee0c43159be4b58af6822de2ee0c43159bMerge commit from fork
2 files changed · +54 −6
packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts+38 −2 modified@@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { mergeConfig } from '@vendure/core'; +import { ConfigService, mergeConfig } from '@vendure/core'; import { AssetFragment } from '@vendure/core/e2e/graphql/generated-e2e-admin-types'; import { createTestEnvironment } from '@vendure/testing'; +import { exec } from 'child_process'; import fs from 'fs-extra'; import gql from 'graphql-tag'; import fetch from 'node-fetch'; @@ -193,6 +194,41 @@ describe('AssetServerPlugin', () => { it('does not error on non-integer height', async () => { return fetch(`${asset.preview}?h=10.5`); }); + + // https://github.com/vendure-ecommerce/vendure/security/advisories/GHSA-r9mq-3c9r-fmjq + describe('path traversal', () => { + function curlWithPathAsIs(url: string) { + return new Promise<string>((resolve, reject) => { + // We use curl here rather than node-fetch or any other fetch-type function because + // those will automatically perform path normalization which will mask the path traversal + return exec(`curl --path-as-is ${url}`, (err, stdout, stderr) => { + if (err) { + reject(err); + } + resolve(stdout); + }); + }); + } + + function testPathTraversalOnUrl(urlPath: string) { + return async () => { + const port = server.app.get(ConfigService).apiOptions.port; + const result = await curlWithPathAsIs(`http://localhost:${port}/assets${urlPath}`); + expect(result).not.toContain('@vendure/asset-server-plugin'); + expect(result.toLowerCase()).toContain('resource not found'); + }; + } + + it('blocks path traversal 1', testPathTraversalOnUrl(`/../../package.json`)); + it('blocks path traversal 2', testPathTraversalOnUrl(`/foo/../../../package.json`)); + it('blocks path traversal 3', testPathTraversalOnUrl(`/foo/../../../foo/../package.json`)); + it('blocks path traversal 4', testPathTraversalOnUrl(`/%2F..%2F..%2Fpackage.json`)); + it('blocks path traversal 5', testPathTraversalOnUrl(`/%2E%2E/%2E%2E/package.json`)); + it('blocks path traversal 6', testPathTraversalOnUrl(`/..//..//package.json`)); + it('blocks path traversal 7', testPathTraversalOnUrl(`/.%2F.%2F.%2Fpackage.json`)); + it('blocks path traversal 8', testPathTraversalOnUrl(`/..\\\\..\\\\package.json`)); + it('blocks path traversal 9', testPathTraversalOnUrl(`/\\\\\\..\\\\\\..\\\\\\package.json`)); + }); }); describe('deletion', () => { @@ -268,7 +304,7 @@ describe('AssetServerPlugin', () => { // https://github.com/vendure-ecommerce/vendure/issues/1563 it('falls back to binary preview if image file cannot be processed', async () => { const filesToUpload = [path.join(__dirname, 'fixtures/assets/bad-image.jpg')]; - const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({ + const { createAssets }: CreateAssetsMutation = await adminClient.fileUploadMutation({ mutation: CREATE_ASSETS, filePaths: filesToUpload, mapVariables: filePaths => ({
packages/asset-server-plugin/src/plugin.ts+16 −4 modified@@ -281,7 +281,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { return async (err: any, req: Request, res: Response, next: NextFunction) => { if (err && (err.status === 404 || err.statusCode === 404)) { if (req.query) { - const decodedReqPath = decodeURIComponent(req.path); + const decodedReqPath = this.sanitizeFilePath(req.path); Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx); let file: Buffer; try { @@ -347,9 +347,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { imageParamsString += quality; } - /* eslint-enable @typescript-eslint/restrict-template-expressions */ - - const decodedReqPath = decodeURIComponent(req.path); + const decodedReqPath = this.sanitizeFilePath(req.path); if (imageParamsString !== '') { const imageParamHash = this.md5(imageParamsString); return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat)); @@ -358,6 +356,20 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } } + /** + * Sanitize the file path to prevent directory traversal attacks. + */ + private sanitizeFilePath(filePath: string): string { + let decodedPath: string; + try { + decodedPath = decodeURIComponent(filePath); + } catch (e: any) { + Logger.error((e.message as string) + ': ' + filePath, loggerCtx); + return ''; + } + return path.normalize(decodedPath).replace(/(\.\.[\/\\])+/, ''); + } + private md5(input: string): string { return createHash('md5').update(input).digest('hex'); }
e4b58af6822dMerge commit from fork
2 files changed · +54 −6
packages/asset-server-plugin/e2e/asset-server-plugin.e2e-spec.ts+38 −2 modified@@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { mergeConfig } from '@vendure/core'; +import { ConfigService, mergeConfig } from '@vendure/core'; import { AssetFragment } from '@vendure/core/e2e/graphql/generated-e2e-admin-types'; import { createTestEnvironment } from '@vendure/testing'; +import { exec } from 'child_process'; import fs from 'fs-extra'; import gql from 'graphql-tag'; import fetch from 'node-fetch'; @@ -193,6 +194,41 @@ describe('AssetServerPlugin', () => { it('does not error on non-integer height', async () => { return fetch(`${asset.preview}?h=10.5`); }); + + // https://github.com/vendure-ecommerce/vendure/security/advisories/GHSA-r9mq-3c9r-fmjq + describe('path traversal', () => { + function curlWithPathAsIs(url: string) { + return new Promise<string>((resolve, reject) => { + // We use curl here rather than node-fetch or any other fetch-type function because + // those will automatically perform path normalization which will mask the path traversal + return exec(`curl --path-as-is ${url}`, (err, stdout, stderr) => { + if (err) { + reject(err); + } + resolve(stdout); + }); + }); + } + + function testPathTraversalOnUrl(urlPath: string) { + return async () => { + const port = server.app.get(ConfigService).apiOptions.port; + const result = await curlWithPathAsIs(`http://localhost:${port}/assets${urlPath}`); + expect(result).not.toContain('@vendure/asset-server-plugin'); + expect(result.toLowerCase()).toContain('resource not found'); + }; + } + + it('blocks path traversal 1', testPathTraversalOnUrl(`/../../package.json`)); + it('blocks path traversal 2', testPathTraversalOnUrl(`/foo/../../../package.json`)); + it('blocks path traversal 3', testPathTraversalOnUrl(`/foo/../../../foo/../package.json`)); + it('blocks path traversal 4', testPathTraversalOnUrl(`/%2F..%2F..%2Fpackage.json`)); + it('blocks path traversal 5', testPathTraversalOnUrl(`/%2E%2E/%2E%2E/package.json`)); + it('blocks path traversal 6', testPathTraversalOnUrl(`/..//..//package.json`)); + it('blocks path traversal 7', testPathTraversalOnUrl(`/.%2F.%2F.%2Fpackage.json`)); + it('blocks path traversal 8', testPathTraversalOnUrl(`/..\\\\..\\\\package.json`)); + it('blocks path traversal 9', testPathTraversalOnUrl(`/\\\\\\..\\\\\\..\\\\\\package.json`)); + }); }); describe('deletion', () => { @@ -268,7 +304,7 @@ describe('AssetServerPlugin', () => { // https://github.com/vendure-ecommerce/vendure/issues/1563 it('falls back to binary preview if image file cannot be processed', async () => { const filesToUpload = [path.join(__dirname, 'fixtures/assets/bad-image.jpg')]; - const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({ + const { createAssets }: CreateAssetsMutation = await adminClient.fileUploadMutation({ mutation: CREATE_ASSETS, filePaths: filesToUpload, mapVariables: filePaths => ({
packages/asset-server-plugin/src/plugin.ts+16 −4 modified@@ -281,7 +281,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { return async (err: any, req: Request, res: Response, next: NextFunction) => { if (err && (err.status === 404 || err.statusCode === 404)) { if (req.query) { - const decodedReqPath = decodeURIComponent(req.path); + const decodedReqPath = this.sanitizeFilePath(req.path); Logger.debug(`Pre-cached Asset not found: ${decodedReqPath}`, loggerCtx); let file: Buffer; try { @@ -347,9 +347,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { imageParamsString += quality; } - /* eslint-enable @typescript-eslint/restrict-template-expressions */ - - const decodedReqPath = decodeURIComponent(req.path); + const decodedReqPath = this.sanitizeFilePath(req.path); if (imageParamsString !== '') { const imageParamHash = this.md5(imageParamsString); return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat)); @@ -358,6 +356,20 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap { } } + /** + * Sanitize the file path to prevent directory traversal attacks. + */ + private sanitizeFilePath(filePath: string): string { + let decodedPath: string; + try { + decodedPath = decodeURIComponent(filePath); + } catch (e: any) { + Logger.error((e.message as string) + ': ' + filePath, loggerCtx); + return ''; + } + return path.normalize(decodedPath).replace(/(\.\.[\/\\])+/, ''); + } + private md5(input: string): string { return createHash('md5').update(input).digest('hex'); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-r9mq-3c9r-fmjqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-48914ghsaADVISORY
- github.com/vendure-ecommerce/vendure/blob/801980e8f599c28c5059657a9d85dd03e3827992/packages/asset-server-plugin/src/plugin.tsnvdWEB
- github.com/vendure-ecommerce/vendure/commit/e2ee0c43159b3d13b51b78654481094fdd4850c5nvdWEB
- github.com/vendure-ecommerce/vendure/commit/e4b58af6822d38a9c92a1d8573e19288b8edaa1cnvdWEB
- github.com/vendure-ecommerce/vendure/security/advisories/GHSA-r9mq-3c9r-fmjqnvdWEB
News mentions
0No linked articles in our index yet.