Path Traversal in @backstage/plugin-scaffolder-backend
Description
@backstage/plugin-scaffolder-backend is the backend for the default Backstage software templates. In affected versions a malicious actor with write access to a registered scaffolder template is able to manipulate the template in a way that writes files to arbitrary paths on the scaffolder-backend host instance. This vulnerability can in some situation also be exploited through user input when executing a template, meaning you do not need write access to the templates. This method will not allow the attacker to control the contents of the injected file however, unless the template is also crafted in a specific way that gives control of the file contents. This vulnerability is fixed in version 0.15.14 of the @backstage/plugin-scaffolder-backend. This attack is mitigated by restricting access and requiring reviews when registering or modifying scaffolder templates.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@backstage/plugin-scaffolder-backendnpm | < 0.15.14 | 0.15.14 |
Affected products
1Patches
1f9352ab60636scaffolder-backend: removed all usaged and prevent new usage of path.resolve
7 files changed · +48 −11
.changeset/serious-pens-tease.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Removed all usages of `path.resolve` in order to ensure that template paths are resolved in a safe way.
plugins/scaffolder-backend/.eslintrc.js+33 −0 modified@@ -1,8 +1,41 @@ +const parent = require('@backstage/cli/config/eslint.backend'); + module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], ignorePatterns: ['sample-templates/'], rules: { 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' + // Usage of path.resolve is extra sensitive in the scaffolder, so forbid it in non-test code + 'no-restricted-imports': [ + 'error', + { + ...parent.rules['no-restricted-imports'][1], + paths: [ + { + name: 'path', + importNames: ['resolve'], + message: + 'Do not use path.resolve, use `resolveSafeChildPath` from `@backstage/backend-common` instead as it prevents security issues', + }, + ], + }, + ], + 'no-restricted-syntax': parent.rules['no-restricted-syntax'].concat([ + { + message: + 'Do not use path.resolve, use `resolveSafeChildPath` from `@backstage/backend-common` instead as it prevents security issues', + selector: + 'MemberExpression[object.name="path"][property.name="resolve"]', + }, + ]), }, + overrides: [ + { + files: ['*.test.*', 'src/setupTests.*', 'dev/**'], + rules: { + 'no-restricted-imports': parent.rules['no-restricted-imports'], + }, + }, + ], };
plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts+2 −2 modified@@ -15,10 +15,10 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath } from 'path'; import { createTemplateAction } from '../../createTemplateAction'; import * as yaml from 'yaml'; import { Entity } from '@backstage/catalog-model'; +import { resolveSafeChildPath } from '@backstage/backend-common'; export function createCatalogWriteAction() { return createTemplateAction<{ name?: string; entity: Entity }>({ @@ -42,7 +42,7 @@ export function createCatalogWriteAction() { const { entity } = ctx.input; await fs.writeFile( - resolvePath(ctx.workspacePath, 'catalog-info.yaml'), + resolveSafeChildPath(ctx.workspacePath, 'catalog-info.yaml'), yaml.stringify(entity), ); },
plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts+2 −2 modified@@ -15,7 +15,7 @@ */ import { readdir, stat } from 'fs-extra'; -import { relative, resolve } from 'path'; +import { relative, join } from 'path'; import { createTemplateAction } from '../../createTemplateAction'; /** @@ -68,7 +68,7 @@ export async function recursiveReadDir(dir: string): Promise<string[]> { const subdirs = await readdir(dir); const files = await Promise.all( subdirs.map(async subdir => { - const res = resolve(dir, subdir); + const res = join(dir, subdir); return (await stat(res)).isDirectory() ? recursiveReadDir(res) : [res]; }), );
plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts+1 −1 modified@@ -19,7 +19,7 @@ import { JsonValue } from '@backstage/types'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import fs from 'fs-extra'; -import * as path from 'path'; +import path from 'path'; export async function fetchContents({ reader,
plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts+4 −4 modified@@ -14,7 +14,7 @@ * limitations under the License. */ -import { resolve as resolvePath, extname } from 'path'; +import { extname } from 'path'; import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; @@ -114,7 +114,7 @@ export function createFetchTemplateAction(options: { ctx.logger.info('Fetching template content from remote URL'); const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolvePath(workDir, 'template'); + const templateDir = resolveSafeChildPath(workDir, 'template'); const targetPath = ctx.input.targetPath ?? './'; const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); @@ -240,7 +240,7 @@ export function createFetchTemplateAction(options: { if (renderFilename) { localOutputPath = templater.renderString(localOutputPath, context); } - const outputPath = resolvePath(outputDir, localOutputPath); + const outputPath = resolveSafeChildPath(outputDir, localOutputPath); // variables have been expanded to make an empty file name // this is due to a conditional like if values.my_condition then file-name.txt else empty string so skip if (outputDir === outputPath) { @@ -259,7 +259,7 @@ export function createFetchTemplateAction(options: { ); await fs.ensureDir(outputPath); } else { - const inputFilePath = resolvePath(templateDir, location); + const inputFilePath = resolveSafeChildPath(templateDir, location); if (await isBinaryFile(inputFilePath)) { ctx.logger.info(
plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts+1 −2 modified@@ -15,7 +15,6 @@ */ import fs from 'fs-extra'; -import path from 'path'; import { parseRepoUrl, isExecutable } from './util'; import { @@ -197,7 +196,7 @@ export const createPublishGithubPullRequestAction = ({ const fileContents = await Promise.all( localFilePaths.map(filePath => { - const absPath = path.resolve(fileRoot, filePath); + const absPath = resolveSafeChildPath(fileRoot, filePath); const base64EncodedContent = fs .readFileSync(absPath) .toString('base64');
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
4- github.com/advisories/GHSA-mg3m-f475-28hvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-43783ghsaADVISORY
- github.com/backstage/backstage/commit/f9352ab606367cd9efc6ff048915c70ed3013b7fghsax_refsource_MISCWEB
- github.com/backstage/backstage/security/advisories/GHSA-mg3m-f475-28hvghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.