CVE-2021-43783 (published 2021-11-29T20:15Z)

@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.

Severity: MEDIUM
CVSS v2 base score: 5.5 (AV:N/AC:L/Au:S/C:N/I:P/A:P)
CVSS v3 base score: 8.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:L)

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/backstage/backstage
commit_details: repo_url: https://github.com/backstage/backstage
author: Patrik Oldsberg
author_date: 2021-11-24 19:56:44+01:00
msg: scaffolder-backend: removed all usaged and prevent new usage of path.resolve

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
repository: repo_url: https://github.com/backstage/backstage
repo_name: backstage/backstage
description: Backstage is an open framework for building developer portals
repo_language: TypeScript
owner: backstage
file_changes: filename: serious-pens-tease.md
change_type: ModificationType.ADD
diff: @@ -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.

programming_language: Markdown
filename: .eslintrc.js
change_type: ModificationType.MODIFY
diff: @@ -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'],
+      },
+    },
+  ],
 };

programming_language: TypeScript
filename: write.ts
change_type: ModificationType.MODIFY
diff: @@ -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),
       );
     },

programming_language: TypeScript
filename: log.ts
change_type: ModificationType.MODIFY
diff: @@ -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];
     }),
   );

programming_language: TypeScript
filename: helpers.ts
change_type: ModificationType.MODIFY
diff: @@ -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,

programming_language: TypeScript
filename: template.ts
change_type: ModificationType.MODIFY
diff: @@ -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(

programming_language: TypeScript
filename: githubPullRequest.ts
change_type: ModificationType.MODIFY
diff: @@ -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');

programming_language: TypeScript