Filesystem security
When to use this skill
Use this skill whenever code touches the filesystem with paths derived from input:
- Building a file path from
$_GET/$_POST/$_REQUEST. include,require,readfile,file_get_contents, orunlinkof a dynamic file.- Serving downloads, exports, or logs based on a request parameter.
- Deleting files selected by the user.
- Using
WP_Filesystemfor writes, deletes, or directory creation.
Path traversal and local file inclusion are high-impact WordPress vulnerabilities —
../../wp-config.php or a malicious uploaded .php can expose secrets or achieve RCE.
Related: see the file-upload-security skill for $_FILES handling and the
object-injection-deserialization skill for uploaded data formats.
Core principles (and why they matter)
- Never
include/requirea user-controlled path. That is remote/local code execution. Keep templates and includes hardcoded. - Constrain every file operation to a known base directory. Resolve with
realpath()and assert the result starts with the allowed base path. validate_file()catches traversal, drive letters, and:characters. A non-zero return value means the path is suspect.- Use
sanitize_file_name()andwp_normalize_path(). Normalize separators and strip dangerous characters before building paths. - Use WordPress deletion helpers.
wp_delete_file()andwp_delete_file_from_directory()are safer than rawunlink(). - Use
WP_Filesystemfor writes. It handles permissions, credentials, and stream wrappers consistently across hosts.
Step-by-step implementation
- Reject any request that wants to
include/requirea file based on input; use a hardcoded allowlist of template files instead. - For read/delete handlers:
- Verify nonce + capability.
- Sanitize the filename with
sanitize_file_name(). - Optionally run
validate_file(). - Build the target path under a known base directory.
- Resolve with
realpath()and confirm it stays under the base.
- For writes/deletes, prefer
WP_Filesystemwithrequest_filesystem_credentials(). - Never expose the full server path in errors or URLs.
Supporting references
| Reference | Load when |
|---|---|
| Filesystem security checklist | Before final verification of the filesystem security controls. |
| Secure filesystem operations | Implementing confined file reads and deletion, and writes through WP_Filesystem. |
Common AI mistakes / anti-patterns
Mistake 1 — include $_GET['page']
// ❌ Insecure: local/remote code execution.
include 'templates/' . $_GET['page'] . '.php';
// ✅ Secure: hardcoded allowlist of template files.
$allowed = array( 'dashboard', 'settings', 'logs' );
$page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : 'dashboard';
if ( ! in_array( $page, $allowed, true ) ) {
wp_die( esc_html__( 'Invalid page.', 'my-plugin' ), 400 );
}
include __DIR__ . '/templates/' . $page . '.php';
Mistake 2 — unlink() of a user-named file
// ❌ Insecure: arbitrary file deletion via traversal.
unlink( WP_CONTENT_DIR . '/exports/' . $_POST['file'] );
// ✅ Secure: sanitize, resolve, and confine to the base directory.
$base = realpath( WP_CONTENT_DIR . '/exports' );
$request = sanitize_file_name( wp_unslash( $_POST['file'] ?? '' ) );
$target = realpath( $base . '/' . $request );
if ( false === $target || strpos( $target, $base . DIRECTORY_SEPARATOR ) !== 0 ) {
wp_die( esc_html__( 'Invalid file.', 'my-plugin' ), 400 );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Forbidden.', 'my-plugin' ), 403 );
}
wp_delete_file( $target );
Mistake 3 — readfile() without base-directory containment
// ❌ Insecure: ../../wp-config.php is reachable.
readfile( '/var/www/uploads/' . $_GET['file'] );
// ✅ Secure: resolve and confine.
$base = realpath( wp_upload_dir()['basedir'] );
$request = sanitize_file_name( wp_unslash( $_GET['file'] ?? '' ) );
$target = realpath( $base . '/' . $request );
if ( false === $target || strpos( $target, $base . DIRECTORY_SEPARATOR ) !== 0 ) {
wp_die( esc_html__( 'Invalid file.', 'my-plugin' ), 400 );
}
readfile( $target );
Mistake 4 — Trusting basename() alone
// ❌ Insecure: basename does not prevent traversal to siblings.
$file = basename( $_GET['file'] );
readfile( '/var/www/uploads/' . $file );
// ✅ Secure: basename plus realpath containment.
$base = realpath( '/var/www/uploads' );
$file = basename( sanitize_file_name( wp_unslash( $_GET['file'] ?? '' ) ) );
$target = realpath( $base . '/' . $file );
if ( false === $target || strpos( $target, $base . DIRECTORY_SEPARATOR ) !== 0 ) {
wp_die( esc_html__( 'Invalid file.', 'my-plugin' ), 400 );
}
readfile( $target );
Mistake 5 — Writing files with raw PHP functions
// ❌ Insecure: no permission/credential handling, may fail on restrictive hosts.
file_put_contents( $path, $data );
// ✅ Secure: use WP_Filesystem after requesting credentials.
function my_plugin_write_file( $path, $data ) {
global $wp_filesystem;
if ( ! function_exists( 'request_filesystem_credentials' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$url = admin_url( 'admin-post.php?action=my_plugin_save' );
$creds = request_filesystem_credentials( $url );
if ( false === $creds || ! WP_Filesystem( $creds ) ) {
return new WP_Error( 'filesystem', __( 'Could not initialize filesystem.', 'my-plugin' ) );
}
return $wp_filesystem->put_contents( $path, $data, FS_CHMOD_FILE );
}
Correct code examples
A complete safe file download/delete helper using base-directory containment is in
references/secure-filesystem-operations.php.
Checklist
- No
include/requireuses user input; templates are selected from a hardcoded allowlist. - File paths are built under a known base directory.
-
realpath()resolves the final path and containment is verified. - Filenames pass through
sanitize_file_name()andwp_normalize_path(). -
validate_file()returns0for paths that include user input. - File delete operations use
wp_delete_file()orwp_delete_file_from_directory(). - File writes use
WP_Filesystemwithrequest_filesystem_credentials(). - State-changing handlers verify nonce + capability first.
- Full server paths are not exposed in errors or URLs.