Falha na validação HMAC do DiscourseConnect com WordPress, apesar dos parâmetros de callback corresponderem

Olá,

Estou configurando o DiscourseConnect entre um site WordPress e uma instância hospedada do Discourse e estou enfrentando uma falha persistente na validação da assinatura HMAC.

Configuração

  • O WordPress é o lado de autenticação/provedor.
  • O Discourse está usando o DiscourseConnect.
  • O WordPress recebe a requisição do DiscourseConnect e retorna o callback contendo sso e sig.
  • O callback chega ao Discourse, mas o Discourse o rejeita porque a assinatura HMAC não é validada.

Realizamos uma depuração bastante extensa e reduzimos bastante o escopo do problema.

O que confirmamos

  • wp_unslash() não altera os valores do callback.
  • As entradas de validação do EA/WordPress correspondem aos parâmetros reconstruídos a partir da string de consulta observada pelo WordPress.
  • O código do callback esperado está definitivamente sendo executado.
  • Os arquivos de fonte implantados correspondem ao nosso manifesto de compilação.
  • Verificamos problemas de arquivo incorreto e definições duplicadas.
  • O PHP-FPM foi reiniciado após as últimas correções, então isso não é um estado antigo de processo PHP/opcache.
  • Uma requisição completamente nova do DiscourseConnect após essas alterações ainda falha na validação HMAC.

Em outras palavras, a falha atual é reproduzível em uma requisição nova.

O que ainda não conseguimos provar

  1. Que o segredo efetivo usado pelo Discourse em tempo de execução é idêntico, byte a byte, ao segredo usado pelo WordPress.
  2. Que o payload sso não está sendo alterado em algum ponto antes de o WordPress observar a requisição.

Nesta etapa, não queremos continuar alterando configurações ou código às cegas.

Pergunta

Para o Discourse/DiscourseConnect atual, qual é a melhor maneira de determinar exatamente qual payload e segredo o Discourse está usando ao calcular o HMAC esperado?

Existe um método de depuração/registro recomendado que nos permita comparar a entrada do HMAC do lado do Discourse com a entrada do lado do WordPress sem expor o segredo real publicamente?

Se houver problemas conhecidos envolvendo o processamento do WordPress/PHP, codificação de URL, payloads Base64, proxies reversos ou Discourse hospedado que possam causar essa situação, também apreciaria indicações.

Posso fornecer valores de requisição/callback sanitizados, o código relevante do WordPress e logs, se necessário.

Obrigado.

Então, o que você está usando no lado do WordPress? O plugin WP-Discourse? Se não, pode postar seu código?

Olá, Richard. O WP-Discourse está instalado, mas o provedor DiscourseConnect e as funções de sincronização de login estão desativados.

Estamos usando um pequeno plugin WordPress personalizado como provedor DiscourseConnect. Ele recebe os parâmetros sso e sig do Discourse, verifica o HMAC recebido usando o segredo compartilhado e, em seguida, constrói e assina a resposta de volta para o Discourse.

A falha está ocorrendo atualmente na solicitação recebida do Discourse para o WordPress — nosso diagnóstico recente mostra que a recomputação do HMAC não corresponde ao sig recebido do Discourse.

Ficarei feliz em postar o código relevante de callback/validação em PHP. Vou remover os valores de configuração/segredos antes de postá-lo.

Então, é o contrário, certo?

O caminho óbvio seria usar o plugin WP-Discourse, mas você provavelmente tem bons motivos para não fazê-lo.
Sim, por favor, poste o código.

Sim — você está correto. Minha redação na publicação original estava invertida.

A falha atual é Discourse → WordPress.

O Discourse gera a requisição DiscourseConnect contendo sso e sig. O WordPress a recebe, e nosso provedor EA personalizado tenta verificar a assinatura. A verificação de HMAC recebida falha, então o WordPress para por ali. Ele não chega ao ponto de retornar o payload do usuário autenticado ao Discourse.

O WP-Discourse está instalado, mas seu provedor DiscourseConnect e as funções de sincronização de login de usuário estão desativados. Atualmente, estamos usando o provedor EA personalizado porque queremos que a camada de conta/perfil do EA permaneça sob nosso controle.

Postarei abaixo o código relevante de callback/config/validação de HMAC, com todos os segredos e configurações privadas removidos.

Este é o caminho sanitizado normal de validação de entrada do DiscourseConnect (Discourse → WordPress) no provedor WordPress personalizado da EA.

<?php
// As constantes de configuração são definidas em outro lugar; seus valores privados são omitidos.

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 ),
			'Login da comunidade 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() {
		// Configuração dos cabeçalhos de resposta omitida.

		if ( ! self::enabled() ) {
			$this->fail( 'O login da comunidade está desativado.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'O login da comunidade não está configurado.', 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(
				'Requisição de login da comunidade inválida. Comece novamente a partir da comunidade.'
			);
		}

		// Login subsequente no WordPress e código de resposta autenticado omitidos.
	}
}

// Instanciado pelo bootstrap do plugin:
new EA_Discourse_Connect();

Você deve usar urldecode() nos parâmetros, não wp_unslash()

Obrigado, Richard. Verifiquei esse ponto em relação ao processamento de solicitações do PHP/WordPress e ao fluxo atual de assinatura do Discourse.

Em nosso callback, lemos de $_GET, portanto o PHP já decodificou os parâmetros da query antes que o EA os receba. wp_unslash() apenas reverte o “slashing” de solicitações do WordPress; ele não está fazendo decodificação de URL.

Aplicar urldecode() novamente nesse ponto resultaria em uma decodificação dupla do valor e pode transformar um + em Base64 em um espaço, o que por si só quebraria o HMAC.

Também confirmei que nosso novo diagnóstico ainda falha no HMAC, mesmo após os segredos do WordPress e do Discourse terem sido corrigidos para corresponder.

Portanto, não acredito que substituir wp_unslash() por urldecode() seja a correção adequada para este caminho específico de callback. Estou continuando a rastrear onde o payload assinado pode estar divergindo antes de chegar ao PHP.