Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/cli/commands/wp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-management/paths';
import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary';
import { getDefaultPhpArgs } from 'cli/lib/native-php';
import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command';
import { validatePhpVersion } from 'cli/lib/utils';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
Expand Down Expand Up @@ -47,9 +48,11 @@ async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promi
const phpVersion = validateNativePhpVersion( site.phpVersion );
await ensurePhpBinaryAvailable( phpVersion );
await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating );
// Don't apply open_basedir or disable_functions to the WP-CLI process
const defaultArgs = getDefaultPhpArgs( phpVersion );
const child = spawn(
getPhpBinaryPath( phpVersion ),
[ getWpCliPharPath(), `--path=${ site.path }`, ...args ],
[ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...args ],
{
cwd: site.path,
stdio: 'inherit',
Expand Down
118 changes: 118 additions & 0 deletions apps/cli/lib/native-php.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata';

// Disabled by default to shrink the attack surface available to PHP code
// running inside a Studio site. Each entry falls into one of:
// - Code execution / loading native code: dl, exec, passthru, popen,
// proc_open/close/terminate, shell_exec, system, pcntl_exec, pcntl_fork
// - Signal & scheduling control (DoS / handler hijack): pcntl_alarm,
// pcntl_async_signals, pcntl_sig*, pcntl_*priority, proc_nice, posix_kill
// - Privilege change: posix_set*
// - Inbound networking (binding listening sockets): socket_accept/bind/
// listen/create_listen/create_pair, stream_socket_server
// - Filesystem tricks that can escape open_basedir or create odd
// primitives: link, symlink, posix_mkfifo
// - Source disclosure: show_source
const PHP_DEFAULT_DISABLED_FUNCTIONS = [
'dl',
'exec',
'link',
'passthru',
'pcntl_alarm',
'pcntl_async_signals',
'pcntl_exec',
'pcntl_fork',
'pcntl_getpriority',
'pcntl_setpriority',
'pcntl_signal',
'pcntl_signal_dispatch',
'pcntl_signal_get_handler',
'pcntl_sigprocmask',
'pcntl_sigtimedwait',
'pcntl_sigwaitinfo',
'popen',
'posix_kill',
'posix_mkfifo',
'posix_setegid',
'posix_seteuid',
'posix_setgid',
'posix_setpgid',
'posix_setsid',
'posix_setuid',
'proc_close',
'proc_nice',
'proc_open',
'proc_terminate',
'shell_exec',
'show_source',
'socket_accept',
'socket_bind',
'socket_create_listen',
'socket_create_pair',
'socket_listen',
'stream_socket_server',
'symlink',
'system',
] as const;

// Process-scoped opcache dir, created lazily and removed when the process exits
let opcacheRootDir: string | null = null;

function getOpcacheRootDir(): string {
if ( opcacheRootDir ) {
return opcacheRootDir;
}

// Resolve to the long-form path on Windows. `os.tmpdir()` can return an 8.3
// short name (e.g. C:\Users\BUILDK~1\AppData\…) when the user has a long
// username, and PHP's INI scanner treats `~` as a special token, breaking
// `-d opcache.file_cache=<path>` parsing.
const tmpRoot =
process.platform === 'win32' ? fs.realpathSync.native( os.tmpdir() ) : os.tmpdir();
opcacheRootDir = fs.mkdtempSync( path.join( tmpRoot, 'studio-opcache-' ) );
const dirToClean = opcacheRootDir;
process.once( 'exit', () => {
try {
fs.rmSync( dirToClean, { recursive: true, force: true } );
} catch {
// Best effort. The OS will reap tmp eventually.
}
} );
return opcacheRootDir;
}

export function getDefaultPhpArgs(
phpVersion: NativePhpSupportedVersion,
openBasedir: string[] = [],
disallowRiskyFunctions: boolean = false
): string[] {
// Partition the file_cache by PHP version: opcache's on-disk script blob
// format isn't stable across minor versions, and reusing a cache populated
// by a different PHP can crash the server at startup on Windows.
const cacheId = `php${ phpVersion }`;
const cacheDirectory = path.join( getOpcacheRootDir(), cacheId );
fs.mkdirSync( cacheDirectory, { recursive: true } );

const args = [
// Avoid loading php.ini config files to prevent other PHP installations from affecting Studio
'-n',
'-d',
'memory_limit=512M',
'-d',
`opcache.file_cache="${ cacheDirectory }"`,
'-d',
`opcache.cache_id="studio-${ cacheId }"`,
];

if ( openBasedir.length ) {
args.push( '-d', `open_basedir="${ openBasedir.join( path.delimiter ) }"` );
}

if ( disallowRiskyFunctions ) {
args.push( '-d', `disable_functions=${ PHP_DEFAULT_DISABLED_FUNCTIONS.join( ',' ) }` );
}

return args;
}
15 changes: 11 additions & 4 deletions apps/cli/lib/run-wp-cli-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getWpCliPharPath,
} from 'cli/lib/dependency-management/paths';
import { validatePhpVersion } from 'cli/lib/utils';
import { getDefaultPhpArgs } from './native-php';
import type { SiteData } from 'cli/lib/cli-config/core';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';

Expand Down Expand Up @@ -124,9 +125,11 @@ async function runNativeWpCliCommand(
const nativeArgs = applyWpCliCommandOptions( 'native', args, options );
const phpVersion = validateNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION );
await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating );
// Don't apply open_basedir or disable_functions to the WP-CLI process
const defaultArgs = getDefaultPhpArgs( phpVersion );
const child = spawn(
getPhpBinaryPath( phpVersion ),
[ getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ],
[ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ],
{
cwd: site.path,
stdio: [ 'ignore', 'pipe', 'pipe' ],
Expand Down Expand Up @@ -259,9 +262,13 @@ export async function runWpCliCommand(

async function runNativeGlobalWpCliCommand( args: string[] ): Promise< DisposableWpCliResponse > {
const phpVersion = validateNativePhpVersion( DEFAULT_PHP_VERSION );
const child = spawn( getPhpBinaryPath( phpVersion ), [ getWpCliPharPath(), ...args ], {
stdio: [ 'ignore', 'pipe', 'pipe' ],
} );
// Don't apply open_basedir or disable_functions to the WP-CLI process
const defaultArgs = getDefaultPhpArgs( phpVersion );
const child = spawn(
getPhpBinaryPath( phpVersion ),
[ ...defaultArgs, getWpCliPharPath(), ...args ],
{ stdio: [ 'ignore', 'pipe', 'pipe' ] }
);

await ensureChildSpawned( child );

Expand Down
143 changes: 143 additions & 0 deletions apps/cli/lib/symlinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import childProcess from 'child_process';
import { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import { FSWatcher, watch as watchPaths } from 'chokidar';

type SymlinkWatcherEvents = {
symlink: [ target: string, symlinkPath: string ];
error: [ error: unknown ];
};

export class SymlinkWatcher extends EventEmitter< SymlinkWatcherEvents > {
private watcher: FSWatcher | null = null;

start( sitePath: string, depth: number = 2 ): void {
if ( this.watcher ) {
return;
}

this.watcher = watchPaths( sitePath, {
// Deep watchers can easily lead to performance issues. That's why we default to a depth of 2.
depth,
ignoreInitial: true,
followSymlinks: false,
persistent: false,
ignorePermissionErrors: true,
// Skip directories that produce noise and never host plugin/theme symlinks.
// chokidar v4+ no longer accepts globs by default — match against the path.
ignored: ( entryPath: string ) =>
/[\\/](node_modules|\.git|\.DS_Store)([\\/]|$)/.test( entryPath ),
} );

const onMaybeSymlink = async ( entryPath: string ) => {
try {
const lst = await fs.promises.lstat( entryPath );
if ( ! lst.isSymbolicLink() ) {
return;
}
} catch {
// Entry vanished before we could inspect it.
return;
}

const target = await resolveSymlinkAllowlistEntry( entryPath );
if ( ! target ) {
return;
}

this.emit( 'symlink', target, entryPath );
};

this.watcher.on( 'add', onMaybeSymlink );
this.watcher.on( 'addDir', onMaybeSymlink );
this.watcher.on( 'error', ( error ) => {
this.emit( 'error', error );
} );
}

async stop(): Promise< void > {
await this.watcher?.close();
this.watcher = null;
}
}

function nativeFindSymlinksInDir( dir: string ): Promise< string[] > {
return new Promise( ( resolve, reject ) => {
const child = childProcess.spawn( 'find', [ dir, '-type', 'l' ] );
let output = '';
child.stdout.on( 'data', ( data ) => {
output += data.toString();
} );
child.on( 'close', ( code ) => {
if ( code !== 0 ) {
reject( new Error( `find exited with code ${ code }` ) );
} else {
const absolutePaths = output
.toString()
.split( '\n' )
.filter( Boolean )
.map( ( relative ) => path.resolve( dir, relative ) );
resolve( absolutePaths );
}
} );
child.on( 'error', ( error ) => {
reject( error );
} );
} );
}

async function walkForSymlinks( dir: string, found: string[] ): Promise< void > {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir( dir, { withFileTypes: true } );
} catch {
// Missing dir or denied permissions — match `find`'s behavior of just skipping.
return;
}
for ( const entry of entries ) {
const fullPath = path.join( dir, entry.name );
if ( entry.isSymbolicLink() ) {
found.push( fullPath );
continue;
}
if ( entry.isDirectory() ) {
await walkForSymlinks( fullPath, found );
}
}
}

async function findSymlinksInDir( dir: string ): Promise< string[] > {
if ( process.platform !== 'win32' ) {
try {
return await nativeFindSymlinksInDir( dir );
} catch {
// Fall back to the Node implementation if `find` is unavailable or fails.
}
}

const found: string[] = [];
await walkForSymlinks( dir, found );
return found;
}

// Resolve a symlink to the directory that needs to be granted in open_basedir.
// PHP's open_basedir compares against the realpath of the accessed file, so we
// follow the symlink chain and grant the containing directory of the target
// (so a single grant covers a whole symlinked plugin/theme, not just one file).
export async function resolveSymlinkAllowlistEntry( linkPath: string ): Promise< string | null > {
try {
const real = await fs.promises.realpath( linkPath );
const stat = await fs.promises.stat( real );
return stat.isDirectory() ? real : path.dirname( real );
} catch {
// Dangling symlink or target gone — nothing to grant.
return null;
}
}

export async function collectSymlinkAllowlistEntries( dir: string ): Promise< string[] > {
const symlinks = await findSymlinksInDir( dir );
const resolved = await Promise.all( symlinks.map( resolveSymlinkAllowlistEntry ) );
return Array.from( new Set( resolved.filter( ( entry ): entry is string => entry !== null ) ) );
}
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@wp-playground/wordpress": "3.1.28",
"archiver": "^7.0.1",
"atomically": "^2.1.1",
"chokidar": "^5.0.0",
"cli-table3": "^0.6.5",
"fs-extra": "^11.3.4",
"http-proxy": "^1.18.1",
Expand Down
Loading