CVE-2019-10773 (published 2019-12-16T20:15Z)

In Yarn before 1.21.1, the package install functionality can be abused to generate arbitrary symlinks on the host filesystem by using specially crafted "bin" keys. Existing files could be overwritten depending on the current user permission set.

Severity: MEDIUM
CVSS v2 base score: 6.8 (AV:N/AC:M/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 7.8, HIGH (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-59
cwe_name: Improper Link Resolution Before File Access ('Link Following')
description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/yarnpkg/yarn
commit_details: repo_url: https://github.com/yarnpkg/yarn
author: Maël Nison
author_date: 2019-12-11 09:39:33+01:00
msg: Fixes bin overwrites (#7755)

* Fixes potential file overwrite at install time

* Fixes the regexp

* Adds warnings

* Fixes overzealous removals

* Fixes test

* Adds tests
repository: repo_url: https://github.com/yarnpkg/yarn
repo_name: yarnpkg/yarn
description: The 1.x line is frozen - features and bugfixes now happen on https://github.com/yarnpkg/berry
repo_language: JavaScript
owner: yarnpkg
file_changes: filename: normalize-manifest.js.snap
change_type: ModificationType.MODIFY
diff: @@ -60,6 +60,24 @@ Array [
 ]
 `;
 
+exports[`dangerous bin name: dangerous bin name 1`] = `
+Array [
+  "foo: Invalid bin entry for \\"/tmp/foo\\" (in \\"foo\\").",
+  "foo: Invalid bin entry for \\"../tmp/foo\\" (in \\"foo\\").",
+  "foo: Invalid bin entry for \\"tmp/../../foo\\" (in \\"foo\\").",
+  "foo: No license field",
+]
+`;
+
+exports[`dangerous bin values: dangerous bin values 1`] = `
+Array [
+  "foo: Invalid bin entry for \\"foo\\" (in \\"foo\\").",
+  "foo: Invalid bin entry for \\"bar\\" (in \\"foo\\").",
+  "foo: Invalid bin entry for \\"baz\\" (in \\"foo\\").",
+  "foo: No license field",
+]
+`;
+
 exports[`dedupe all trivial dependencies: dedupe all trivial dependencies 1`] = `
 Array [
   "No license field",
@@ -92,6 +110,7 @@ Array [
 
 exports[`empty bin string: empty bin string 1`] = `
 Array [
+  "foo: Invalid bin field for \\"foo\\".",
   "foo: No license field",
 ]
 `;

programming_language: TypeScript
filename: actual.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,9 @@
+{
+    "name": "foo",
+    "version": "",
+    "bin": {
+        "/tmp/foo": "main.js",
+        "../tmp/foo": "main.js",
+        "tmp/../../foo": "main.js"
+    }
+}

programming_language: JavaScript
filename: expected.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,5 @@
+{
+    "name": "foo",
+    "version": "",
+    "bin": {}
+}

programming_language: TypeScript
filename: actual.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,9 @@
+{
+    "name": "foo",
+    "version": "",
+    "bin": {
+        "foo": "../../../../../foo",
+        "bar": "/hello/world",
+        "baz": "./foo/bar/../../../../../../foo"
+    }
+}

programming_language: TypeScript
filename: expected.json
change_type: ModificationType.ADD
diff: @@ -0,0 +1,5 @@
+{
+    "name": "foo",
+    "version": "",
+    "bin": {}
+}

programming_language: TypeScript
filename: expected.json
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,4 @@
 {
   "name": "foo",
-  "version": "",
-  "bin": ""
+  "version": ""
 }

programming_language: None
filename: en.js
change_type: ModificationType.MODIFY
diff: @@ -26,7 +26,8 @@ const messages = {
   couldntClearPackageFromCache: "Couldn't clear package $0 from cache",
   clearedPackageFromCache: 'Cleared package $0 from cache',
   packWroteTarball: 'Wrote tarball to $0.',
-
+  invalidBinField: 'Invalid bin field for $0.',
+  invalidBinEntry: 'Invalid bin entry for $1 (in $0).',
   helpExamples: '  Examples:\n$0\n',
   helpCommands: '  Commands:\n$0\n',
   helpCommandsMore: '  Run `$0` for more information on specific commands.',

programming_language: JavaScript
filename: fix.js
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,7 @@
 
 import {MANIFEST_FIELDS} from '../../constants';
 import type {Reporter} from '../../reporters/index.js';
-import {isValidLicense} from './util.js';
+import {isValidBin, isValidLicense} from './util.js';
 import {normalizePerson, extractDescription} from './util.js';
 import {hostedGitFragmentToGitUrl} from '../../resolvers/index.js';
 import inferLicense from './infer-license.js';
@@ -12,6 +12,8 @@ const semver = require('semver');
 const path = require('path');
 const url = require('url');
 
+const VALID_BIN_KEYS = /^[a-z0-9_-]+$/i;
+
 const LICENSE_RENAMES: {[key: string]: ?string} = {
   'MIT/X11': 'MIT',
   X11: 'MIT',
@@ -159,6 +161,24 @@ export default (async function(
     info.bin = {[name]: info.bin};
   }
 
+  // Validate that the bin entries reference only files within their package, and that
+  // their name is a valid file name
+  if (typeof info.bin === 'object' && info.bin !== null) {
+    const bin: Object = info.bin;
+    for (const key of Object.keys(bin)) {
+      const target = bin[key];
+      if (!VALID_BIN_KEYS.test(key) || !isValidBin(target)) {
+        delete bin[key];
+        warn(reporter.lang('invalidBinEntry', info.name, key));
+      } else {
+        bin[key] = path.normalize(target);
+      }
+    }
+  } else if (typeof info.bin !== 'undefined') {
+    delete info.bin;
+    warn(reporter.lang('invalidBinField', info.name));
+  }
+
   // bundleDependencies is an alias for bundledDependencies
   if (info.bundledDependencies) {
     info.bundleDependencies = info.bundledDependencies;

programming_language: TypeScript
filename: util.js
change_type: ModificationType.MODIFY
diff: @@ -2,12 +2,19 @@
 
 import type {PersonObject} from '../../types.js';
 
+const path = require('path');
 const validateLicense = require('validate-npm-package-license');
 
+const PARENT_PATH = /^\.\.([\\\/]|$)/;
+
 export function isValidLicense(license: string): boolean {
   return !!license && validateLicense(license).validForNewPackages;
 }
 
+export function isValidBin(bin: string): boolean {
+  return !path.isAbsolute(bin) && !PARENT_PATH.test(path.normalize(bin));
+}
+
 export function stringifyPerson(person: mixed): any {
   if (!person || typeof person !== 'object') {
     return person;

programming_language: TypeScript