Merge commit from fork

This commit is contained in:
Ben Lambert
2026-01-20 16:05:28 +01:00
committed by GitHub
parent ae4dd5d157
commit c641c147ab
12 changed files with 238 additions and 11 deletions
@@ -108,4 +108,16 @@ describe('ReadableArrayResponse', () => {
},
});
});
it('should validate relative paths', async () => {
const arr: FromReadableArrayOptions = [
{ data: createReadStream(path1), path: '../other/file.yaml' },
];
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
await expect(res.dir()).rejects.toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
});
@@ -15,6 +15,7 @@
*/
import {
resolveSafeChildPath,
UrlReaderServiceReadTreeResponse,
UrlReaderServiceReadTreeResponseDirOptions,
UrlReaderServiceReadTreeResponseFile,
@@ -98,7 +99,7 @@ export class ReadableArrayResponse implements UrlReaderServiceReadTreeResponse {
for (let i = 0; i < this.stream.length; i++) {
if (!this.stream[i].path.endsWith('/')) {
const filePath = platformPath.join(dir, this.stream[i].path);
const filePath = resolveSafeChildPath(dir, this.stream[i].path);
await fs.mkdir(dirname(filePath), { recursive: true });
await pipeline(this.stream[i].data, fs.createWriteStream(filePath));
}
@@ -15,9 +15,10 @@
*/
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, join as joinPath } from 'path';
import { TarArchiveResponse } from './TarArchiveResponse';
import { createMockDirectory } from '@backstage/backend-test-utils';
import tar from 'tar';
const archiveData = fs.readFileSync(
resolvePath(__filename, '../../__fixtures__/mock-main.tar.gz'),
@@ -223,4 +224,84 @@ describe('TarArchiveResponse', () => {
await expect(res.dir({ targetDir: sub })).rejects.toThrow('NOPE');
await expect(fs.pathExists(sub)).resolves.toBe(true);
});
describe('symlink handling', () => {
const tempDir = createMockDirectory();
it('should not extract symlinks with absolute targets', async () => {
const archiveSourceDir = tempDir.resolve('source');
await fs.ensureDir(joinPath(archiveSourceDir, 'repo'));
await fs.writeFile(
joinPath(archiveSourceDir, 'repo', 'file.txt'),
'file content',
);
// Create a symlink with an absolute path target
const absoluteTarget = tempDir.resolve('other.txt');
await fs.writeFile(absoluteTarget, 'other content');
await fs.symlink(
absoluteTarget,
joinPath(archiveSourceDir, 'repo', 'abs-link'),
);
const tarballPath = tempDir.resolve('archive.tar.gz');
await tar.create(
{ gzip: true, file: tarballPath, cwd: archiveSourceDir },
['repo'],
);
const stream = fs.createReadStream(tarballPath);
const res = new TarArchiveResponse(stream, '', targetDir.path, 'etag');
targetDir.addContent({ out: {} });
const dir = await res.dir({ targetDir: targetDir.resolve('out') });
// Regular file should be extracted
await expect(
fs.readFile(joinPath(dir, 'file.txt'), 'utf8'),
).resolves.toBe('file content');
// Symlink with absolute target should not be extracted
await expect(fs.lstat(joinPath(dir, 'abs-link'))).rejects.toThrow(
'ENOENT',
);
});
it('should extract symlinks with relative targets within archive', async () => {
const archiveSourceDir = tempDir.resolve('source2');
await fs.ensureDir(joinPath(archiveSourceDir, 'repo', 'subdir'));
await fs.writeFile(
joinPath(archiveSourceDir, 'repo', 'target.txt'),
'target content',
);
// Create a relative symlink pointing to a file within the archive
await fs.symlink(
'../target.txt',
joinPath(archiveSourceDir, 'repo', 'subdir', 'rel-link'),
);
const tarballPath = tempDir.resolve('archive2.tar.gz');
await tar.create(
{ gzip: true, file: tarballPath, cwd: archiveSourceDir },
['repo'],
);
const stream = fs.createReadStream(tarballPath);
const res = new TarArchiveResponse(stream, '', targetDir.path, 'etag');
targetDir.addContent({ out2: {} });
const dir = await res.dir({ targetDir: targetDir.resolve('out2') });
// The symlink should be extracted and point to the correct file
const linkPath = joinPath(dir, 'subdir', 'rel-link');
const stats = await fs.lstat(linkPath);
expect(stats.isSymbolicLink()).toBe(true);
// Following the symlink should give us the target content
await expect(fs.readFile(linkPath, 'utf8')).resolves.toBe(
'target content',
);
});
});
});
@@ -15,6 +15,7 @@
*/
import {
isChildPath,
UrlReaderServiceReadTreeResponse,
UrlReaderServiceReadTreeResponseDirOptions,
UrlReaderServiceReadTreeResponseFile,
@@ -23,7 +24,7 @@ import concatStream from 'concat-stream';
import fs from 'fs-extra';
import platformPath from 'path';
import { pipeline as pipelineCb, Readable } from 'stream';
import tar, { Parse, ParseStream, ReadEntry } from 'tar';
import tar, { FileStat, Parse, ParseStream, ReadEntry } from 'tar';
import { promisify } from 'util';
import { stripFirstDirectoryFromPath } from './util';
@@ -182,6 +183,22 @@ export class TarArchiveResponse implements UrlReaderServiceReadTreeResponse {
return false;
}
// Block symlinks/hardlinks that escape the extraction directory
const entry = stat as FileStat & { type?: string; linkpath?: string };
if (
(entry.type === 'SymbolicLink' || entry.type === 'Link') &&
entry.linkpath
) {
const strippedPath = path.split('/').slice(strip).join('/');
const linkDir = platformPath.dirname(
platformPath.join(dir, strippedPath),
);
const targetPath = platformPath.resolve(linkDir, entry.linkpath);
if (!isChildPath(dir, targetPath)) {
return false;
}
}
// File path relative to the root extracted directory. Will remove the
// top level dir name from the path since its name is hard to predetermine.
const relativePath = this.stripFirstDirectory
@@ -271,4 +271,47 @@ describe('ZipArchiveResponse', () => {
'invalid relative path: ../side.txt',
);
});
describe('symlink handling', () => {
it('should extract symlink entries as regular files', async () => {
const externalPath = targetDir.resolve('external.txt');
await fs.writeFile(externalPath, 'external content');
const zipPath = targetDir.resolve('test.zip');
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(zipPath);
const archive = createArchive('zip');
output.on('close', () => resolve());
archive.on('error', reject);
archive.pipe(output);
archive.append('file content', { name: 'file.txt' });
archive.symlink('link', externalPath);
archive.finalize();
});
const stream = createReadStream(zipPath);
const res = new ZipArchiveResponse(stream, '', targetDir.path, 'etag');
targetDir.addContent({ out: {} });
const outDir = targetDir.resolve('out');
const dir = await res.dir({ targetDir: outDir });
await expect(
fs.readFile(resolvePath(dir, 'file.txt'), 'utf8'),
).resolves.toBe('file content');
const entryPath = resolvePath(dir, 'link');
const stats = await fs.lstat(entryPath);
// yauzl extracts symlink entries as regular files
expect(stats.isSymbolicLink()).toBe(false);
expect(stats.isFile()).toBe(true);
const content = await fs.readFile(entryPath, 'utf8');
expect(content).toBe(externalPath);
});
});
});