diff --git a/messages/sdr.md b/messages/sdr.md index 1793c2b33..b1c9406e5 100644 --- a/messages/sdr.md +++ b/messages/sdr.md @@ -109,6 +109,10 @@ No source-backed components present in the package. No components in the package to retrieve. +# error_static_resource_attempting_zip_slip + +Entry '%s' in static resource '%s' resolves to a location outside the extraction directory ('%s'). + # error_static_resource_expected_archive_type A StaticResource directory must have a content type of application/zip or application/jar - found %s for %s. diff --git a/src/convert/transformers/staticResourceMetadataTransformer.ts b/src/convert/transformers/staticResourceMetadataTransformer.ts index 4fa05ac18..34bec390b 100644 --- a/src/convert/transformers/staticResourceMetadataTransformer.ts +++ b/src/convert/transformers/staticResourceMetadataTransformer.ts @@ -129,13 +129,21 @@ export class StaticResourceMetadataTransformer extends BaseMetadataTransformer { const srZip = await getStaticResourceZip(component, content); const pipelinePromises: Array> = []; + const baseDestinationPath = isAbsolute(baseContentPath) + ? baseContentPath + : join(this.defaultDirectory ?? component.getPackageRelativePath('', 'source'), baseContentPath); for (const filePath of Object.keys(srZip.files)) { const zipObj = srZip.file(filePath); if (zipObj && !zipObj.dir) { - const path = join(baseContentPath, filePath); - const fullDest = isAbsolute(path) - ? path - : join(this.defaultDirectory ?? component.getPackageRelativePath('', 'source'), path); + const fullDest = join(baseDestinationPath, filePath); + const relativeDest = relative(baseDestinationPath, fullDest); + if (relativeDest.startsWith('..') || isAbsolute(relativeDest)) { + throw messages.createError('error_static_resource_attempting_zip_slip', [ + filePath, + component.name, + baseDestinationPath, + ]); + } pipelinePromises.push(this.pipeline(new Readable().wrap(zipObj.nodeStream()), fullDest)); } } diff --git a/test/convert/transformers/staticResourceMetadataTransformer.test.ts b/test/convert/transformers/staticResourceMetadataTransformer.test.ts index eb367120b..879c4faa4 100644 --- a/test/convert/transformers/staticResourceMetadataTransformer.test.ts +++ b/test/convert/transformers/staticResourceMetadataTransformer.test.ts @@ -352,6 +352,30 @@ describe('StaticResourceMetadataTransformer', () => { expect(await transformer.toSourceFormat({ component })).to.deep.equalInAnyOrder(expectedInfos); }); + it('blocks static resources attempting zip-slip attack', async () => { + assert(typeof transformer.defaultDirectory === 'string'); + + const component = mixedContentSingleFile.COMPONENT; + const { xml } = component; + assert(xml); + env.stub(component, 'parseXml').resolves({ + StaticResource: { + contentType: 'application/zip', + }, + }); + + const filePath = join('..', '..', '..', 'b', 'c.css'); + const testZip = new JSZip().file(filePath, 'malicious css content'); + env.stub(JSZip, 'loadAsync').resolves(testZip); + + try { + void (await transformer.toSourceFormat({ component })); + assert.fail('SHOULD HAVE THROWN ERROR'); + } catch (error) { + expect((error as Error).message).to.include('resolves to a location outside the extraction directory'); + } + }); + it('should merge output with merge component when content is archive', async () => { const root = join('path', 'to', 'another', 'mixedSingleFiles'); const component = mixedContentSingleFile.COMPONENT;