Skip to content

Commit b638e43

Browse files
committed
Code Review
1 parent 0a93dd9 commit b638e43

11 files changed

Lines changed: 733 additions & 89 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog for EngineScript Site Exporter
22

3+
## 2.1.1 - Unreleased
4+
5+
### Security
6+
7+
- **Multisite Export Authorization**: Full-site export access now uses a shared permission helper. On multisite, exporter page access, export creation, secure download, and manual delete require a super admin or `manage_network_options`; single-site installs continue to require `manage_options`.
8+
- **Per-Export Private Storage**: Each export is now staged in a random private child directory under the configured private temp export base. The exporter rejects symlinked or unsafe pre-existing directories and enforces `0700` directory permissions.
9+
- **Private File Modes**: Sensitive export artifacts now use a private umask during export and explicit `0600` chmod verification after database dumps, compressed database payloads, file archives, manifests, final ZIPs, and protection files are created.
10+
- **WP-CLI Trust Boundary**: WP-CLI discovery now prefers trusted system paths (`/usr/local/bin/wp`, `/usr/bin/wp`). Alternate executables must be explicitly configured with `SSE_WP_CLI_PATH` or the `sse_wp_cli_path` filter and must pass ownership and writable-mode checks.
11+
- **Export Action Binding**: Generated download and delete actions include the private export directory identifier in their request data and nonce action so requests are tied to the generated export location.
12+
13+
### Architecture
14+
15+
- **Private Export Cleanup**: Bulk cleanup now scans generated private export directories, scheduled/manual deletion can clean up empty private export directories, and failed exports remove their private staging directory.
16+
- **Private Storage Helpers**: Added shared helpers for multisite-aware capability checks, private export directory naming, generated directory validation, and private filesystem mode enforcement.
17+
18+
### Documentation
19+
20+
- **Security Requirements**: Updated README.md and readme.txt to describe trusted WP-CLI configuration, multisite authorization expectations, random private export directories, and private filesystem permissions.
21+
322
## 2.1.0 - May 14, 2026
423

524
### Security

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,17 @@ The downloaded ZIP is named `<site>_enginescript_site_export_<timestamp>.zip`.
7171
- WordPress 6.8 or higher
7272
- PHP 8.2 or higher
7373
- Write access to a private WordPress temporary directory. If your host's temp directory is inside the WordPress web root, configure `WP_TEMP_DIR` to a non-public writable path.
74-
- WP-CLI installed at `/usr/local/bin/wp`, `/usr/bin/wp`, or as `wp-cli.phar` in the WordPress root for database exports
74+
- WP-CLI installed at `/usr/local/bin/wp` or `/usr/bin/wp` for database exports. To use another trusted executable, define `SSE_WP_CLI_PATH` or filter `sse_wp_cli_path`; local paths must pass ownership and permission checks.
7575

7676
## Security Features
7777

7878
EngineScript Site Exporter is built with security as a priority:
7979

80-
- **Export Authentication**: Only authorized administrators can create and download exports
80+
- **Export Authentication**: Only authorized administrators can create and download exports; multisite exports require a network-capable administrator
8181
- **Secure Downloads**: All downloads are validated with WordPress nonces
8282
- **Request Validation**: WordPress nonce validation for all admin actions
8383
- **Path Traversal Protection**: Comprehensive file path validation
84+
- **Private Export Storage**: Exports are staged in random private directories with `0700` directories and `0600` files
8485
- **Automatic Deletion**: Exports are automatically cleaned up after 5 minutes
8586
- **Security Headers**: Implements proper headers for download operations
8687
- **Secure File Handling**: Uses WordPress Filesystem API for file operations
@@ -96,15 +97,15 @@ The plugin is designed to work with most WordPress sites, but very large sites (
9697
Exports are staged in WordPress' temporary directory under:
9798
`<temp-dir>/enginescript-site-exporter-exports/`
9899

99-
For security, the plugin refuses to export if that directory resolves inside the WordPress web root. Configure `WP_TEMP_DIR` to a private writable path if your host's default temporary directory is public.
100+
Each export is written inside a random private child directory. For security, the plugin refuses to export if the export directory resolves inside the WordPress web root. Configure `WP_TEMP_DIR` to a private writable path if your host's default temporary directory is public.
100101

101102
### Why do export files disappear after 5 minutes?
102103

103104
For security and disk space considerations, all exports are automatically deleted after 5 minutes. This ensures sensitive site data isn't left stored indefinitely.
104105

105106
### Can I create multiple exports?
106107

107-
Yes, you can create as many exports as needed. Each will have a unique filename based on the timestamp of creation.
108+
Yes, you can create as many exports as needed. Each export is staged in its own random private directory.
108109

109110
### Does this include my themes and plugins?
110111

enginescript-site-exporter.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@
3535
define( 'SSE_EXPORT_DIR_NAME', 'enginescript-site-exporter-exports' );
3636
}
3737

38+
// Define private export directory naming and filesystem modes.
39+
if ( ! defined( 'SSE_EXPORT_PRIVATE_DIR_PREFIX' ) ) {
40+
define( 'SSE_EXPORT_PRIVATE_DIR_PREFIX', 'export-' );
41+
}
42+
43+
if ( ! defined( 'SSE_PRIVATE_DIR_MODE' ) ) {
44+
define( 'SSE_PRIVATE_DIR_MODE', 0700 );
45+
}
46+
47+
if ( ! defined( 'SSE_PRIVATE_FILE_MODE' ) ) {
48+
define( 'SSE_PRIVATE_FILE_MODE', 0600 );
49+
}
50+
3851
// Define the filter name for maximum file size override.
3952
if ( ! defined( 'SSE_FILTER_MAX_FILE_SIZE' ) ) {
4053
define( 'SSE_FILTER_MAX_FILE_SIZE', 'sse_max_file_size_for_export' );

includes/admin-page.php

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function sse_admin_menu(): void {
1919
add_management_page(
2020
__( 'EngineScript Site Exporter', 'enginescript-site-exporter' ), // Page title (escaped by WordPress core).
2121
__( 'Site Exporter', 'enginescript-site-exporter' ), // Menu title (escaped by WordPress core).
22-
'manage_options', // Capability required.
22+
sse_get_exporter_menu_capability(), // Capability required.
2323
'enginescript-site-exporter',
2424
'sse_exporter_page_html'
2525
);
@@ -123,39 +123,56 @@ function sse_render_exporter_notices(): void {
123123
* @return void
124124
*/
125125
function sse_render_export_success_notice( array $zip_result ): void {
126+
$export_dir_name = basename( dirname( $zip_result['filepath'] ) );
127+
$has_private_dir_param = sse_is_export_private_directory_name( $export_dir_name );
128+
$download_args = [
129+
'action' => 'sse_secure_download',
130+
'file' => $zip_result['filename'],
131+
];
132+
$download_nonce_action = 'sse_secure_download_' . $zip_result['filename'];
133+
134+
if ( $has_private_dir_param ) {
135+
$download_args['export_dir'] = $export_dir_name;
136+
$download_nonce_action .= '_' . $export_dir_name;
137+
}
138+
126139
$download_url = wp_nonce_url(
127140
add_query_arg(
128-
[
129-
'action' => 'sse_secure_download',
130-
'file' => $zip_result['filename'],
131-
],
141+
$download_args,
132142
admin_url( 'admin-post.php' )
133143
),
134-
'sse_secure_download_' . $zip_result['filename']
144+
$download_nonce_action
135145
);
136146

137147
$display_zip_path = wp_normalize_path( $zip_result['filepath'] );
138148
$delete_confirm = __( 'Are you sure you want to delete this export file?', 'enginescript-site-exporter' );
149+
$delete_nonce = 'sse_delete_export_' . $zip_result['filename'];
150+
if ( $has_private_dir_param ) {
151+
$delete_nonce .= '_' . $export_dir_name;
152+
}
139153
?>
140154
<div class="notice notice-success is-dismissible">
141155
<div class="sse-notice-actions">
142156
<span><?php esc_html_e( 'Site export successfully created!', 'enginescript-site-exporter' ); ?></span>
143157
<a href="<?php echo esc_url( $download_url ); ?>" class="button sse-action-button">
144158
<?php esc_html_e( 'Download Export File', 'enginescript-site-exporter' ); ?>
145159
</a>
146-
<form
147-
method="post"
148-
action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"
149-
class="sse-inline-form sse-confirm-delete"
150-
data-sse-confirm-message="<?php echo esc_attr( $delete_confirm ); ?>"
151-
>
152-
<input type="hidden" name="action" value="sse_delete_export">
153-
<input type="hidden" name="file" value="<?php echo esc_attr( $zip_result['filename'] ); ?>">
154-
<?php wp_nonce_field( 'sse_delete_export_' . $zip_result['filename'] ); ?>
155-
<button type="submit" class="button button-secondary sse-action-button">
156-
<?php esc_html_e( 'Delete Export File', 'enginescript-site-exporter' ); ?>
157-
</button>
158-
</form>
160+
<form
161+
method="post"
162+
action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"
163+
class="sse-inline-form sse-confirm-delete"
164+
data-sse-confirm-message="<?php echo esc_attr( $delete_confirm ); ?>"
165+
>
166+
<input type="hidden" name="action" value="sse_delete_export">
167+
<input type="hidden" name="file" value="<?php echo esc_attr( $zip_result['filename'] ); ?>">
168+
<?php if ( $has_private_dir_param ) : ?>
169+
<input type="hidden" name="export_dir" value="<?php echo esc_attr( $export_dir_name ); ?>">
170+
<?php endif; ?>
171+
<?php wp_nonce_field( $delete_nonce ); ?>
172+
<button type="submit" class="button button-secondary sse-action-button">
173+
<?php esc_html_e( 'Delete Export File', 'enginescript-site-exporter' ); ?>
174+
</button>
175+
</form>
159176
</div>
160177
<p><small>
161178
<?php
@@ -177,7 +194,7 @@ class="sse-inline-form sse-confirm-delete"
177194
* @return void
178195
*/
179196
function sse_exporter_page_html(): void {
180-
if ( ! current_user_can( 'manage_options' ) ) {
197+
if ( ! sse_current_user_can_export_site() ) {
181198
sse_wp_die( __( 'You do not have permission to view this page.', 'enginescript-site-exporter' ), 403 );
182199
}
183200

includes/archive.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ function sse_create_compressed_database_file( string $source_path, string $targe
241241
return new WP_Error( 'db_compress_verify_failed', __( 'Compressed database file was not created successfully.', 'enginescript-site-exporter' ) );
242242
}
243243

244+
if ( ! sse_chmod_private_file( $target_path ) ) {
245+
sse_cleanup_files( [ $target_path ] );
246+
return new WP_Error( 'db_compress_permissions_failed', __( 'Could not secure compressed database file permissions.', 'enginescript-site-exporter' ) );
247+
}
248+
244249
return true;
245250
}
246251

@@ -262,6 +267,12 @@ function sse_create_wordpress_files_archive( string $files_archive_path, string
262267

263268
try {
264269
$tar_archive = new PharData( $tar_path );
270+
if ( ! sse_chmod_private_file( $tar_path ) ) {
271+
unset( $tar_archive );
272+
sse_cleanup_files( [ $tar_path, $files_archive_path ] );
273+
return new WP_Error( 'files_archive_permissions_failed', __( 'Could not secure files archive permissions.', 'enginescript-site-exporter' ) );
274+
}
275+
265276
$file_result = sse_add_wordpress_files_to_tar( $tar_archive, $export_dir );
266277
unset( $tar_archive );
267278

@@ -290,6 +301,11 @@ function sse_create_wordpress_files_archive( string $files_archive_path, string
290301
return new WP_Error( 'files_archive_verify_failed', __( 'WordPress files archive was not created successfully.', 'enginescript-site-exporter' ) );
291302
}
292303

304+
if ( ! sse_chmod_private_file( $files_archive_path ) ) {
305+
sse_cleanup_files( [ $files_archive_path ] );
306+
return new WP_Error( 'files_archive_permissions_failed', __( 'Could not secure files archive permissions.', 'enginescript-site-exporter' ) );
307+
}
308+
293309
return true;
294310
}
295311

@@ -321,10 +337,14 @@ function sse_write_engine_script_manifest( array $bundle_paths, string $site_ide
321337
}
322338

323339
global $wp_filesystem;
324-
if ( ! $wp_filesystem->put_contents( $bundle_paths['manifest_path'], $manifest_content, FS_CHMOD_FILE ) ) {
340+
if ( ! $wp_filesystem->put_contents( $bundle_paths['manifest_path'], $manifest_content, SSE_PRIVATE_FILE_MODE ) ) {
325341
return new WP_Error( 'manifest_write_failed', __( 'Could not write EngineScript export manifest.', 'enginescript-site-exporter' ) );
326342
}
327343

344+
if ( ! sse_chmod_private_file( $bundle_paths['manifest_path'] ) ) {
345+
return new WP_Error( 'manifest_permissions_failed', __( 'Could not secure EngineScript export manifest permissions.', 'enginescript-site-exporter' ) );
346+
}
347+
328348
return true;
329349
}
330350

@@ -379,6 +399,11 @@ function sse_create_combined_engine_script_zip( array $bundle_paths ) {
379399
return new WP_Error( 'zip_finalize_failed', __( 'Failed to finalize or save the ZIP archive after processing files.', 'enginescript-site-exporter' ) );
380400
}
381401

402+
if ( ! sse_chmod_private_file( $bundle_paths['combined_zip_path'] ) ) {
403+
sse_cleanup_files( [ $bundle_paths['combined_zip_path'] ] );
404+
return new WP_Error( 'zip_permissions_failed', __( 'Could not secure ZIP archive permissions.', 'enginescript-site-exporter' ) );
405+
}
406+
382407
return true;
383408
}
384409

0 commit comments

Comments
 (0)