Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/nextgen-gallery/adminApp/build/dependencies.php
+++ b/nextgen-gallery/adminApp/build/dependencies.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '713cb6f59506e3d395e8');
+<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => 'f7afa128be1833048fd9');
--- a/nextgen-gallery/nggallery.php
+++ b/nextgen-gallery/nggallery.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: NextGEN Gallery
* Description: The most popular gallery plugin for WordPress and one of the most popular plugins of all time with over 30 million downloads.
- * Version: 4.2.0
+ * Version: 4.2.1
* Author: Imagely
* Plugin URI: https://www.imagely.com/wordpress-gallery-plugin/nextgen-gallery/?utm_source=ngglite&utm_medium=pluginlist&utm_campaign=pluginuri
* Author URI: https://www.imagely.com/?utm_source=ngglite&utm_medium=pluginlist&utm_campaign=authoruri
@@ -1221,7 +1221,7 @@
define( 'NGG_PRODUCT_DIR', implode( DIRECTORY_SEPARATOR, [ rtrim( NGG_PLUGIN_DIR, '/\' ), 'products' ] ) );
define( 'NGG_MODULE_DIR', implode( DIRECTORY_SEPARATOR, [ rtrim( NGG_PRODUCT_DIR, '/\' ), 'photocrati_nextgen', 'modules' ] ) );
define( 'NGG_PLUGIN_STARTED_AT', microtime() );
- define( 'NGG_PLUGIN_VERSION', '4.2.0' );
+ define( 'NGG_PLUGIN_VERSION', '4.2.1' );
$random_version = function_exists( 'wp_rand' ) ? wp_rand( 0, mt_getrandmax() ) : mt_rand( 0, mt_getrandmax() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand
define( 'NGG_SCRIPT_VERSION', defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? (string) $random_version : NGG_PLUGIN_VERSION );
--- a/nextgen-gallery/products/photocrati_nextgen/modules/legacy_compat/package.module.legacy_compat.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/legacy_compat/package.module.legacy_compat.php
@@ -722,8 +722,10 @@
$total = $wpdb->get_var("SELECT COUNT(`pid`) FROM {$wpdb->nggpictures} {$old_where_sql}");
$image_ids = [];
if ($total <= $limit) {
+ $total_int = (int) $total;
+ // SQLi hardening: cast COUNT() result to int before LIMIT interpolation.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
- $image_ids = $wpdb->get_col("SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total}");
+ $image_ids = $wpdb->get_col("SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total_int}");
} else {
// Start retrieving random ID from the DB and hope they exist; continue looping until our count is full.
$segments = ceil($limit / 4);
@@ -744,12 +746,21 @@
// in the same images being retrieved for the duration of that page execution.
self::$_random_image_ids_cache[$id] = $image_ids;
}
- $image_ids = implode(',', $image_ids);
- // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
- $mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}"];
+ // SQLi hardening: force every PID to int before IN() interpolation — prepare() cannot parameterize a comma-joined list.
+ $int_ids = array_filter(array_map('intval', (array) $image_ids));
+ if (empty($int_ids)) {
+ // Guard against invalid SQL from an empty IN() list.
+ $mapper->_where_clauses = [' 1=0'];
+ } else {
+ $image_ids = implode(',', $int_ids);
+ // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
+ $mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}"];
+ }
} else {
+ $limit_int = (int) $limit;
+ // SQLi hardening: cast LIMIT to int; callers may pass shortcode/REST maximum_entity_count.
// Replace the existing WHERE clause with one that selects from a sub-query that is randomly ordered.
- $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit}";
+ $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit_int}";
$mapper->_where_clauses = [" {$noExtras} `{$image_key}` IN (SELECT `{$image_key}` FROM ({$sub_where}) o) {$noExtras}"];
}
}
@@ -790,6 +801,8 @@
{
global $wpdb;
$mod = wp_rand(3, 9);
+ $limit_int = (int) $limit;
+ // SQLi hardening: cast LIMIT to int — callers may pass derived user input.
if (empty($where_sql)) {
$where_sql = 'WHERE 1=1';
}
@@ -797,7 +810,7 @@
//
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
- return $wpdb->get_col("SELECT `pictures`.`pid` from {$wpdb->nggpictures} `pictures`n JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`n {$where_sql}n AND `pictures`.`pid` MOD {$mod} = 0n LIMIT {$limit}");
+ return $wpdb->get_col("SELECT `pictures`.`pid` from {$wpdb->nggpictures} `pictures`n JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`n {$where_sql}n AND `pictures`.`pid` MOD {$mod} = 0n LIMIT {$limit_int}");
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
--- a/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_admin/templates/mailchimp_optin.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_admin/templates/mailchimp_optin.php
@@ -125,7 +125,7 @@
success: function(result) {
if (result.result === "success") {
- fetch('<?php print esc_js( $dismiss_url ); ?>', {
+ fetch(<?php echo wp_json_encode( esc_url_raw( $dismiss_url ) ); ?>, { // esc_url_raw — URL sanitize; wp_json_encode — JS-string context (URL, not HTML attr) with surrounding quotes.
method: 'post',
cache: 'no-cache'
}).then(function(result) { return result.json(); }).then(function(data) {
--- a/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_basic_album/package.module.nextgen_basic_album.php
+++ b/nextgen-gallery/products/photocrati_nextgen/modules/nextgen_basic_album/package.module.nextgen_basic_album.php
@@ -316,7 +316,8 @@
$dg = $gallery->displayed_gallery;
$id = $dg->id();
$retval .= 'galleries.gallery_' . $id . ' = ' . wp_json_encode($dg->get_entity()) . ';';
- $retval .= 'galleries.gallery_' . $id . '.wordpress_page_root = "' . get_permalink() . '";';
+ $retval .= 'galleries.gallery_' . $id . '.wordpress_page_root = ' . wp_json_encode((string) get_permalink()) . ';';
+ // wp_json_encode — JS-string context; raw permalink could contain ", breaking literal.
}
$retval .= '}, false);</script>';
return $retval;
--- a/nextgen-gallery/src/Admin/AMNotifications.php
+++ b/nextgen-gallery/src/Admin/AMNotifications.php
@@ -461,6 +461,11 @@
// Run a security check.
check_ajax_referer( 'nextgen_dismiss_notification', 'nonce' );
+ // Capability gate added: has_access() only checks an option filter, not user caps — require manage_options before option write.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( '', 403 );
+ }
+
// Check for access and required param.
if ( ! $this->has_access() || empty( $_POST['id'] ) ) {
wp_send_json_error();
--- a/nextgen-gallery/src/Admin/About.php
+++ b/nextgen-gallery/src/Admin/About.php
@@ -644,7 +644,12 @@
// Activate the addon.
if ( isset( $_POST['basename'] ) ) {
- $activate = activate_plugin( sanitize_text_field( wp_unslash( $_POST['basename'] ) ) );
+ $basename_raw = sanitize_text_field( wp_unslash( $_POST['basename'] ) ); // Ingestion-point sanitize before any comparison/use.
+ // CSRF-bypass hardening: restrict activation to the partner plugin allowlist so a stolen nonce can't activate an unrelated installed plugin.
+ if ( ! in_array( $basename_raw, $this->get_am_plugin_basenames(), true ) ) {
+ wp_send_json_error( [ 'message' => esc_html__( 'Plugin is not in the allowed list.', 'nggallery' ) ] );
+ }
+ $activate = activate_plugin( $basename_raw );
if ( is_wp_error( $activate ) ) {
echo wp_json_encode( [ 'error' => $activate->get_error_message() ] );
@@ -656,6 +661,32 @@
die;
}
+ /**
+ * Build allowlist of partner plugin basenames advertised by the About page.
+ *
+ * Used to constrain activate_plugin()/deactivate_plugins() inputs so the CSRF-protected
+ * partner actions cannot be abused to toggle arbitrary plugins installed on the site.
+ *
+ * @return array<int,string>
+ */
+ private function get_am_plugin_basenames() {
+ $basenames = [];
+ foreach ( $this->get_am_plugins() as $key => $partner ) {
+ // Array key is sometimes the basename itself (legacy entries), sometimes a slug.
+ if ( is_string( $key ) && false !== strpos( $key, '.php' ) ) {
+ $basenames[] = $key;
+ }
+ if ( ! empty( $partner['basename'] ) && is_string( $partner['basename'] ) ) {
+ $basenames[] = $partner['basename'];
+ }
+ // Pro variant basename lives under `pro.plug` in get_am_plugins().
+ if ( ! empty( $partner['pro']['plug'] ) && is_string( $partner['pro']['plug'] ) ) {
+ $basenames[] = $partner['pro']['plug'];
+ }
+ }
+ return array_values( array_unique( $basenames ) );
+ }
+
/**
* Helper method to deactivate partner
@@ -674,7 +705,12 @@
// Deactivate the addon.
if ( isset( $_POST['basename'] ) ) {
- deactivate_plugins( sanitize_text_field( wp_unslash( $_POST['basename'] ) ) );
+ $basename_raw = sanitize_text_field( wp_unslash( $_POST['basename'] ) ); // Sanitize at ingestion.
+ // CSRF-bypass hardening: same allowlist as activate_am_plugin() to prevent toggling unrelated plugins.
+ if ( ! in_array( $basename_raw, $this->get_am_plugin_basenames(), true ) ) {
+ wp_send_json_error( [ 'message' => esc_html__( 'Plugin is not in the allowed list.', 'nggallery' ) ] );
+ }
+ deactivate_plugins( $basename_raw );
}
echo wp_json_encode( true );
@@ -700,6 +736,35 @@
if ( isset( $_POST['download_url'] ) ) {
$download_url = esc_url_raw( wp_unslash( $_POST['download_url'] ) );
+
+ // Host+path allowlist: restrict installable package source to specific distribution paths so a future
+ // arbitrary file upload, open redirect, or media attachment on a broad host (wordpress.org, imagely.com)
+ // cannot be coerced into installing an attacker-controlled plugin zip via admin CSRF.
+ $parsed_dl_url = wp_parse_url( $download_url );
+ $dl_host = isset( $parsed_dl_url['host'] ) ? strtolower( $parsed_dl_url['host'] ) : '';
+ $dl_scheme = isset( $parsed_dl_url['scheme'] ) ? strtolower( $parsed_dl_url['scheme'] ) : '';
+ $dl_path = isset( $parsed_dl_url['path'] ) ? $parsed_dl_url['path'] : '';
+
+ // Tuples of [host, path-prefix-regex]. Path must end in .zip after the .zip-only suffix check below.
+ $allowed_sources = [
+ [ 'downloads.wordpress.org', '#^/plugin/[A-Za-z0-9._-]+.zip$#' ],
+ [ 'www.imagely.com', '#^/(downloads|dl)/[A-Za-z0-9._/-]+.zip$#' ],
+ [ 'imagely.com', '#^/(downloads|dl)/[A-Za-z0-9._/-]+.zip$#' ],
+ ];
+
+ $source_ok = false;
+ if ( 'https' === $dl_scheme ) {
+ foreach ( $allowed_sources as $allowed ) {
+ if ( $dl_host === $allowed[0] && preg_match( $allowed[1], $dl_path ) ) {
+ $source_ok = true;
+ break;
+ }
+ }
+ }
+ if ( ! $source_ok ) {
+ wp_send_json_error( [ 'message' => esc_html__( 'Download URL is not from an allowed host.', 'nggallery' ) ] );
+ }
+
global $hook_suffix;
// Set the current screen to avoid undefined notices.
@@ -740,6 +805,16 @@
if ( $installer->plugin_info() ) {
$plugin_basename = $installer->plugin_info();
+ // Activate only basenames registered in get_am_plugins(); same gate sibling activate/deactivate use.
+ if ( ! in_array( $plugin_basename, $this->get_am_plugin_basenames(), true ) ) {
+ wp_send_json_error(
+ [
+ 'message' => esc_html__( 'Installed plugin is not in the partner allowlist; activation skipped.', 'nggallery' ),
+ 'plugin' => $plugin_basename,
+ ]
+ );
+ }
+
$active = activate_plugin( $plugin_basename, false, false, true );
wp_send_json_success( [ 'plugin' => $plugin_basename ] );
--- a/nextgen-gallery/src/Admin/MenuNudge.php
+++ b/nextgen-gallery/src/Admin/MenuNudge.php
@@ -284,6 +284,10 @@
*/
public function mark_admin_menu_tooltip_hidden() {
check_ajax_referer( 'ngg-tooltip-admin-nonce', 'nonce' );
+ // Capability gate added: nonce alone insufficient — enforce manage_options before option write.
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( '', 403 );
+ }
update_option( 'ngg_admin_menu_tooltip', time() );
wp_send_json_success();
}
--- a/nextgen-gallery/src/Admin/Onboarding_Wizard.php
+++ b/nextgen-gallery/src/Admin/Onboarding_Wizard.php
@@ -557,12 +557,18 @@
// Sanitize data, plugins is a string delimited by comma.
$plugins = explode( ',', sanitize_text_field( wp_unslash( $_POST['plugins'] ) ) );
+ // Allowlist: only slugs returned by get_recommended_plugins() may be installed via this endpoint; prevents arbitrary wp.org plugin install from attacker-supplied slug.
+ $allowed_slugs = array_keys( $this->get_recommended_plugins() );
// Install the plugins.
foreach ( $plugins as $plugin ) {
+ $plugin = sanitize_key( $plugin ); // Enforce slug charset (a-z0-9_-) so crafted values cannot break out of the downloads.wordpress.org URL path.
+ if ( ! in_array( $plugin, $allowed_slugs, true ) ) {
+ continue; // Reject any slug not in the recommended allowlist.
+ }
if ( '' !== $this->is_recommended_plugin_installed( $plugin ) ) {
continue; // Skip the plugin if it is already installed.
}
- // Generate the plugin URL by slug.
+ // Generate the plugin URL by slug (slug now validated against allowlist above).
$url = 'https://downloads.wordpress.org/plugin/' . $plugin . '.zip';
$this->install_helper( $url );
@@ -605,6 +611,12 @@
* Uses shared LicenseHelper utility for license verification and installation.
*/
public function ngg_plugin_verify_license_key() {
+ // Capability guard: handler installs/activates plugins and writes license options; restrict to site admins, matching sibling onboarding handlers (save_onboarding_data, install_recommended_plugins).
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'You do not have permission to verify license keys' );
+ wp_die();
+ }
+
if (
! isset( $_POST['nextgen-gallery-license-key'], $_POST['nonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'nextgen-galleryOnboardingCheck' )
--- a/nextgen-gallery/src/Admin/Shortcode_Preview.php
+++ b/nextgen-gallery/src/Admin/Shortcode_Preview.php
@@ -255,7 +255,7 @@
<?php echo esc_html( $debug_data['raw_shortcode'] ); ?>
<br>
<br>
- <?php echo $debug_data['sorted_shortcode']; //phpcs:ignore ?>
+ <?php echo wp_kses( $debug_data['sorted_shortcode'], [ 'br' => [] ] ); // Fixed: was raw echo of user-derived shortcode params joined by <br>; wp_kses entity-encodes quotes/amps while preserving intended <br> separators. ?>
</div>
<?php endif; ?>
</div>
--- a/nextgen-gallery/src/DataMapper/DriverBase.php
+++ b/nextgen-gallery/src/DataMapper/DriverBase.php
@@ -201,7 +201,8 @@
* @return string
*/
public function _clean_column( $val ) {
- return str_replace( [ ';', "'", '"', '`' ], [ '' ], $val );
+ // Security fix (SQLi): replace blacklist with identifier whitelist. Only allow [A-Za-z0-9_] to prevent injection into ORDER BY/column contexts.
+ return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $val );
}
/**
--- a/nextgen-gallery/src/DataMapper/TableDriver.php
+++ b/nextgen-gallery/src/DataMapper/TableDriver.php
@@ -163,16 +163,29 @@
// We treat the rand() function as an exception.
if ( preg_match( '/rand(s*)/', $order_by ) ) {
$order = 'rand()';
+ } elseif ( preg_match( "/^FIELD\(\s*[`']?([A-Za-z_][A-Za-z0-9_]*)[`']?\s*,\s*([0-9,\s]+)\)$/", (string) $order_by, $m ) ) {
+ // Security fix (SQLi): allow the legacy FIELD(<ident>, <int-list>) form used for preserving IN() result order. Both identifier and integer list are strictly validated so no user-controlled string can reach SQL here.
+ $ident = $m[1];
+ $ints = array_filter( array_map( 'intval', preg_split( '/s*,s*/', trim( $m[2] ) ) ) );
+ $int_csv = implode( ',', $ints );
+ $order = "FIELD(`{$ident}`, {$int_csv})";
} else {
+ // Security fix (SQLi): identifier whitelist via _clean_column leaves only [A-Za-z0-9_], safe to inline in ORDER BY without requiring has_column() (aliased SELECT columns like 'new_sortorder'/'ordered_by' are legitimate but not in _table_columns).
$order_by = $this->_clean_column( $order_by );
-
- // If the order by clause is a column, then it should be backticked.
+ if ( '' === $order_by ) {
+ // Empty after sanitization — refuse to build clause to avoid SQL error and potential open-ORDER-BY.
+ return $this;
+ }
if ( $this->has_column( $order_by ) ) {
$order_by = "`{$order_by}`";
}
- $direction = $this->_clean_column( $direction );
- $order = "{$order_by} {$direction}";
+ // Security fix (SQLi): strict whitelist for direction; default to ASC if not exactly ASC/DESC.
+ $direction = strtoupper( $this->_clean_column( $direction ) );
+ if ( 'ASC' !== $direction && 'DESC' !== $direction ) {
+ $direction = 'ASC';
+ }
+ $order = "{$order_by} {$direction}";
}
$this->order_clauses[] = $order;
@@ -234,25 +247,56 @@
public function add_where_clause( $where_clauses, $join ) {
$clauses = [];
+ // Security fix (SQLi defense-in-depth): allowlist of comparators. Anything outside this list is
+ // coerced to '=' so caller-supplied $compare cannot inject query-shape-changing fragments.
+ $allowed_compares = [ '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'BETWEEN', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT' ];
+
foreach ( $where_clauses as $clause ) {
- extract( $clause );
+ // Replaced extract() with explicit reads: extract() on a structured array is a foot-gun and would clobber locals if upstream schema gains new keys.
+ $column = isset( $clause['column'] ) ? $clause['column'] : '';
+ $value = isset( $clause['value'] ) ? $clause['value'] : '';
+ $compare = isset( $clause['compare'] ) ? $clause['compare'] : '=';
+ $type = isset( $clause['type'] ) ? $clause['type'] : 'string';
+
+ // Security fix (SQLi defense-in-depth): identifier hardening for $column. Schema-defined
+ // columns are backticked as before; everything else is forced through the same identifier
+ // allowlist used by order_by() ([A-Za-z0-9_]). Empty result drops the clause rather than
+ // concatenating raw caller input into SQL.
if ( $this->has_column( $column ) ) {
$column = "`{$column}`";
+ } else {
+ $column = $this->_clean_column( (string) $column );
+ if ( '' === $column ) {
+ continue;
+ }
+ $column = "`{$column}`";
}
+
+ // Security fix (SQLi defense-in-depth): strict comparator allowlist (was: any string passed
+ // through). Normalize to upper-case + trim before lookup so 'in' / ' IN ' still match.
+ $compare = strtoupper( trim( (string) $compare ) );
+ if ( ! in_array( $compare, $allowed_compares, true ) ) {
+ $compare = '=';
+ }
+
if ( ! is_array( $value ) ) {
$value = [ $value ];
}
foreach ( $value as $index => $v ) {
- $v = $clause['type'] == 'numeric' ? $v : "'{$v}'";
+ // esc_sql + single-quote wrap for string values; raw cast for numeric. Prevents SQLi when callers pass user-influenced strings.
+ $v = ( 'numeric' === $type ) ? (float) $v : "'" . esc_sql( $v ) . "'";
$value[ $index ] = $v;
}
- if ( $compare == 'BETWEEN' ) {
+ if ( 'BETWEEN' === $compare ) {
$value = "{$value[0]} AND {$value[1]}";
} else {
$value = implode( ', ', $value );
- if ( strpos( $compare, 'IN' ) !== false ) {
+ // Exact match on the comparator string (not substring) so values like 'INFO' / 'INSERT'
+ // never sneak through into IN-style parenthesization. After normalization above the
+ // only IN-family entries are 'IN' and 'NOT IN'.
+ if ( 'IN' === $compare || 'NOT IN' === $compare ) {
$value = "({$value})";
}
}
--- a/nextgen-gallery/src/DataTypes/DisplayedGallery.php
+++ b/nextgen-gallery/src/DataTypes/DisplayedGallery.php
@@ -559,8 +559,9 @@
$image_ids = [];
if ( $total <= $limit ) {
+ $total_int = (int) $total; // SQLi hardening: cast COUNT() result to int before LIMIT interpolation.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
- $image_ids = $wpdb->get_col( "SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total}" );
+ $image_ids = $wpdb->get_col( "SELECT `pictures`.`pid` FROM {$wpdb->nggpictures} `pictures` {$old_where_sql} LIMIT {$total_int}" );
} else {
// Start retrieving random ID from the DB and hope they exist; continue looping until our count is full.
$segments = ceil( $limit / 4 );
@@ -585,13 +586,20 @@
self::$_random_image_ids_cache[ $id ] = $image_ids;
}
- $image_ids = implode( ',', $image_ids );
-
- // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
- $mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}" ];
+ // SQLi hardening: force every PID to int before IN() interpolation — prepare() cannot parameterize a comma-joined list.
+ $int_ids = array_filter( array_map( 'intval', (array) $image_ids ) );
+ if ( empty( $int_ids ) ) {
+ // Guard against invalid SQL from an empty IN() list.
+ $mapper->where_clauses = [ ' 1=0' ];
+ } else {
+ $image_ids = implode( ',', $int_ids );
+ // Replace the existing WHERE clause with one where aready retrieved "random" PID are included.
+ $mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN ({$image_ids}) {$noExtras}" ];
+ }
} else {
+ $limit_int = (int) $limit; // SQLi hardening: cast LIMIT to int; callers may pass shortcode/REST maximum_entity_count.
// Replace the existing WHERE clause with one that selects from a sub-query that is randomly ordered.
- $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit}";
+ $sub_where = "SELECT `{$image_key}` FROM `{$table_name}` i {$old_where_sql} ORDER BY RAND() LIMIT {$limit_int}";
$mapper->where_clauses = [ " {$noExtras} `{$image_key}` IN (SELECT `{$image_key}` FROM ({$sub_where}) o) {$noExtras}" ];
}
}
@@ -636,7 +644,8 @@
*/
public function _query_random_ids_for_cache( $limit = 10, $where_sql = '' ) {
global $wpdb;
- $mod = wp_rand( 3, 9 );
+ $mod = wp_rand( 3, 9 );
+ $limit_int = (int) $limit; // SQLi hardening: cast LIMIT to int — callers may pass derived user input.
if ( empty( $where_sql ) ) {
$where_sql = 'WHERE 1=1';
@@ -651,7 +660,7 @@
JOIN (SELECT CEIL(MAX(`pid`) * RAND()) AS `pid` FROM {$wpdb->nggpictures}) AS `x` ON `pictures`.`pid` >= `x`.`pid`
{$where_sql}
AND `pictures`.`pid` MOD {$mod} = 0
- LIMIT {$limit}"
+ LIMIT {$limit_int}"
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
--- a/nextgen-gallery/src/DynamicStylesheets/Controller.php
+++ b/nextgen-gallery/src/DynamicStylesheets/Controller.php
@@ -32,17 +32,27 @@
// gallery customization for style.
//
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- $data = isset( $_REQUEST['data'] ) ? Sanitization::recursive_stripslashes( wp_unslash( $_REQUEST['data'] ) ) : null;
- $name = isset( $_REQUEST['name'] ) ? Sanitization::recursive_stripslashes( wp_unslash( $_REQUEST['name'] ) ) : null;
+ // Force scalar + unslash + strip; reject arrays/objects to prevent type-juggling into template lookup/decode.
+ $raw_data = isset( $_REQUEST['data'] ) && is_scalar( $_REQUEST['data'] ) ? (string) wp_unslash( $_REQUEST['data'] ) : null;
+ $raw_name = isset( $_REQUEST['name'] ) && is_scalar( $_REQUEST['name'] ) ? (string) wp_unslash( $_REQUEST['name'] ) : null;
+ $data = null !== $raw_data ? Sanitization::recursive_stripslashes( $raw_data ) : null;
+ // sanitize_key restricts template name to [a-z0-9_-], blocks path traversal / unexpected chars in array key lookup.
+ $name = null !== $raw_name ? sanitize_key( Sanitization::recursive_stripslashes( $raw_name ) ) : null;
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( isset( $data ) && isset( $name ) ) {
+ if ( isset( $data ) && isset( $name ) && '' !== $name ) {
$manager = Manager::get_instance( 'all' );
+ // Whitelist: only render if name maps to a registered template; prevents undefined-offset + arbitrary template key lookup.
+ $template = $manager->get_css_template( $name );
+ if ( false === $template || null === $template ) {
+ return;
+ }
+
if ( C_NextGEN_Bootstrap::get_pro_api_version() < 4.0 ) {
- $view = new C_MVC_View( $manager->get_css_template( $name ), $manager->decode( $data ) );
+ $view = new C_MVC_View( $template, $manager->decode( $data ) );
} else {
- $view = new View( $manager->get_css_template( $name ), $manager->decode( $data ) );
+ $view = new View( $template, $manager->decode( $data ) );
}
return $view->render( $return_output );
--- a/nextgen-gallery/src/DynamicStylesheets/Manager.php
+++ b/nextgen-gallery/src/DynamicStylesheets/Manager.php
@@ -55,7 +55,8 @@
* @return string|false The template path or false if not found.
*/
public function get_css_template( $name ) {
- return $this->templates[ $name ];
+ // isset guard prevents PHP warning + undefined-index leak on unregistered names; returns false so callers can reject.
+ return isset( $this->templates[ $name ] ) ? $this->templates[ $name ] : false;
}
/**
--- a/nextgen-gallery/src/Legacy/admin/ajax.php
+++ b/nextgen-gallery/src/Legacy/admin/ajax.php
@@ -2,6 +2,46 @@
add_action( 'wp_ajax_ngg_ajax_operation', 'ngg_ajax_operation' );
/**
+ * Resolve an NGG image id to its parent gallery's author and check current user can edit.
+ *
+ * Owner of the parent gallery, or holder of 'NextGEN Manage others gallery', passes.
+ * Missing image returns true so the caller emits its own not-found response shape.
+ *
+ * @param int $image_id NGG picture id (pid).
+ * @return bool
+ */
+function ngg_ajax_user_can_edit_image( $image_id ) {
+ $image_id = (int) $image_id;
+ if ( $image_id <= 0 ) {
+ return false;
+ }
+
+ $image = nggdb::find_image( $image_id );
+ if ( ! $image ) {
+ return true;
+ }
+
+ $gallery_id = isset( $image->galleryid ) ? (int) $image->galleryid : 0;
+ if ( $gallery_id <= 0 ) {
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown
+ return current_user_can( 'NextGEN Manage others gallery' );
+ }
+
+ $gallery = ImagelyNGGDataMappersGallery::get_instance()->find( $gallery_id );
+ if ( ! $gallery ) {
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown
+ return current_user_can( 'NextGEN Manage others gallery' );
+ }
+
+ if ( get_current_user_id() === (int) $gallery->author ) {
+ return true;
+ }
+
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown
+ return current_user_can( 'NextGEN Manage others gallery' );
+}
+
+/**
* Image edit functions via AJAX
*
* @author Alex Rabe
@@ -35,11 +75,23 @@
// Get the image id.
if ( isset( $_POST['image'] ) ) {
$id = (int) sanitize_text_field( wp_unslash( $_POST['image'] ) );
+
+ if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+ die( '-1' );
+ }
+
// let's get the image data.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $_POST['image'] is sanitized on line 36
$picture = nggdb::find_image( $id );
// what do you want to do ?
$operation = isset( $_POST['operation'] ) ? sanitize_text_field( wp_unslash( $_POST['operation'] ) ) : '';
+
+ // Bounds the dynamic ngg_ajax_<operation> hook to addon-registered op names.
+ $allowed_dynamic_operations = (array) apply_filters(
+ 'ngg_ajax_operation_allowlist',
+ []
+ );
+
switch ( $operation ) {
case 'create_thumbnail':
$result = nggAdmin::create_thumbnail( $picture );
@@ -87,7 +139,9 @@
$result = '1';
break;
default:
- do_action( 'ngg_ajax_' . $operation );
+ if ( $operation !== '' && in_array( $operation, $allowed_dynamic_operations, true ) ) {
+ do_action( 'ngg_ajax_' . $operation, $id );
+ }
die( '-1' );
}
// A success should return a '1'.
@@ -118,6 +172,10 @@
$id = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );
+ if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+ die( '-1' );
+ }
+
$x = round( ( isset( $_POST['x'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['x'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
$y = round( ( isset( $_POST['y'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['y'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
$w = round( ( isset( $_POST['w'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['w'] ) ) ) : 0 ) * ( isset( $_POST['rr'] ) ? floatval( sanitize_text_field( wp_unslash( $_POST['rr'] ) ) ) : 1 ), 0 );
@@ -174,7 +232,12 @@
// include the ngg function.
include_once __DIR__ . '/functions.php';
- $id = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );
+ $id = (int) ( isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : 0 );
+
+ if ( ! ngg_ajax_user_can_edit_image( $id ) ) {
+ die( '-1' );
+ }
+
$result = '-1';
$ra = isset( $_POST['ra'] ) ? sanitize_text_field( wp_unslash( $_POST['ra'] ) ) : '';
--- a/nextgen-gallery/src/Legacy/admin/edit-thumbnail.php
+++ b/nextgen-gallery/src/Legacy/admin/edit-thumbnail.php
@@ -104,7 +104,7 @@
<script type="text/javascript">
var status = 'edit';
var xT, yT, wT, hT, selectedCoords;
- var selectedImage = "thumb<?php echo esc_js( $id ); ?>";
+ var selectedImage = "thumb<?php echo (int) $id; ?>"; // (int) cast — numeric ID appended to JS string literal, guarantees digits only.
function showPreview(coords) {
if (status != 'edit') {
@@ -113,12 +113,12 @@
status = 'edit';
}
- var rx = <?php echo esc_js( $WidthHtmlPrev ); ?> / coords.w;
- var ry = <?php echo esc_js( $HeightHtmlPrev ); ?> / coords.h;
+ var rx = <?php echo (float) $WidthHtmlPrev; ?> / coords.w; // (float) cast — bare JS numeric literal; esc_js would not enforce numeric.
+ var ry = <?php echo (float) $HeightHtmlPrev; ?> / coords.h; // (float) cast — bare JS numeric literal.
jQuery('#imageToEditPreview').css({
- width: Math.round(rx * <?php echo esc_js( $resizedPreviewInfo['newWidth'] ); ?>) + 'px',
- height: Math.round(ry * <?php echo esc_js( $resizedPreviewInfo['newHeight'] ); ?>) + 'px',
+ width: Math.round(rx * <?php echo (float) $resizedPreviewInfo['newWidth']; ?>) + 'px', // (float) cast — bare JS numeric literal in expression.
+ height: Math.round(ry * <?php echo (float) $resizedPreviewInfo['newHeight']; ?>) + 'px', // (float) cast — bare JS numeric literal in expression.
marginLeft: '-' + Math.round(rx * coords.x) + 'px',
marginTop: '-' + Math.round(ry * coords.y) + 'px'
});
@@ -146,7 +146,7 @@
w: wT,
h: hT,
action: 'createNewThumb',
- id: <?php echo esc_js( $id ); ?>, rr: <?php echo esc_js( str_replace( ',', '.', $rr ) ); ?>,
+ id: <?php echo (int) $id; ?>, rr: <?php echo (float) str_replace( ',', '.', $rr ); ?>, // (int)/(float) casts — bare JS numeric literals in object; eliminates need for esc_js.
nonce: nonce
},
cache: false,
@@ -202,7 +202,7 @@
onChange: showPreview,
onSelect: showPreview,
<?php echo $default_crop_js_parameter; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Contains safe JavaScript parameters ?>
- aspectRatio: <?php echo esc_js( str_replace( ',', '.', round( $WidthHtmlPrev / $HeightHtmlPrev, 3 ) ) ); ?>
+ aspectRatio: <?php echo (float) str_replace( ',', '.', round( $WidthHtmlPrev / $HeightHtmlPrev, 3 ) ); ?> <?php // (float) cast — bare JS numeric literal; esc_js not appropriate for numeric context. ?>
});
});
})(jQuery);
--- a/nextgen-gallery/src/Legacy/admin/manage-sort.php
+++ b/nextgen-gallery/src/Legacy/admin/manage-sort.php
@@ -84,7 +84,7 @@
// In the case somebody presort, then we take this url.
if ( isset( $_GET['dir'] ) || isset( $_GET['presort'] ) ) {
- $base_url = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
+ $base_url = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // Fixed: sanitize_text_field preserved quotes/control chars in URL; esc_url_raw validates URL at ingestion before use in add_query_arg/output.
} else {
$base_url = $clean_url;
}
@@ -168,43 +168,43 @@
<ul class="subsubsub">
<li><?php esc_html_e( 'Presort', 'nggallery' ); ?>:</li>
- <li><a href="<?php print esc_attr( remove_query_arg( 'presort', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( remove_query_arg( 'presort', $base_url ) ); ?>"<?php // Fixed: esc_attr wrong context for href; esc_url blocks javascript:/data: schemes in URL attribute. ?>
<?php
if ( $presort == '' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Unsorted', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'presort', 'pid', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'presort', 'pid', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url (scheme validation), not esc_attr. ?>
<?php
if ( $presort == 'pid' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Image ID', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'presort', 'filename', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'presort', 'filename', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
<?php
if ( $presort == 'filename' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Filename', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'presort', 'alttext', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'presort', 'alttext', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
<?php
if ( $presort == 'alttext' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Alt/Title text', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'presort', 'imagedate', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'presort', 'imagedate', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
<?php
if ( $presort == 'imagedate' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Date/Time', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'dir', 'ASC', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'dir', 'ASC', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
<?php
if ( $dir == 'ASC' ) {
print 'class="current"';}
?>
><?php esc_html_e( 'Ascending', 'nggallery' ); ?></a> |</li>
- <li><a href="<?php print esc_attr( add_query_arg( 'dir', 'DESC', $base_url ) ); ?>"
+ <li><a href="<?php print esc_url( add_query_arg( 'dir', 'DESC', $base_url ) ); ?>"<?php // Fixed: href context needs esc_url, not esc_attr. ?>
<?php
if ( $dir == 'DESC' ) {
print 'class="current"';}
--- a/nextgen-gallery/src/Legacy/admin/manage.php
+++ b/nextgen-gallery/src/Legacy/admin/manage.php
@@ -649,6 +649,13 @@
$id = sanitize_text_field( wp_unslash( $id ) );
$gallery = $mapper->find( $id );
+ if ( ! $gallery ) {
+ continue;
+ }
+ // Per-row ownership: can_manage_this_gallery() returns true for the gallery author or for users holding 'NextGEN Manage others gallery'.
+ if ( ! nggAdmin::can_manage_this_gallery( $gallery->author ) ) {
+ continue;
+ }
if ( $gallery->path == '../' || false !== strpos( $gallery->path, '/../' ) ) {
/* translators: %s: gallery ID */
nggGallery::show_message( sprintf( __( 'One or more "../" in Gallery paths could be unsafe and NextGen Gallery will not delete gallery %s automatically', 'nggallery' ), $gallery->{$gallery->id_field} ) );
@@ -912,17 +919,26 @@
}
if ( $this->gallery ) {
- $excludes = [ '_wpnonce', '_wp_http_referer', 'nggpage' ];
- foreach ( $_POST as $key => $value ) {
- // Yet another IIS hack: gallery paths can be mangled into \wp-content\blah\ which causes
- // later errors when validating the gallery path: just automatically replace \ with / here.
- if ( $key === 'path' ) {
- $value = str_replace( '\\', '/', $value );
+ // Allowlist matches the editable inputs in templates/manage_gallery/gallery_*_field.php.
+ // Iterating $_POST blindly would let columns like author/gid/extras_post_id/slug be set.
+ $editable_gallery_fields = [ 'title', 'galdesc', 'previewpic', 'path', 'pageid' ];
+
+ foreach ( $editable_gallery_fields as $field ) {
+ if ( ! isset( $_POST[ $field ] ) ) {
+ continue;
}
- if ( ! in_array( $key, $excludes, true ) ) {
- $this->gallery->$key = $value;
+ if ( $field === 'path' ) {
+ // IIS hack: gallery paths can be mangled into \wp-content\blah\ which causes later errors when validating the gallery path.
+ $value = str_replace( '\\', '/', sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) );
+ } elseif ( $field === 'previewpic' || $field === 'pageid' ) {
+ $value = (int) wp_unslash( $_POST[ $field ] );
+ } else {
+ // title/galdesc are pre-sanitized higher in this branch (sanitize_text_field + strip_tags allowlist) and written back into $_POST; read that processed value.
+ $value = wp_unslash( $_POST[ $field ] );
}
+
+ $this->gallery->$field = $value;
}
$mapper->save( $this->gallery );
@@ -951,6 +967,11 @@
// Rescan folder.
if ( isset( $_POST['scanfolder'] ) ) {
+ // Outer nonce + page-level cap is not enough; folder import targets a specific gallery row.
+ if ( ! $this->can_user_manage_gallery() ) {
+ return;
+ }
+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
$gallerypath = $wpdb->get_var(
$wpdb->prepare(
@@ -967,8 +988,17 @@
// Add a new page.
if ( isset( $_POST['addnewpage'] ) ) {
- $parent_id = isset( $_POST['parent_id'] ) ? esc_attr( sanitize_text_field( wp_unslash( $_POST['parent_id'] ) ) ) : '';
- $gallery_title = isset( $_POST['title'] ) ? esc_attr( sanitize_text_field( wp_unslash( $_POST['title'] ) ) ) : '';
+ // Branch mutates a specific gallery and publishes a page — gate on gallery ownership and on publish_pages
+ // because wp_insert_post() does not enforce capabilities when called directly.
+ if ( ! $this->can_user_manage_gallery() ) {
+ return;
+ }
+ if ( ! current_user_can( 'publish_pages' ) ) {
+ return;
+ }
+
+ $parent_id = isset( $_POST['parent_id'] ) ? (int) wp_unslash( $_POST['parent_id'] ) : 0;
+ $gallery_title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
$mapper = GalleryMapper::get_instance();
$gallery = $mapper->find( $this->gid );
$gallery_name = $gallery->name;
@@ -1021,6 +1051,7 @@
$image_mapper = ImageMapper::get_instance();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- wp_unslash only removes slashes, values are sanitized on line 976 and later
+ $current_gallery_id = isset( $this->gallery->{$this->gallery->id_field} ) ? (int) $this->gallery->{$this->gallery->id_field} : 0;
foreach ( wp_unslash( $_POST['images'] ) as $pid => $data ) {
$pid = sanitize_text_field( wp_unslash( $pid ) );
if ( ! isset( $data['exclude'] ) ) {
@@ -1028,6 +1059,13 @@
}
$image = $image_mapper->find( $pid );
if ( $image ) {
+ // Cross-gallery IDOR guard: $_POST['images'] is keyed by raw pid from the form payload;
+ // reject any image whose galleryid does not match the gallery the form was rendered for.
+ // can_user_manage_gallery() above only verifies ownership of $this->gallery, so without this
+ // check a user managing gallery A could rewrite fields on images in gallery B.
+ if ( 0 === $current_gallery_id || (int) $image->galleryid !== $current_gallery_id ) {
+ continue;
+ }
// Strip slashes from title/description/alttext fields.
if ( isset( $data['description'] ) ) {
$data['description'] = ImagelyNGGDisplayI18N::ngg_sanitize_text_alt_title_desc( $data['description'] );
@@ -1044,9 +1082,14 @@
$data['image_slug'] = null; // will cause a new slug to be generated.
}
- // Update all fields.
- foreach ( $data as $key => $value ) {
- $image->$key = $value;
+ // image_slug is included because the alttext-change branch above sets it to null to trigger regeneration.
+ // Iterating $data blindly would allow columns like galleryid/meta_data/post_id/extras_post_id/imagedate to be overwritten via crafted images[<pid>][...] POST.
+ $editable_image_fields = [ 'alttext', 'description', 'title', 'exclude', 'tags', 'image_slug' ];
+
+ foreach ( $editable_image_fields as $field ) {
+ if ( array_key_exists( $field, $data ) ) {
+ $image->$field = $data[ $field ];
+ }
}
if ( $image_mapper->save( $image ) ) {
++$updated;
--- a/nextgen-gallery/src/Legacy/admin/media-upload.php
+++ b/nextgen-gallery/src/Legacy/admin/media-upload.php
@@ -143,13 +143,20 @@
// Get the images.
if ( $galleryID != 0 ) {
+ // SQLi hardening: allowlist sort column + direction read from plugin options before interpolating into ORDER BY.
+ $allowed_sort_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid' ];
+ $gal_sort = isset( $ngg->options['galSort'] ) ? (string) $ngg->options['galSort'] : 'sortorder';
+ $gal_sort = in_array( $gal_sort, $allowed_sort_cols, true ) ? $gal_sort : 'sortorder';
+ $gal_sort_dir = ( isset( $ngg->options['galSortDir'] ) && 'DESC' === strtoupper( (string) $ngg->options['galSortDir'] ) ) ? 'DESC' : 'ASC';
+ $start_int = absint( $start ); // Cast LIMIT offset to int to block injection via $start.
+
// Using %i in $wpdb->prepare() to signify column identifiers was only added in WP 6.2
//
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
$picarray = $wpdb->get_col(
$wpdb->prepare(
- "SELECT DISTINCT `pid` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d AND `exclude` != 1 ORDER BY {$ngg->options['galSort']}, `pid` {$ngg->options['galSortDir']} LIMIT {$start}, 10",
+ "SELECT DISTINCT `pid` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d AND `exclude` != 1 ORDER BY `{$gal_sort}`, `pid` {$gal_sort_dir} LIMIT {$start_int}, 10",
[
$galleryID,
]
@@ -369,8 +376,9 @@
$ajax_nonce = wp_create_nonce( "set_post_thumbnail-$calling_post_id" );
}
$second_nonce = wp_create_nonce( 'ngg_set_post_thumbnails' );
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Complex HTML structure with onclick handler for WordPress media functionality
- echo "<a class='ngg-post-thumbnail' id='ngg-post-thumbnail-" . $picid . "' href='#' onclick='NGGSetAsThumbnail("$picid", "$ajax_nonce", "$second_nonce"); return false;'>" . esc_html__( 'Use as featured image', 'nggallery' ) . '</a>';
+ $picid_int = (int) $picid; // Cast to int — id used in HTML attr and JS args, int eliminates quote/backslash injection.
+ $onclick_args = wp_json_encode( $picid_int ) . ',' . wp_json_encode( $ajax_nonce ) . ',' . wp_json_encode( $second_nonce ); // wp_json_encode — correct JS-context escape for string/int literals, replaces raw concat.
+ echo "<a class='ngg-post-thumbnail' id='ngg-post-thumbnail-" . esc_attr( $picid_int ) . "' href='#' onclick='" . esc_attr( 'NGGSetAsThumbnail(' . $onclick_args . '); return false;' ) . "'>" . esc_html__( 'Use as featured image', 'nggallery' ) . '</a>'; // esc_attr wraps onclick value — HTML-attr context around JS payload.
echo "<a class='ngg-post-thumbnail-standin' href='#' style='display:none;'></a>";
?>
<button type="submit" id="ngg-mlitp-<?php echo esc_attr( $picid ); ?>" class="button ngg-mlitp" value="1" name="send[<?php echo esc_attr( $picid ); ?>]"><?php esc_html_e( 'Insert into Post', 'nggallery' ); ?></button>
--- a/nextgen-gallery/src/Legacy/admin/rotate.php
+++ b/nextgen-gallery/src/Legacy/admin/rotate.php
@@ -44,8 +44,8 @@
?>
<script type='text/javascript'>
- var selectedImage = "thumb<?php echo esc_js( $id ); ?>";
- var rotateImageNonce = '<?php print esc_attr( wp_create_nonce( 'ngg-rotate-image' ) ); ?>';
+ var selectedImage = "thumb<?php echo (int) $id; ?>"; // (int) cast — $id appended in JS string, numeric guarantee prevents break.
+ var rotateImageNonce = <?php echo wp_json_encode( wp_create_nonce( 'ngg-rotate-image' ) ); ?>; // wp_json_encode — JS-string context; esc_attr was HTML-entity encode, wrong context.
function rotateImage() {
@@ -57,7 +57,7 @@
data: {
action: 'rotateImage',
nonce: rotateImageNonce,
- id: <?php print esc_attr( $id ); ?>,
+ id: <?php echo (int) $id; ?>, // (int) cast — bare JS numeric literal; esc_attr returned HTML entities, not safe in JS context.
ra: rotate_angle
},
cache: false,
--- a/nextgen-gallery/src/Legacy/admin/thumbnails-template.php
+++ b/nextgen-gallery/src/Legacy/admin/thumbnails-template.php
@@ -34,34 +34,47 @@
}
if ( is_array( $thumb_sizes ) ) {
- $size_selected = null;
- $size_select_html = "<select name='{$thumbnails_template_name}' id='{$thumbnails_template_id}' onchange='"
- . 'var jt = jQuery(this);'
+ $size_selected = null;
+
+ // XSS hardening: escape all dynamic pieces per context (attr vs JS string) before building select markup.
+ $name_attr = esc_attr( $thumbnails_template_name );
+ $id_attr = esc_attr( $thumbnails_template_id );
+ $width_name_js = esc_js( $thumbnails_template_width_name );
+ $height_name_js = esc_js( $thumbnails_template_height_name );
+ $width_value_js = esc_js( (string) $thumbnails_template_width_value );
+ $height_value_js = esc_js( (string) $thumbnails_template_height_value );
+
+ // Build JS separately then esc_attr() the whole string: esc_js() escapes ' as ' which does NOT
+ // prevent attribute breakout in single-quoted HTML attributes — only HTML-encoding (') does.
+ $onchange_js = 'var jt = jQuery(this);'
. ' var szcust = jt.next(".nextgen-thumb-size-custom");'
. ' if (jt.val() == "custom") {'
- . " szcust.find("[name=\"{$thumbnails_template_width_name}\"]").val("{$thumbnails_template_width_value}");"
- . " szcust.find("[name=\"{$thumbnails_template_height_name}\"]").val("{$thumbnails_template_height_value}");"
+ . " szcust.find("[name=\"{$width_name_js}\"]").val("{$width_value_js}");"
+ . " szcust.find("[name=\"{$height_name_js}\"]").val("{$height_value_js}");"
. ' szcust.show();'
. ' } else {'
. ' var parts = jt.val().split("x");'
. ' szcust.hide();'
- . " szcust.find("[name=\"{$thumbnails_template_width_name}\"]").val(parts[0]);"
- . " szcust.find("[name=\"{$thumbnails_template_height_name}\"]").val(parts[1]);"
- . " }'>";
+ . " szcust.find("[name=\"{$width_name_js}\"]").val(parts[0]);"
+ . " szcust.find("[name=\"{$height_name_js}\"]").val(parts[1]);"
+ . ' }';
+
+ $size_select_html = "<select name='{$name_attr}' id='{$id_attr}' onchange='" . esc_attr( $onchange_js ) . "'>";
foreach ( $thumb_sizes as $thumb_size ) {
$thumb_size_parts = explode( 'x', $thumb_size );
$thumb_width = $thumb_size_parts[0];
$thumb_height = $thumb_size_parts[1];
- $size_select_html .= "n" . '<option value="' . $thumb_size . '"';
+ // Escape option value (attr) and text (HTML) from stored option to neutralize XSS payloads.
+ $size_select_html .= "n" . '<option value="' . esc_attr( $thumb_size ) . '"';
if ( $thumbnails_template_width_value == $thumb_width && $thumbnails_template_height_value == $thumb_height ) {
$size_selected = $thumb_size;
$size_select_html .= ' selected';
}
- $size_select_html .= '>' . $thumb_size . '</option>';
+ $size_select_html .= '>' . esc_html( $thumb_size ) . '</option>';
}
$size_select_html .= "n" . '<option value="custom"';
--- a/nextgen-gallery/src/Legacy/lib/core.php
+++ b/nextgen-gallery/src/Legacy/lib/core.php
@@ -223,15 +223,18 @@
* @return bool $result of capability check
*/
public static function current_user_can( $capability ) {
+ // Map NGG caps that are referenced by code but never installed by UtilInstaller::set_role_caps()
+ // to the installed parent cap. Without this mapping a passthrough current_user_can() check
+ // against an uninstalled cap returns false for every role, including admins.
+ static $ngg_cap_alias_map = [
+ 'NextGEN Edit gallery options' => 'NextGEN Manage gallery',
+ 'NextGEN Add new gallery' => 'NextGEN Manage gallery',
+ 'NextGEN Import image folder' => 'NextGEN Upload images',
+ ];
- global $_ngg_capabilites;
+ $effective_cap = isset( $ngg_cap_alias_map[ $capability ] ) ? $ngg_cap_alias_map[ $capability ] : $capability;
- if ( is_array( $_ngg_capabilites ) ) {
- if ( in_array( $capability, $_ngg_capabilites ) ) {
- return current_user_can( $capability );
- }
- }
-
- return true;
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown -- NGG-specific custom caps registered by UtilInstaller::set_role_caps().
+ return current_user_can( $effective_cap );
}
}
--- a/nextgen-gallery/src/Legacy/lib/ngg-db.php
+++ b/nextgen-gallery/src/Legacy/lib/ngg-db.php
@@ -128,6 +128,9 @@
// Say no to any other value
$order_dir = ( $order_dir == 'DESC' ) ? 'DESC' : 'ASC';
$order_by = ( empty( $order_by ) ) ? 'sortorder' : $order_by;
+ // SQLi hardening: allowlist nggpictures column names for ORDER BY before SQL interpolation.
+ $allowed_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid', 'image_slug', 'description', 'exclude' ];
+ $order_by = in_array( $order_by, $allowed_cols, true ) ? $order_by : 'sortorder';
// Query database
if ( is_numeric( $id ) ) {
@@ -473,6 +476,9 @@
// Say no to any other value
$order_dir = ( $order_dir == 'DESC' ) ? 'DESC' : 'ASC';
$order_by = ( empty( $order_by ) ) ? 'galleryid' : $order_by;
+ // SQLi hardening: allowlist nggpictures column names for ORDER BY before SQL interpolation.
+ $allowed_cols = [ 'pid', 'sortorder', 'imagedate', 'filename', 'alttext', 'galleryid', 'image_slug', 'description', 'exclude' ];
+ $order_by = in_array( $order_by, $allowed_cols, true ) ? $order_by : 'galleryid';
$sql = $wpdb->prepare(
"SELECT t.*, tt.*
--- a/nextgen-gallery/src/Legacy/lib/post-thumbnail.php
+++ b/nextgen-gallery/src/Legacy/lib/post-thumbnail.php
@@ -156,18 +156,21 @@
die( '-1' );
}
- if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( $_REQUEST['nonce'], 'ngg_set_post_thumbnails' ) ) {
+ // Sanitize + unslash nonce prior to verification per WP standards (defense-in-depth).
+ if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ), 'ngg_set_post_thumbnails' ) ) {
die( '-1' );
}
// get the post id as global variable, otherwise the ajax_nonce failed later
- $post_ID = intval( $_REQUEST['post_id'] );
+ // wp_unslash added before intval() per WP input handling standards (strip magic-quote slashes before cast).
+ $post_ID = isset( $_REQUEST['post_id'] ) ? intval( wp_unslash( $_REQUEST['post_id'] ) ) : 0;
if ( ! current_user_can( 'edit_post', $post_ID ) ) {
die( '-1' );
}
- $thumbnail_id = intval( $_REQUEST['thumbnail_id'] );
+ // wp_unslash added before intval() per WP input handling standards (strip magic-quote slashes before cast).
+ $thumbnail_id = isset( $_REQUEST['thumbnail_id'] ) ? intval( wp_unslash( $_REQUEST['thumbnail_id'] ) ) : 0;
// delete the image
if ( $thumbnail_id == '-1' ) {
@@ -175,6 +178,11 @@
die( '1' );
}
+ // Scope NGG image selection: edit_post alone lets an author attach arbitrary NGG images (incl. private/admin-only galleries) as featured image. Require the NGG Attach Interface cap so only users granted gallery-attach rights may pick NGG images. Removal branch above is not gated since it only clears post meta on a post the user already owns.
+ if ( ! ImagelyNGGUtilSecurity::is_allowed( 'NextGEN Attach Interface' ) ) {
+ die( '-1' );
+ }
+
$attachment_id = StorageManager::get_instance()->set_post_thumbnail( $post_ID, $thumbnail_id, TRUE );
if ( $attachment_id ) {
die( strval( $attachment_id ) );
@@ -217,7 +225,8 @@
$img_src = trailingslashit( home_url() ) . 'index.php?callback=image&pid=' . $image->pid . '&width=' . $width . '&height=' . $height . '&mode=crop';
}
- $thumbnail_html = '<img width="266" src="' . $img_src . '" alt="' . $image->alttext . '" title="' . $image->alttext . '" />';
+ // Escape URL + attribute data from DB to prevent stored XSS via image alttext in admin featured-image meta box.
+ $thumbnail_html = '<img width="266" src="' . esc_url( $img_src ) . '" alt="' . esc_attr( $image->alttext ) . '" title="' . esc_attr( $image->alttext ) . '" />';
if ( !empty( $thumbnail_html ) ) {
$ajax_nonce = wp_create_nonce( "set_post_thumbnail-$post_ID" );
--- a/nextgen-gallery/src/REST/Admin/AttachToPost.php
+++ b/nextgen-gallery/src/REST/Admin/AttachToPost.php
@@ -122,23 +122,73 @@
}
public function get_images( $request ) {
- global $wpdb;
-
$response = [];
$params = $request->get_param( 'displayed_gallery' );
+ if ( ! is_array( $params ) ) {
+ $params = [];
+ }
$storage = StorageManager::get_instance();
$image_mapper = ImageMapper::get_instance();
$displayed_gallery = new DisplayedGallery();
- foreach ( $params as $key => $value ) {
- $key = $wpdb->_escape( $key );
- // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
- if ( ! in_array( $key, [ 'container_ids', 'entity_ids', 'sortorder' ] ) ) {
- $value = esc_sql( $value );
+ // Per-property allowlist + type cast for DisplayedGallery params arriving from the REST body.
+ // Anything outside the allowlist is silently dropped; values are cast per group rather than
+ // piped through esc_sql() (which is not a substitute for $wpdb->prepare()).
+ $id_array_props = [ 'container_ids', 'entity_ids', 'excluded_container_ids', 'gallery_ids', 'image_ids', 'tag_ids', 'album_ids', 'ids', 'exclusions' ];
+ $string_props = [ 'display_type', 'order_by', 'order_direction', 'returns', 'source', 'src', 'slug', 'sortorder', 'transient_id' ];
+ $int_props = [ 'ID', 'id', 'maximum_entity_count', 'images_list_count' ];
+ $bool_props = [ 'is_album_gallery', 'skip_excluding_globally_excluded_images', 'tagcloud' ];
+ $assoc_props = [ 'display_settings' ];
+ $text_props = [ 'effect_code', 'inner_content', 'display' ];
+
+ foreach ( $params as $raw_key => $raw_value ) {
+ if ( ! is_string( $raw_key ) ) {
+ continue;
+ }
+ $key = sanitize_key( $raw_key );
+ if ( '' === $key ) {
+ continue;
+ }
+
+ if ( in_array( $key, $id_array_props, true ) ) {
+ if ( is_array( $raw_value ) ) {
+ $value = array_values( array_filter( array_map( 'absint', $raw_value ) ) );
+ } elseif ( is_string( $raw_value ) ) {
+ $value = array_values(
+ array_filter(
+ array_map(
+ 'absint',
+ array_map( 'trim', explode( ',', $raw_value ) )
+ )
+ )
+ );
+ } else {
+ $value = [];
+ }
+ } elseif ( in_array( $key, $string_props, true ) ) {
+ $value = is_scalar( $raw_value ) ? sanitize_text_field( (string) $raw_value ) : '';
+ } elseif ( in_array( $key, $int_props, true ) ) {
+ $value = is_scalar( $raw_value ) ? (int) $raw_value : 0;
+ } elseif ( in_array( $key, $bool_props, true ) ) {
+ $value = (bool) $raw_value;
+ } elseif ( in_array( $key, $assoc_props, true ) ) {
+ if ( is_array( $raw_value ) ) {
+ $value = $raw_value;
+ } elseif ( is_string( $raw_value ) ) {
+ $decoded = json_decode( $raw_value, true );
+ $value = is_array( $decoded ) ? $decoded : [];
+ } else {
+ $value = [];
+ }
+ } elseif ( in_array( $key, $text_props, true ) ) {
+ $value = is_scalar( $raw_value ) ? wp_kses_post( (string) $raw_value ) : '';
+ } else {
+ continue;
}
+
$displayed_gallery->$key = $value;
}
--- a/nextgen-gallery/src/REST/DataMappers/AddonsREST.php
+++ b/nextgen-gallery/src/REST/DataMappers/AddonsREST.php
@@ -202,8 +202,8 @@
'google_analytics' => 'Google Analytics',
'instagram' => 'Instagram',
];
- $display_name = isset( $feature_display_names[ $addon_id ] ) ? $feature_display_names[ $addon_id ] : $addon_id;
- $noun = __( 'Feature', 'nggallery' );
+ $display_name