DiscourseConnect: проверка HMAC не проходит в WordPress, несмотря на совпадение параметров callback

Привет,

Я настраиваю DiscourseConnect между сайтом на WordPress и хостинговой инстанцией Discourse и столкнулся с устойчивой ошибкой валидации HMAC-подписи.

Конфигурация

  • WordPress выступает стороной аутентификации/провайдера.
  • Discourse использует DiscourseConnect.
  • WordPress получает запрос DiscourseConnect и возвращает callback, содержащий sso и sig.
  • Callback доходит до Discourse, но Discourse отклоняет его, так как HMAC-подпись не проходит валидацию.

Мы провели достаточно обширную отладку и значительно сузили круг проблем.

Что мы подтвердили

  • wp_unslash() не изменяет значения callback.
  • Входные данные для валидации EA/WordPress совпадают с параметрами, восстановленными из наблюдаемой строки запроса WordPress.
  • Ожидаемый код callback действительно выполняется.
  • Развернутые исходные файлы соответствуют нашему манифесту сборки.
  • Мы проверили наличие проблем с неправильными файлами и дублированием определений.
  • PHP-FPM был перезапущен после последних исправлений, поэтому это не проблема со старым процессом PHP или состоянием opcache.
  • Полностью новый запрос DiscourseConnect после этих изменений по-прежнему не проходит валидацию HMAC.

Другими словами, текущая ошибка воспроизводится при новом запросе.

Что нам пока не удалось доказать

  1. Что эффективный секрет, используемый Discourse во время выполнения, побайтово идентичен секрету, используемому WordPress.
  2. Что полезная нагрузка sso не изменяется где-либо до момента, когда WordPress наблюдает запрос.

На данном этапе мы не хотим продолжать слепое изменение настроек или кода.

Вопрос

Для текущих версий Discourse/DiscourseConnect, какой лучший способ определить, какая именно полезная нагрузка и секрет используются Discourse при расчете ожидаемого HMAC?

Существует ли рекомендуемый метод отладки/логирования, который позволил бы нам сравнить входные данные для HMAC на стороне Discourse с входными данными на стороне WordPress, не раскрывая фактический секрет публично?

Если есть известные проблемы, связанные с обработкой WordPress/PHP, кодированием URL, полезными нагрузками Base64, обратными прокси или хостинговым Discourse, которые могли бы привести к такой ситуации, я буду благодарен за указания.

При необходимости я могу предоставить очищенные значения запроса/callback, соответствующий код WordPress и логи.

Спасибо.

Так что же вы используете на стороне WordPress? Плагин WP-Discourse? Если нет, не могли бы вы опубликовать ваш код?

Привет, Ричард. WP-Discourse установлен, но его провайдер DiscourseConnect и функции синхронизации входа отключены.

Мы используем небольшой собственный плагин WordPress в качестве провайдера DiscourseConnect. Он получает параметры sso и sig от Discourse, проверяет входящий HMAC с использованием общего секрета, а затем формирует и подписывает ответ для отправки обратно в Discourse.

В настоящее время сбой происходит на этапе входящего запроса от Discourse к WordPress — наша свежая диагностика показывает, что пересчитанный HMAC не совпадает с полученным от Discourse sig.

Буду рад опубликовать соответствующий PHP-код обратного вызова/валидации. Перед публикацией я удалю значения конфигурации и секреты.

То есть всё наоборот, верно?

Очевидным решением было бы использовать плагин WP-Discourse, но, скорее всего, у вас есть веские причины, чтобы этого не делать.
Да, пожалуйста, опубликуйте код.

Да — вы правы. В исходном сообщении я перепутал формулировки.

Текущий сбой происходит в направлении Discourse → WordPress.

Discourse формирует запрос DiscourseConnect, содержащий sso и sig. WordPress получает его, и наш собственный провайдер EA пытается проверить подпись. Проверка входящего HMAC не проходит, поэтому WordPress останавливается на этом этапе. Он не доходит до этапа отправки данных аутентифицированного пользователя обратно в Discourse.

WP-Discourse установлен, но его провайдер DiscourseConnect и функции синхронизации при входе пользователей отключены. В настоящее время мы используем собственный провайдер EA, так как хотим, чтобы слой аккаунта/профиля EA оставался под нашим контролем.

Ниже я приведу соответствующий код обратного вызова/конфигурации/проверки HMAC, удалив все секреты и частные настройки.

Это путь санитизированной стандартной входящей проверки DiscourseConnect (Discourse → WordPress) в кастомном провайдере WordPress от EA.

<?php
// Константы конфигурации определены в другом месте; их частные значения опущены.

final class EA_Discourse_Connect {
	public function __construct() {
		add_action( 'admin_post_ea_discourse_connect', array( $this, 'connect' ) );
		add_action( 'admin_post_nopriv_ea_discourse_connect', array( $this, 'connect' ) );
	}

	public static function enabled() {
		return defined( 'EA_DISCOURSE_CONNECT_ENABLED' ) && true === EA_DISCOURSE_CONNECT_ENABLED;
	}

	public static function config() {
		$url = defined( 'EA_DISCOURSE_URL' ) ? EA_DISCOURSE_URL : '';
		$secret = defined( 'EA_DISCOURSE_CONNECT_SECRET' ) ? EA_DISCOURSE_CONNECT_SECRET : '';

		if ( ! is_string( $url ) || ! preg_match( '~\Ahttps://[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9_-]+)*/?\z~i', $url ) ) {
			$url = '';
		}

		return array(
			'url'    => rtrim( $url, '/' ),
			'secret' => is_string( $secret ) ? $secret : '',
		);
	}

	public static function ready() {
		$c = self::config();

		return self::enabled()
			&& $c['url']
			&& strlen( $c['secret'] ) >= 32
			&& 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
	}

	private function fail( $message, $code = 400 ) {
		wp_die(
			esc_html( $message ),
			'Вход в сообщество EA',
			array( 'response' => $code )
		);
	}

	public static function validate( $payload, $signature, $config ) {
		if (
			! is_string( $payload )
			|| ! is_string( $signature )
			|| strlen( $payload ) > 8192
			|| ! preg_match( '/\A[a-f0-9]{64}\z/', $signature )
			|| ! hash_equals(
				hash_hmac( 'sha256', $payload, $config['secret'] ),
				$signature
			)
		) {
			return false;
		}

		$decoded = base64_decode( $payload, true );

		if ( false === $decoded ) {
			return false;
		}

		parse_str( $decoded, $params );

		if (
			empty( $params['nonce'] )
			|| ! is_string( $params['nonce'] )
			|| strlen( $params['nonce'] ) > 256
			|| ! isset( $params['return_sso_url'] )
			|| $config['url'] . '/session/sso_login' !== $params['return_sso_url']
		) {
			return false;
		}

		return $params['nonce'];
	}

	public function connect() {
		// Настройка заголовков ответа опущена.

		if ( ! self::enabled() ) {
			$this->fail( 'Вход в сообщество отключен.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'Вход в сообщество не настроен.', 503 );
		}

		$c = self::config();

		$payload = isset( $_GET['sso'] )
			? wp_unslash( $_GET['sso'] )
			: null;

		$sig = isset( $_GET['sig'] )
			? wp_unslash( $_GET['sig'] )
			: null;

		$nonce = self::validate( $payload, $sig, $c );

		if ( false === $nonce ) {
			$this->fail(
				'Недействительный запрос на вход в сообщество. Начните заново со стороны сообщества.'
			);
		}

		// Последующий вход в WordPress и код ответа с аутентификацией опущены.
	}
}

// Инициализируется при загрузке плагина:
new EA_Discourse_Connect();

Вам следует использовать urldecode() для параметров, а не wp_unslash()

Спасибо, Ричард. Я проверил этот момент с учётом обработки запросов в PHP/WordPress и текущего потока подписи в Discourse.

В нашем колбэке мы читаем данные из $_GET, поэтому PHP уже декодировал параметры запроса из URL до того, как EA их получает. wp_unslash() лишь отменяет экранирование запросов WordPress; он не выполняет URL-декодирование.

Применение urldecode() снова на этом этапе приведёт к двойному декодированию значения и может превратить + в Base64 в пробел, что само по себе нарушит HMAC.

Я также подтвердил, что наш свежий диагностический код по-прежнему не проходит проверку HMAC, даже после того как секреты WordPress и Discourse были исправлены и приведены в соответствие.

Поэтому я не считаю, что замена wp_unslash() на urldecode() является правильным решением для этого конкретного пути колбэка. Я продолжаю отслеживать, где подписанный пакет данных может расходиться до того, как он дойдёт до PHP.