Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/groundhogg/admin/contacts/tables/contacts-table.php
+++ b/groundhogg/admin/contacts/tables/contacts-table.php
@@ -167,6 +167,9 @@
$query = apply_filters( 'groundhogg/admin/contacts/search_query', $query );
+ // select is not allowed here
+ unset( $query['select'] );
+
$this->query = $query;
$c_query = new Contact_Query( $query );
--- a/groundhogg/api/v3/contacts-api.php
+++ b/groundhogg/api/v3/contacts-api.php
@@ -311,6 +311,9 @@
$query['order'] = 'ASC';
}
+ // select is unsupported here
+ unset( $query['select'] );
+
$contacts = $contact_query->query( $query );
if ( $is_for_select2 ) {
--- a/groundhogg/api/v4/contacts-api.php
+++ b/groundhogg/api/v4/contacts-api.php
@@ -215,6 +215,9 @@
'found_rows' => true
] );
+ // select is not supported here
+ unset( $query['select'] );
+
if ( $request->get_param( 'count' ) ) {
$count = $contact_query->count( $query );
@@ -362,6 +365,8 @@
}
$contact_query = new Contact_Query();
+ // select is not allowed here
+ unset( $query['select'] );
$contacts = $contact_query->query( $query, true );
$updated = 0;
@@ -655,6 +660,9 @@
return self::SUCCESS_RESPONSE();
}
+ // select is not allowed here
+ unset( $query_vars['select'] );
+
$query = new Contact_Query( $query_vars );
$items = $query->query( null, true );
--- a/groundhogg/db/query/query.php
+++ b/groundhogg/db/query/query.php
@@ -216,59 +216,59 @@
$column_regex = '([A-Za-z0-9_.]+)';
$aggregate_functions = [
- "/^COUNT(DISTINCT($column_regex))/i" => function ( $matches ) {
+ "/^COUNT(DISTINCT($column_regex))$/i" => function ( $matches ) {
return sprintf( "COUNT(DISTINCT(%s))", $this->sanitize_column( $matches[1] ) );
},
- "/^COUNT((?:$column_regex.*))/i" => function ( $matches ) {
+ "/^COUNT((?:$column_regex.*))$/i" => function ( $matches ) {
return "COUNT(*)";
},
- "/^COUNT($column_regex)/i" => function ( $matches ) {
+ "/^COUNT($column_regex)$/i" => function ( $matches ) {
return sprintf( "COUNT(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^COUNT(*)/i" => function ( $matches ) {
+ "/^COUNT(*)$/i" => function ( $matches ) {
return "COUNT(*)";
},
- "/^DISTINCT($column_regex)/i" => function ( $matches ) {
+ "/^DISTINCT($column_regex)$/i" => function ( $matches ) {
return sprintf( "DISTINCT(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^SUM($column_regex)/i" => function ( $matches ) {
+ "/^SUM($column_regex)$/i" => function ( $matches ) {
return sprintf( "SUM(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^AVG($column_regex)/i" => function ( $matches ) {
+ "/^AVG($column_regex)$/i" => function ( $matches ) {
return sprintf( "AVG(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^MAX($column_regex)/i" => function ( $matches ) {
+ "/^MAX($column_regex)$/i" => function ( $matches ) {
return sprintf( "MAX(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^LOWER($column_regex)/i" => function ( $matches ) {
+ "/^LOWER($column_regex)$/i" => function ( $matches ) {
return sprintf( "LOWER(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^UPPER($column_regex)/i" => function ( $matches ) {
+ "/^UPPER($column_regex)$/i" => function ( $matches ) {
return sprintf( "UPPER(%s)", $this->sanitize_column( $matches[1] ) );
},
- "/^COALESCE($column_regex,s*(?:'|")?(w*)(?:'|")?)/i" => function ( $matches ) {
+ "/^COALESCE($column_regex,s*(?:'|")?(w*)(?:'|")?)$/i" => function ( $matches ) {
$column = $this->sanitize_column( $matches[1] );
$format = is_numeric( $matches[2] ) ? '%d' : '%s';
return $this->db->prepare( "COALESCE($column, $format)", $matches[2] );
},
- "/^DATE_FORMAT($column_regex,s*(?:'|")?([^'"]+)(?:'|")?)/i" => function ( $matches ) {
+ "/^DATE_FORMAT($column_regex,s*(?:'|")?([^'"]+)(?:'|")?)$/i" => function ( $matches ) {
$column = $this->sanitize_column( $matches[1] );
$format = is_numeric( $matches[2] ) ? '%d' : '%s';
return $this->db->prepare( "DATE_FORMAT($column, $format)", $matches[2] );
},
- "/^CAST($column_regex as (SIGNED|UNSIGNED|DATE|TIME|INT|DATETIME|DECIMAL([^)]+)))/i" => function ( $matches ) {
+ "/^CAST($column_regex as (SIGNED|UNSIGNED|DATE|TIME|INT|DATETIME|DECIMAL([^)]+)))$/i" => function ( $matches ) {
return sprintf( "CAST(%s as %s)", $this->sanitize_column( $matches[1] ), strtoupper( $matches[2] ) );
},
- "/^DATE(FROM_UNIXTIME($column_regex))/i" => function ( $matches ) {
+ "/^DATE(FROM_UNIXTIME($column_regex))$/i" => function ( $matches ) {
return sprintf( "DATE(FROM_UNIXTIME(%s))", $this->sanitize_column( $matches[1] ) );
},
];
foreach ( $aggregate_functions as $aggregate_regex => $callback ) {
- $column = preg_replace_callback( $aggregate_regex, $callback, $maybe_column, 1, $count );
+ $column = preg_replace_callback( $aggregate_regex, $callback, trim( $maybe_column ), 1, $count );
if ( $count ) {
return $column;
--- a/groundhogg/groundhogg.php
+++ b/groundhogg/groundhogg.php
@@ -3,7 +3,7 @@
* Plugin Name: Groundhogg
* Plugin URI: https://www.groundhogg.io/?utm_source=wp-plugins&utm_campaign=plugin-uri&utm_medium=wp-dash
* Description: CRM and marketing automation for WordPress
- * Version: 4.5.8
+ * Version: 4.5.9
* Author: Groundhogg Inc.
* Author URI: https://www.groundhogg.io/?utm_source=wp-plugins&utm_campaign=author-uri&utm_medium=wp-dash
* Text Domain: groundhogg
@@ -24,7 +24,7 @@
if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
-define( 'GROUNDHOGG_VERSION', '4.5.8' );
+define( 'GROUNDHOGG_VERSION', '4.5.9' );
define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.5.3' );
define( 'GROUNDHOGG__FILE__', __FILE__ );
--- a/groundhogg/includes/classes/email.php
+++ b/groundhogg/includes/classes/email.php
@@ -842,63 +842,79 @@
}
/**
- * Replace the link with another link which has the ?ref UTM which will lead to the original link
+ * Replace href URLs with Groundhogg tracking links.
*
- * @param $matches
+ * @param array $matches
+ * @param string $replacement
*
* @return string
*/
public function tracking_link_callback( $matches, $replacement = 'href="%s"' ) {
- $clean_url = no_and_amp( html_entity_decode( $matches[1] ) );
+ $clean_url = $matches[1] ?? '';
+
+ // Only decode the common HTML attribute encoding we expect from hrefs.
+ // Avoid broad URL normalization before signing.
+ $clean_url = trim( str_replace( '&', '&', $clean_url ) );
- // If the url is not to be tracked leave it alone.
if ( is_url_excluded_from_tracking( $clean_url ) ) {
- return sprintf( $replacement, $clean_url );
+ return sprintf( $replacement, esc_attr( $clean_url ) );
}
- $local_hostname = wp_parse_url( home_url(), PHP_URL_HOST );
+ $local_hostname = wp_parse_url( home_url(), PHP_URL_HOST );
+ $target_hostname = wp_parse_url( $clean_url, PHP_URL_HOST );
- // target hostname and local hostname are the same
- // in that case just use the path as the hostname is not needed.
- if ( wp_parse_url( $clean_url, PHP_URL_HOST ) === $local_hostname ) {
- $regex = preg_quote( $local_hostname );
- $clean_url = preg_replace( "@https?://$regex@", '', $clean_url );
+ // If linking to the same site, store only path/query/fragment.
+ // Do not preg_replace the host globally, because it may also appear inside redirect_to.
+ if ( $target_hostname && $target_hostname === $local_hostname ) {
+ $path = wp_parse_url( $clean_url, PHP_URL_PATH ) ?: '/';
+ $query = wp_parse_url( $clean_url, PHP_URL_QUERY );
+ $fragment = wp_parse_url( $clean_url, PHP_URL_FRAGMENT );
+
+ $clean_url = $path;
+
+ if ( $query !== null && $query !== '' ) {
+ $clean_url .= '?' . $query;
+ }
+
+ if ( $fragment !== null && $fragment !== '' ) {
+ $clean_url .= '#' . $fragment;
+ }
}
- // Tracking link does not support empty
- // "/" sends it to the homepage.
- if ( empty( $clean_url ) ) {
+ if ( $clean_url === '' ) {
$clean_url = '/';
}
- // if using legacy format without computing the signature
- if ( $this->_use_legacy_tracking_links ) {
+ $contact_id_hex = dechex( $this->get_contact()->get_id() );
+ $event_id_hex = dechex( $this->get_event()->get_id( true ) );
+ if ( $this->_use_legacy_tracking_links ) {
$tracking_url = managed_page_url( sprintf(
'c/%s/%s/%s',
- dechex( $this->get_contact()->get_id() ),
- dechex( $this->get_event()->get_id( true ) ),
+ $contact_id_hex,
+ $event_id_hex,
base64url_encode( $clean_url )
- ) );;
+ ) );
- return sprintf( $replacement, $tracking_url );
+ return sprintf( $replacement, esc_url( $tracking_url ) );
}
$payload = implode( '|', [
$clean_url,
- dechex( $this->get_contact()->get_id() ),
- dechex( $this->get_event()->get_id( true ) ),
+ $contact_id_hex,
+ $event_id_hex,
] );
- // Example: https://groundhogg.dev/gh/c/L3ZlcnNpb24tMy03LTIvfDF8MTgyNzM.S9cLmojPjxsRxGKLsTYyvw/
+ $signature = compute_signature( $payload, 16 );
+
$tracking_url = managed_page_url( sprintf(
'c/%s.%s',
base64url_encode( $payload ),
- base64url_encode( compute_signature( $payload, 16 ) )
+ base64url_encode( $signature )
) );
- return sprintf( $replacement, $tracking_url );
+ return sprintf( $replacement, esc_url( $tracking_url ) );
}
/**
--- a/groundhogg/includes/contact-query.php
+++ b/groundhogg/includes/contact-query.php
@@ -2584,6 +2584,11 @@
return $this->count( $query_vars );
}
+ // if the intent is to get contact objects, we should ignore any previously set select clauses
+ if ( $as_objects ) {
+ $this->setSelect( '*' );
+ }
+
try {
$items = $this->get_results();
} catch ( FilterException|Exception $exception ) {
@@ -2620,6 +2625,7 @@
}
if ( $this->groupby ) {
+ $this->setSelect( '*' );
$this->setLimit( 1 );
$this->setOffset( 0 );
$this->setFoundRows( true );
--- a/groundhogg/includes/functions.php
+++ b/groundhogg/includes/functions.php
@@ -590,7 +590,7 @@
* @return array|string|string[]
*/
function base64url_encode( $stuff ) {
- return str_replace( [ '+', '/', '=' ], [ '-', '_', '' ], base64_encode( $stuff ) );
+ return rtrim( strtr( base64_encode( $stuff ), '+/', '-_' ), '=' );
}
/**
@@ -612,7 +612,10 @@
* @return false|string
*/
function base64url_decode( $stuff ) {
- return base64_decode( str_replace( [ '-', '_' ], [ '+', '/' ], $stuff ) );
+ $stuff = str_replace( [ '-', '_' ], [ '+', '/' ], $stuff );
+ $stuff .= str_repeat( '=', ( 4 - strlen( $stuff ) % 4 ) % 4 );
+
+ return base64_decode( $stuff, true );
}
/**
@@ -9671,7 +9674,7 @@
function compute_signature( string $data, int $length = 0, string $secret = '' ){
if ( empty( $secret ) ){
- $secret = utils::get_secret_key();
+ $secret = Utils::get_secret_key();
}
$signature = hash_hmac( 'sha256', $data, $secret, true );
@@ -9689,22 +9692,24 @@
*/
function check_signature( string $data, string $signature, int $length = 0 ) {
- if ( $length > 0 && strlen( $signature ) !== $length ){
- return false;
- }
+ $secrets = [
+ Utils::get_secret_key(),
+ hex2bin( Utils::get_secret_key() ), // whoops
+ wp_salt( 'auth' ),
+ ];
- $secrets = [
- utils::get_secret_key(),
- wp_salt( 'auth' ) // back compat for using the auth salt
- ];
+ foreach ( $secrets as $secret ) {
- foreach ( $secrets as $secret ){
- $expected = compute_signature( $data, $length, $secret );
+ $expected = compute_signature( $data, $length, $secret );
- if ( hash_equals( $signature, $expected ) ){
- return true;
- }
- }
+ if ( strlen( $signature ) !== strlen( $expected ) ) {
+ continue;
+ }
+
+ if ( hash_equals( $expected, $signature ) ) {
+ return true;
+ }
+ }
return false;
}
--- a/groundhogg/includes/tracking.php
+++ b/groundhogg/includes/tracking.php
@@ -349,7 +349,7 @@
$event_id = absint( hexdec( array_pop( $link_parts ) ) );
$contact_id = absint( hexdec( array_pop( $link_parts ) ) );
// in case the url contains `|` we re-implode and start from the back
- $target_url = esc_url_raw( sanitize_text_field( implode( '|', $link_parts ) ) );
+ $target_url = implode( '|', $link_parts );
// signature check failed,
if ( ! check_signature( $id_payload, $signature, 16 ) ){
--- a/groundhogg/includes/utils/utils.php
+++ b/groundhogg/includes/utils/utils.php
@@ -219,15 +219,18 @@
if ( in_array( strtolower( self::$encrypt_method ), map_deep( openssl_get_cipher_methods(), 'strtolower' ) ) ) {
+ $key = self::$secret_key;
+ $iv = self::$secret_iv;
+
//backwards compat
- if ( function_exists( 'ctype_xdigit' ) && ctype_xdigit( self::$secret_key ) ) {
- self::$secret_key = hex2bin( self::$secret_key );
- self::$secret_iv = hex2bin( self::$secret_iv );
+ if ( function_exists( 'ctype_xdigit' ) && ctype_xdigit( $key ) ) {
+ $key = hex2bin( $key );
+ $iv = hex2bin( $iv );
}
$output = false;
- $key = substr( hash( 'sha256', self::$secret_key ), 0, 32 );
- $iv = substr( hash( 'sha256', self::$secret_iv ), 0, 16 );
+ $key = substr( hash( 'sha256', $key ), 0, 32 );
+ $iv = substr( hash( 'sha256', $iv ), 0, 16 );
if ( $action == 'e' ) {
$output = base64_encode( openssl_encrypt( $string, self::$encrypt_method, $key, 0, $iv ) );