|
8 | 8 | use Composer\Installer; |
9 | 9 | use Composer\Json\JsonFile; |
10 | 10 | use Composer\Package\BasePackage; |
| 11 | +use Composer\Package\Loader\ValidatingArrayLoader; |
11 | 12 | use Composer\Package\PackageInterface; |
12 | 13 | use Composer\Package\Version\VersionSelector; |
13 | 14 | use Composer\Repository; |
@@ -302,6 +303,10 @@ public function install( $args, $assoc_args ) { |
302 | 303 | // Move to a location based on the package name |
303 | 304 | $local_dir = rtrim( WP_CLI::get_runner()->get_packages_dir_path(), '/' ) . '/local/'; |
304 | 305 | $actual_dir_package = $local_dir . str_replace( '/', '-', $package_name ); |
| 306 | + // Guard against path traversal: ensure destination stays within local_dir. |
| 307 | + if ( ! self::is_child_path( $actual_dir_package, $local_dir ) ) { |
| 308 | + throw new Exception( 'Invalid package: resolved destination path escapes the packages directory.' ); |
| 309 | + } |
305 | 310 | Extractor::copy_overwrite_files( $dir_package, $actual_dir_package ); |
306 | 311 | Extractor::rmdir( $dir_package ); |
307 | 312 | // Behold, the extracted package |
@@ -1182,13 +1187,57 @@ private static function get_package_name_and_version_from_dir_package( $dir_pack |
1182 | 1187 | WP_CLI::error( sprintf( "Invalid package: no name in composer.json file '%s'.", $composer_file ) ); |
1183 | 1188 | } |
1184 | 1189 | $package_name = $composer_data['name']; |
1185 | | - $version = self::DEFAULT_DEV_BRANCH_CONSTRAINTS; |
| 1190 | + $naming_error = ValidatingArrayLoader::hasPackageNamingError( $package_name ); |
| 1191 | + if ( null !== $naming_error ) { |
| 1192 | + WP_CLI::error( sprintf( "Invalid package name '%s': %s", $package_name, $naming_error ) ); |
| 1193 | + } |
| 1194 | + $version = self::DEFAULT_DEV_BRANCH_CONSTRAINTS; |
1186 | 1195 | if ( ! empty( $composer_data['version'] ) ) { |
1187 | 1196 | $version = $composer_data['version']; |
1188 | 1197 | } |
1189 | 1198 | return [ $package_name, $version ]; |
1190 | 1199 | } |
1191 | 1200 |
|
| 1201 | + /** |
| 1202 | + * Checks whether a path is inside (or equal to) a given parent directory. |
| 1203 | + * |
| 1204 | + * Resolves '.' and '..' segments without touching the filesystem so it |
| 1205 | + * works even when the paths do not exist yet. Uses a case-insensitive |
| 1206 | + * comparison on Windows where the filesystem is case-insensitive. |
| 1207 | + * |
| 1208 | + * @param string $path Path to test. |
| 1209 | + * @param string $parent_dir Parent directory to test against. |
| 1210 | + * @return bool True when $path is inside $parent_dir. |
| 1211 | + */ |
| 1212 | + private static function is_child_path( $path, $parent_dir ) { |
| 1213 | + $normalized_path = self::resolve_dot_segments( rtrim( str_replace( '\\', '/', $path ), '/' ) ) . '/'; |
| 1214 | + $normalized_parent = self::resolve_dot_segments( rtrim( str_replace( '\\', '/', $parent_dir ), '/' ) ) . '/'; |
| 1215 | + if ( DIRECTORY_SEPARATOR === '\\' ) { |
| 1216 | + return 0 === stripos( $normalized_path, $normalized_parent ); |
| 1217 | + } |
| 1218 | + return 0 === strpos( $normalized_path, $normalized_parent ); |
| 1219 | + } |
| 1220 | + |
| 1221 | + /** |
| 1222 | + * Resolves '.' and '..' segments in a path without touching the filesystem. |
| 1223 | + * |
| 1224 | + * @param string $path Forward-slash path to resolve. |
| 1225 | + * @return string Resolved path. |
| 1226 | + */ |
| 1227 | + private static function resolve_dot_segments( $path ) { |
| 1228 | + $is_absolute = isset( $path[0] ) && '/' === $path[0]; |
| 1229 | + $result = []; |
| 1230 | + foreach ( explode( '/', $path ) as $part ) { |
| 1231 | + if ( '..' === $part ) { |
| 1232 | + array_pop( $result ); |
| 1233 | + } elseif ( '.' !== $part && '' !== $part ) { |
| 1234 | + $result[] = $part; |
| 1235 | + } |
| 1236 | + } |
| 1237 | + $resolved = implode( '/', $result ); |
| 1238 | + return $is_absolute ? '/' . $resolved : $resolved; |
| 1239 | + } |
| 1240 | + |
1192 | 1241 | /** |
1193 | 1242 | * Gets the WP-CLI packages composer.json object. |
1194 | 1243 | */ |
|
0 commit comments