From 6ce84626abeee15fd27246e40e49e6d17990953b Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Mon, 16 Mar 2026 12:38:46 +0200 Subject: [PATCH 01/10] Move docs directory validation to after copying README.md Signed-off-by: Ruslans Tarasovs --- .changeset/tough-pots-dream.md | 5 +++++ .../cli-e2e-tests/techdocs-cli.test.ts | 14 ++++++++++++++ .../techdocs-node/src/stages/generate/techdocs.ts | 11 ++++++----- 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 .changeset/tough-pots-dream.md diff --git a/.changeset/tough-pots-dream.md b/.changeset/tough-pots-dream.md new file mode 100644 index 0000000000..ba652224ab --- /dev/null +++ b/.changeset/tough-pots-dream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-node': patch +--- + +Fixed bug causing --legacyCopyReadmeMdToIndexMd option to fail if docs directory is not present diff --git a/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts b/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts index a2720c934e..d9bba51a74 100644 --- a/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts +++ b/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts @@ -60,6 +60,7 @@ jest.setTimeout(timeout * 2); describe('end-to-end', () => { const cwd = path.resolve(__dirname, '../src/example-docs'); + const legacyCwd = path.resolve(__dirname, '../src/legacy-docs'); const entryPoint = path.resolve(__dirname, '../bin/techdocs-cli'); afterEach(async () => { @@ -106,6 +107,19 @@ describe('end-to-end', () => { expect(proc.exit).toEqual(0); }); + it('can generate with --legacyCopyReadmeMdToIndexMd option', async () => { + const proc = await executeCommand( + entryPoint, + ['generate', '--no-docker', '--legacyCopyReadmeMdToIndexMd'], + { + legacyCwd, + timeout, + }, + ); + expect(proc.stdout).toContain('Successfully generated docs'); + expect(proc.exit).toEqual(0); + }); + it('can serve in mkdocs', async () => { const proc = await executeCommand( entryPoint, diff --git a/plugins/techdocs-node/src/stages/generate/techdocs.ts b/plugins/techdocs-node/src/stages/generate/techdocs.ts index 8a764eb391..cc4fb816d1 100644 --- a/plugins/techdocs-node/src/stages/generate/techdocs.ts +++ b/plugins/techdocs-node/src/stages/generate/techdocs.ts @@ -121,11 +121,6 @@ export class TechdocsGenerator implements GeneratorBase { this.options.dangerouslyAllowAdditionalKeys, ); - // Validate that no symlinks in the docs directory point outside the input directory - // This prevents path traversal attacks where malicious symlinks could leak host files - const resolvedDocsDir = path.join(inputDir, docsDir ?? 'docs'); - await validateDocsDirectory(resolvedDocsDir, inputDir); - if (parsedLocationAnnotation) { await patchMkdocsYmlPreBuild( mkdocsYmlPath, @@ -139,6 +134,12 @@ export class TechdocsGenerator implements GeneratorBase { await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir }); } + // Validate that no symlinks in the docs directory point outside the input directory + // This prevents path traversal attacks where malicious symlinks could leak host files + const resolvedDocsDir = path.join(inputDir, docsDir ?? 'docs'); + + await validateDocsDirectory(resolvedDocsDir, inputDir); + // patch the list of mkdocs plugins const defaultPlugins = this.options.defaultPlugins ?? []; From ff860a985d5ade433e755798f93d208af6f1896d Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Mon, 16 Mar 2026 13:03:51 +0200 Subject: [PATCH 02/10] Added forgotten test file Signed-off-by: Ruslans Tarasovs --- packages/techdocs-cli/src/legacy-docs/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/techdocs-cli/src/legacy-docs/README.md diff --git a/packages/techdocs-cli/src/legacy-docs/README.md b/packages/techdocs-cli/src/legacy-docs/README.md new file mode 100644 index 0000000000..93c2c4989a --- /dev/null +++ b/packages/techdocs-cli/src/legacy-docs/README.md @@ -0,0 +1 @@ +Very simple file to test that `--legacyCopyReadmeMdToIndexMd` option work From 18b2059ce360bd8dc5b1cc2246b876e7104d3e91 Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Mon, 16 Mar 2026 13:08:56 +0200 Subject: [PATCH 03/10] Fixed incorrect parameter name Signed-off-by: Ruslans Tarasovs --- packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts b/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts index d9bba51a74..4a4819a715 100644 --- a/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts +++ b/packages/techdocs-cli/cli-e2e-tests/techdocs-cli.test.ts @@ -112,8 +112,8 @@ describe('end-to-end', () => { entryPoint, ['generate', '--no-docker', '--legacyCopyReadmeMdToIndexMd'], { - legacyCwd, - timeout, + cwd: legacyCwd, + timeout: timeout, }, ); expect(proc.stdout).toContain('Successfully generated docs'); From 1825392afdf8ed064a3adce076507e4e8206473f Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs <49794769+rtar@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:25:56 +0300 Subject: [PATCH 04/10] Update .changeset/tough-pots-dream.md Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Ruslans Tarasovs <49794769+rtar@users.noreply.github.com> Signed-off-by: Ruslans Tarasovs --- .changeset/tough-pots-dream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tough-pots-dream.md b/.changeset/tough-pots-dream.md index ba652224ab..0bdc1500a9 100644 --- a/.changeset/tough-pots-dream.md +++ b/.changeset/tough-pots-dream.md @@ -2,4 +2,4 @@ '@backstage/plugin-techdocs-node': patch --- -Fixed bug causing --legacyCopyReadmeMdToIndexMd option to fail if docs directory is not present +Fixed bug causing `--legacyCopyReadmeMdToIndexMd` option to fail if docs directory is not present From 5011e30c2c8cfe721e7ca844abe2b6e22c55cabf Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs <49794769+rtar@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:28:21 +0300 Subject: [PATCH 05/10] Update packages/techdocs-cli/src/legacy-docs/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ruslans Tarasovs <49794769+rtar@users.noreply.github.com> Signed-off-by: Ruslans Tarasovs --- packages/techdocs-cli/src/legacy-docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/techdocs-cli/src/legacy-docs/README.md b/packages/techdocs-cli/src/legacy-docs/README.md index 93c2c4989a..ab47987eb4 100644 --- a/packages/techdocs-cli/src/legacy-docs/README.md +++ b/packages/techdocs-cli/src/legacy-docs/README.md @@ -1 +1 @@ -Very simple file to test that `--legacyCopyReadmeMdToIndexMd` option work +Very simple file to test that `--legacyCopyReadmeMdToIndexMd` option works From 5d36b961e32d744434eded1d73d2a8a3f198080c Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Thu, 30 Apr 2026 10:18:17 +0300 Subject: [PATCH 06/10] Validate that symlink to README.md does not escape the directory Signed-off-by: Ruslans Tarasovs --- .../src/stages/generate/helpers.test.ts | 42 +++++++++++++++++++ .../src/stages/generate/helpers.ts | 5 +++ 2 files changed, 47 insertions(+) diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 1f198b43e9..511ba970d1 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -543,6 +543,48 @@ describe('helpers', () => { ], ]); }); + + it('should use a symlink to README.md if docs/index.md does not exist', async () => { + mockDir.setContent({ + 'information.md': 'information.md content', + 'README.md': ctx => ctx.symlink('./information.md'), + }); + + await patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }); + + await expect( + fs.readFile(mockDir.resolve('docs/index.md'), 'utf-8'), + ).resolves.toEqual('information.md content'); + expect(warn.mock.calls).toEqual([ + [`${path.normalize('docs/index.md')} not found.`], + [`${path.normalize('docs/README.md')} not found.`], + [`${path.normalize('docs/readme.md')} not found.`], + ]); + }); + + it('should reject a symlink pointing outside of the current directory', async () => { + const anotherMockDir = createMockDirectory(); + + mockDir.setContent({ + 'information.md': 'information.md content', + 'README.md': ctx => ctx.symlink(anotherMockDir.resolve('tmp/secret')), + }); + + anotherMockDir.setContent({ + tmp: { + secret: 'password', + }, + }); + + await expect( + patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), + ).rejects.toThrow(/not allowed to refer to a location outside/i); + expect(warn.mock.calls).toEqual([ + [`${path.normalize('docs/index.md')} not found.`], + [`${path.normalize('docs/README.md')} not found.`], + [`${path.normalize('docs/readme.md')} not found.`], + ]); + }); }); describe('addBuildTimestampMetadata', () => { diff --git a/plugins/techdocs-node/src/stages/generate/helpers.ts b/plugins/techdocs-node/src/stages/generate/helpers.ts index caecb47524..351fff2f93 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.ts @@ -399,6 +399,11 @@ export const patchIndexPreBuild = async ({ await fs.ensureDir(docsPath); for (const filePath of fallbacks) { + if (!isChildPath(inputDir, filePath)) { + throw new NotAllowedError( + `Path ${filePath} is not allowed to refer to a location outside ${inputDir}`, + ); + } try { await fs.copyFile(filePath, indexMdPath); return; From a30dbc500beb05452a66812e9ee371b5fcecebcd Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Thu, 30 Apr 2026 10:44:14 +0300 Subject: [PATCH 07/10] Made the test more generic Signed-off-by: Ruslans Tarasovs --- .../src/stages/generate/helpers.test.ts | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 511ba970d1..f464bdb918 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -544,47 +544,47 @@ describe('helpers', () => { ]); }); - it('should use a symlink to README.md if docs/index.md does not exist', async () => { - mockDir.setContent({ - 'information.md': 'information.md content', - 'README.md': ctx => ctx.symlink('./information.md'), - }); + it.each(['README.md', 'readme.md', 'docs/README.md', 'docs/readme.md'])( + 'should use a symlink to %s if docs/index.md does not exist', + async fileName => { + console.log(`Testing with symlink to ${fileName}`); + mockDir.setContent({ + 'information.md': 'information.md content', + [fileName]: ctx => ctx.symlink(mockDir.resolve('information.md')), + }); - await patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }); + await patchIndexPreBuild({ + inputDir: mockDir.path, + logger: mockLogger, + }); - await expect( - fs.readFile(mockDir.resolve('docs/index.md'), 'utf-8'), - ).resolves.toEqual('information.md content'); - expect(warn.mock.calls).toEqual([ - [`${path.normalize('docs/index.md')} not found.`], - [`${path.normalize('docs/README.md')} not found.`], - [`${path.normalize('docs/readme.md')} not found.`], - ]); - }); + await expect( + fs.readFile(mockDir.resolve('docs/index.md'), 'utf-8'), + ).resolves.toEqual('information.md content'); + }, + ); - it('should reject a symlink pointing outside of the current directory', async () => { - const anotherMockDir = createMockDirectory(); + it.each(['README.md', 'readme.md', 'docs/README.md', 'docs/readme.md'])( + 'should reject a symlink from %s to outside of the current directory', + async fileName => { + const anotherMockDir = createMockDirectory(); - mockDir.setContent({ - 'information.md': 'information.md content', - 'README.md': ctx => ctx.symlink(anotherMockDir.resolve('tmp/secret')), - }); + mockDir.setContent({ + 'information.md': 'information.md content', + [fileName]: ctx => ctx.symlink(anotherMockDir.resolve('tmp/secret')), + }); - anotherMockDir.setContent({ - tmp: { - secret: 'password', - }, - }); + anotherMockDir.setContent({ + tmp: { + secret: 'password', + }, + }); - await expect( - patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), - ).rejects.toThrow(/not allowed to refer to a location outside/i); - expect(warn.mock.calls).toEqual([ - [`${path.normalize('docs/index.md')} not found.`], - [`${path.normalize('docs/README.md')} not found.`], - [`${path.normalize('docs/readme.md')} not found.`], - ]); - }); + await expect( + patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), + ).rejects.toThrow(/not allowed to refer to a location outside/i); + }, + ); }); describe('addBuildTimestampMetadata', () => { From 0db7b1b163bfd256d24038d74117f18fae4f0a4f Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Thu, 30 Apr 2026 10:50:27 +0300 Subject: [PATCH 08/10] Removed accidential console.log Signed-off-by: Ruslans Tarasovs --- plugins/techdocs-node/src/stages/generate/helpers.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index f464bdb918..67b534417f 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -547,7 +547,6 @@ describe('helpers', () => { it.each(['README.md', 'readme.md', 'docs/README.md', 'docs/readme.md'])( 'should use a symlink to %s if docs/index.md does not exist', async fileName => { - console.log(`Testing with symlink to ${fileName}`); mockDir.setContent({ 'information.md': 'information.md content', [fileName]: ctx => ctx.symlink(mockDir.resolve('information.md')), From 47c1ca0613ad0c83188f3974b56d4e93cca2326b Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Thu, 30 Apr 2026 11:13:55 +0300 Subject: [PATCH 09/10] Add a test checking that it is not possible to write outside of a current directory Signed-off-by: Ruslans Tarasovs --- .../src/stages/generate/helpers.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 67b534417f..4285a26657 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -584,6 +584,43 @@ describe('helpers', () => { ).rejects.toThrow(/not allowed to refer to a location outside/i); }, ); + + it.each(['README.md', 'readme.md'])( + 'should write %s to a symlink docs directory if docs/index.md does not exist', + async fileName => { + mockDir.setContent({ + 'target/docs': {}, + docs: ctx => ctx.symlink('./target/docs'), + [fileName]: `${fileName} content`, + }); + + await patchIndexPreBuild({ + inputDir: mockDir.path, + logger: mockLogger, + }); + + await expect( + fs.readFile(mockDir.resolve('docs/index.md'), 'utf-8'), + ).resolves.toEqual(`${fileName} content`); + }, + ); + + it.each(['README.md', 'readme.md'])( + 'should reject writing %s if targe symlink points outside of the current directory', + async fileName => { + const anotherMockDir = createMockDirectory(); + + mockDir.setContent({ + docs: ctx => ctx.symlink(anotherMockDir.path), + 'information.md': 'information.md content', + [fileName]: `${fileName} content`, + }); + + await expect( + patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), + ).rejects.toThrow(/not allowed to refer to a location outside/i); + }, + ); }); describe('addBuildTimestampMetadata', () => { From a27a24fd5a69d4fde65b019dd5e850fe5b531af6 Mon Sep 17 00:00:00 2001 From: Ruslans Tarasovs Date: Thu, 30 Apr 2026 12:31:08 +0300 Subject: [PATCH 10/10] Implemented a fix preventing to write outside of current directory Signed-off-by: Ruslans Tarasovs --- .../src/stages/generate/helpers.test.ts | 47 ++++++++++++++++--- .../src/stages/generate/helpers.ts | 7 ++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 4285a26657..770eb0e254 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -581,11 +581,13 @@ describe('helpers', () => { await expect( patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), - ).rejects.toThrow(/not allowed to refer to a location outside/i); + ).rejects.toThrow( + /Source path .* is not allowed to refer to a location outside/i, + ); }, ); - it.each(['README.md', 'readme.md'])( + it.each(['README.md', 'readme.md', 'docs/README.md', 'docs/readme.md'])( 'should write %s to a symlink docs directory if docs/index.md does not exist', async fileName => { mockDir.setContent({ @@ -600,25 +602,58 @@ describe('helpers', () => { }); await expect( - fs.readFile(mockDir.resolve('docs/index.md'), 'utf-8'), + fs.readFile(mockDir.resolve('target/docs/index.md'), 'utf-8'), ).resolves.toEqual(`${fileName} content`); }, ); it.each(['README.md', 'readme.md'])( - 'should reject writing %s if targe symlink points outside of the current directory', + 'should reject creating docs dir if target symlink points to non-existing directory outside of the current directory', async fileName => { const anotherMockDir = createMockDirectory(); mockDir.setContent({ - docs: ctx => ctx.symlink(anotherMockDir.path), + docs: ctx => ctx.symlink(anotherMockDir.resolve('docs')), 'information.md': 'information.md content', [fileName]: `${fileName} content`, }); await expect( patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), - ).rejects.toThrow(/not allowed to refer to a location outside/i); + ).rejects.toThrow( + /Target path .* is not allowed to refer to a location outside/i, + ); + + await expect(fs.exists(anotherMockDir.resolve('docs'))).resolves.toBe( + false, + ); + }, + ); + + it.each(['README.md', 'readme.md'])( + 'should reject creating docs dir if target symlink points to existing directory outside of the current directory', + async fileName => { + const anotherMockDir = createMockDirectory(); + + mockDir.setContent({ + docs: ctx => ctx.symlink(anotherMockDir.resolve('docs')), + 'information.md': 'information.md content', + [fileName]: `${fileName} content`, + }); + + anotherMockDir.setContent({ + docs: {}, + }); + + await expect( + patchIndexPreBuild({ inputDir: mockDir.path, logger: mockLogger }), + ).rejects.toThrow( + /Target path .* is not allowed to refer to a location outside/i, + ); + + await expect( + fs.exists(anotherMockDir.resolve('docs/index.md')), + ).resolves.toBe(false); }, ); }); diff --git a/plugins/techdocs-node/src/stages/generate/helpers.ts b/plugins/techdocs-node/src/stages/generate/helpers.ts index 351fff2f93..7e6b710f96 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.ts @@ -397,11 +397,16 @@ export const patchIndexPreBuild = async ({ path.join(inputDir, 'readme.md'), ]; + if (!isChildPath(inputDir, docsPath)) { + throw new NotAllowedError( + `Target path ${docsPath} is not allowed to refer to a location outside ${inputDir}`, + ); + } await fs.ensureDir(docsPath); for (const filePath of fallbacks) { if (!isChildPath(inputDir, filePath)) { throw new NotAllowedError( - `Path ${filePath} is not allowed to refer to a location outside ${inputDir}`, + `Source path ${filePath} is not allowed to refer to a location outside ${inputDir}`, ); } try {