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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified wp-puller.zip
Binary file not shown.
8 changes: 6 additions & 2 deletions wp-puller/assets/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
border: 1px solid #c3c4c7;
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
overflow: hidden;
}

.wp-puller-card-header {
Expand All @@ -91,8 +92,6 @@
padding: 12px 16px;
border-bottom: 1px solid #f0f0f1;
background: #f6f7f7;
border-top-right-radius: 3px;
border-top-left-radius: 3px;
}

.wp-puller-card-header h2 {
Expand Down Expand Up @@ -338,6 +337,11 @@
padding: 0 8px !important;
}

.wp-core-ui .button:is([class*="wp-puller"], [id*="wp-puller"]) {
display: flex;
place-items: center;
}

.wp-puller-copy-btn .dashicons {
font-size: 16px;
width: 16px;
Expand Down
28 changes: 21 additions & 7 deletions wp-puller/assets/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,36 @@
var $btn = $(e.currentTarget);
var inputId = $btn.data('copy');
var $input = $('#' + inputId);
var text = $input.val();

if ( navigator.clipboard && window.isSecureContext ) {
navigator.clipboard.writeText( text ).then(function() {
WPPuller.setCopiedState( $btn );
}).catch(function() {
WPPuller.copyFallback( $input, $btn );
});
} else {
WPPuller.copyFallback( $input, $btn );
}
},

copyFallback: function($input, $btn) {
$input.select();

try {
document.execCommand('copy');
$btn.find('.dashicons').removeClass('dashicons-clipboard').addClass('dashicons-yes');

setTimeout(function() {
$btn.find('.dashicons').removeClass('dashicons-yes').addClass('dashicons-clipboard');
}, 1500);
WPPuller.setCopiedState( $btn );
} catch (err) {
// Fallback for older browsers
window.prompt('Copy to clipboard:', $input.val());
}
},

setCopiedState: function($btn) {
$btn.find('.dashicons').removeClass('dashicons-clipboard').addClass('dashicons-yes');
setTimeout(function() {
$btn.find('.dashicons').removeClass('dashicons-yes').addClass('dashicons-clipboard');
}, 1500);
},

setLoading: function($btn, loading) {
if (loading) {
$btn.addClass('wp-puller-btn-loading').prop('disabled', true);
Expand Down
12 changes: 9 additions & 3 deletions wp-puller/includes/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,16 @@ public function ajax_save_settings() {
$auto_update = isset( $_POST['auto_update'] ) && 'true' === $_POST['auto_update'];
$backup_count = isset( $_POST['backup_count'] ) ? absint( $_POST['backup_count'] ) : 3;

// Clean up theme path - remove leading/trailing slashes
// Clean up theme path - remove leading/trailing slashes.
$theme_path = trim( $theme_path, '/' );

// Reject path traversal attempts.
if ( false !== strpos( $theme_path, '..' ) ) {
wp_send_json_error( array(
'message' => __( 'Invalid theme path.', 'wp-puller' ),
) );
}

update_option( 'wp_puller_repo_url', $repo_url );
update_option( 'wp_puller_branch', $branch );
update_option( 'wp_puller_theme_path', $theme_path );
Expand Down Expand Up @@ -399,7 +406,7 @@ public static function get_masked_pat() {
return '';
}

return str_repeat( '*', min( strlen( $decrypted ), 20 ) ) . substr( $decrypted, -4 );
return str_repeat( '*', min( strlen( $decrypted ), 24 ) );
}

/**
Expand Down Expand Up @@ -442,7 +449,6 @@ public static function get_pat_status() {
'decrypts' => true,
'type' => $type,
'length' => strlen( $decrypted ),
'prefix' => substr( $decrypted, 0, 10 ) . '...',
'message' => sprintf( 'Token OK (%s, %d chars)', $type, strlen( $decrypted ) ),
);
}
Expand Down
14 changes: 12 additions & 2 deletions wp-puller/includes/class-backup.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
class WP_Puller_Backup {

/**
* Backup directory name.
* Backup directory base name.
*
* @var string
*/
Expand All @@ -25,10 +25,20 @@ class WP_Puller_Backup {
/**
* Get the backup directory path.
*
* The directory name includes a random suffix stored in the database so it
* is not guessable on servers (e.g. Nginx) where .htaccess has no effect.
*
* @return string
*/
public function get_backup_dir() {
return WP_CONTENT_DIR . '/' . self::BACKUP_DIR;
$suffix = get_option( 'wp_puller_backup_dir_suffix', '' );

if ( empty( $suffix ) ) {
$suffix = wp_generate_password( 16, false );
update_option( 'wp_puller_backup_dir_suffix', $suffix, false );
}

return WP_CONTENT_DIR . '/' . self::BACKUP_DIR . '-' . $suffix;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions wp-puller/includes/class-github-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,17 +218,17 @@ public function download_archive( $owner, $repo, $branch = 'main' ) {
}

$tmp_file = wp_tempnam( 'wp-puller-' );
$response = wp_remote_get( $url, $args );
$response = wp_safe_remote_get( $url, $args );

if ( is_wp_error( $response ) ) {
@unlink( $tmp_file );
unlink( $tmp_file );
return $response;
}

$status_code = wp_remote_retrieve_response_code( $response );

if ( 200 !== $status_code ) {
@unlink( $tmp_file );
unlink( $tmp_file );

if ( 404 === $status_code ) {
return new WP_Error(
Expand Down Expand Up @@ -263,7 +263,7 @@ public function download_archive( $owner, $repo, $branch = 'main' ) {
}

if ( ! $wp_filesystem->put_contents( $tmp_file, $body ) ) {
@unlink( $tmp_file );
unlink( $tmp_file );
return new WP_Error(
'write_failed',
__( 'Failed to save downloaded file.', 'wp-puller' )
Expand Down Expand Up @@ -323,7 +323,7 @@ private function api_request( $endpoint, $args = array() ) {
// Debug logging
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'WP Puller API Request: ' . $url );
error_log( 'WP Puller Auth Header: ' . ( ! empty( $auth_header ) ? substr( $auth_header, 0, 20 ) . '...' : 'none' ) );
error_log( 'WP Puller Auth Header: ' . ( ! empty( $auth_header ) ? 'present' : 'none' ) );
error_log( 'WP Puller Response Code: ' . wp_remote_retrieve_response_code( $response ) );
}

Expand Down
78 changes: 77 additions & 1 deletion wp-puller/includes/class-theme-updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,51 @@ public function __construct( $github_api, $backup, $logger ) {
* @return bool|WP_Error True on success, WP_Error on failure.
*/
public function update( $source = 'manual' ) {
// Prevent concurrent updates from corrupting the theme directory.
if ( ! $this->acquire_update_lock() ) {
$error = new WP_Error(
'update_locked',
__( 'An update is already in progress. Please try again shortly.', 'wp-puller' )
);
$this->logger->log_update_error( $error->get_error_message(), $source );
return $error;
}

$result = $this->do_update( $source );

$this->release_update_lock();

return $result;
}

/**
* Acquire the update lock.
*
* @return bool True if the lock was acquired, false if already locked.
*/
private function acquire_update_lock() {
if ( get_transient( 'wp_puller_update_lock' ) ) {
return false;
}
// 5-minute TTL as a safety net in case the process dies unexpectedly.
set_transient( 'wp_puller_update_lock', 1, 5 * MINUTE_IN_SECONDS );
return true;
}

/**
* Release the update lock.
*/
private function release_update_lock() {
delete_transient( 'wp_puller_update_lock' );
}

/**
* Internal update implementation, called only when the lock is held.
*
* @param string $source Update source.
* @return bool|WP_Error
*/
private function do_update( $source ) {
$repo_url = get_option( 'wp_puller_repo_url', '' );
$branch = get_option( 'wp_puller_branch', 'main' );

Expand Down Expand Up @@ -104,10 +149,32 @@ public function update( $source = 'manual' ) {

$result = $this->install_theme( $zip_file, $parsed['repo'], $branch );

@unlink( $zip_file );
unlink( $zip_file );

if ( is_wp_error( $result ) ) {
$this->logger->log_update_error( $result->get_error_message(), $source );

// Attempt to auto-restore the backup created before this update.
$restore = $this->backup->restore_backup( basename( $backup_path ) );

if ( is_wp_error( $restore ) ) {
$this->logger->log(
sprintf(
/* translators: %s: error message */
__( 'Auto-restore failed after update error: %s', 'wp-puller' ),
$restore->get_error_message()
),
WP_Puller_Logger::STATUS_ERROR,
WP_Puller_Logger::SOURCE_SYSTEM
);
} else {
$this->logger->log(
__( 'Theme auto-restored from backup after failed update.', 'wp-puller' ),
WP_Puller_Logger::STATUS_INFO,
WP_Puller_Logger::SOURCE_SYSTEM
);
}

return $result;
}

Expand Down Expand Up @@ -219,6 +286,15 @@ private function install_theme( $zip_file, $repo, $branch ) {
// Handle theme in subdirectory
$theme_path = get_option( 'wp_puller_theme_path', '' );
if ( ! empty( $theme_path ) ) {
// Validate at use time as a second line of defense against path traversal.
if ( false !== strpos( $theme_path, '..' ) ) {
$wp_filesystem->delete( $temp_dir, true );
return new WP_Error(
'invalid_path',
__( 'Invalid theme path.', 'wp-puller' )
);
}

$extracted_dir = $extracted_dir . '/' . $theme_path;

if ( ! is_dir( $extracted_dir ) ) {
Expand Down
59 changes: 49 additions & 10 deletions wp-puller/includes/class-webhook-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ public static function get_webhook_url() {
* @return WP_REST_Response
*/
public function handle_webhook( $request ) {
if ( ! $this->check_rate_limit() ) {
$response = new WP_REST_Response(
array(
'success' => false,
'message' => 'Too many requests.',
),
429
);
$response->header( 'Retry-After', '60' );
return $response;
}

$signature = $request->get_header( 'X-Hub-Signature-256' );
$event = $request->get_header( 'X-GitHub-Event' );
$delivery = $request->get_header( 'X-GitHub-Delivery' );
Expand All @@ -100,16 +112,6 @@ public function handle_webhook( $request ) {
WP_Puller_Logger::SOURCE_WEBHOOK
);

if ( 'ping' === $event ) {
return new WP_REST_Response(
array(
'success' => true,
'message' => 'Pong! Webhook is configured correctly.',
),
200
);
}

if ( empty( $signature ) ) {
$this->logger->log(
__( 'Webhook rejected: missing signature', 'wp-puller' ),
Expand Down Expand Up @@ -144,6 +146,16 @@ public function handle_webhook( $request ) {
);
}

if ( 'ping' === $event ) {
return new WP_REST_Response(
array(
'success' => true,
'message' => 'Pong! Webhook is configured correctly.',
),
200
);
}

if ( 'push' !== $event ) {
return new WP_REST_Response(
array(
Expand Down Expand Up @@ -258,6 +270,33 @@ private function handle_push_event( $payload ) {
);
}

/**
* Check whether the current request is within the rate limit.
*
* Allows a maximum of 10 requests per minute per IP address.
*
* @return bool True if within limit, false if exceeded.
*/
private function check_rate_limit() {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

if ( empty( $ip ) ) {
return true;
}

$transient_key = 'wp_puller_rl_' . md5( $ip );
$count = (int) get_transient( $transient_key );

if ( $count >= 10 ) {
return false;
}

// Increment counter; start a fresh 60-second window on first request.
set_transient( $transient_key, $count + 1, 60 );

return true;
}

/**
* Verify GitHub webhook signature.
*
Expand Down
Loading