Skip to content
Merged
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
122 changes: 112 additions & 10 deletions inc/Core/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ class HttpClient {
* - headers: array - Additional headers to merge
* - body: string|array - Request body (for POST/PUT/PATCH)
* - timeout: int - Request timeout (default 120)
* - proxy_url: string - Optional per-request proxy URL (http, https, socks4, socks5, socks5h)
* - auth: array - Optional standard auth config: {type: basic, username, password} or {type: bearer, token}
* - browser_mode: bool - Use browser-like headers (default false)
* - context: string - Context for logging (default 'HTTP Request')
* @return array{success: bool, data?: string, status_code?: int, headers?: array, response?: array, error?: string}
* @return array Response array.
*/
public static function request( string $method, string $url, array $options = array() ): array {
$method = strtoupper( $method );
$context = $options['context'] ?? 'HTTP Request';
$method = strtoupper( $method );
$context = $options['context'] ?? 'HTTP Request';
$proxy_filter = null;

if ( ! in_array( $method, self::VALID_METHODS, true ) ) {
do_action(
Expand All @@ -66,17 +69,28 @@ public static function request( string $method, string $url, array $options = ar

$args = self::buildRequestArgs( $method, $options );

$response = ( 'GET' === $method )
? wp_remote_get( $url, $args )
: wp_remote_request( $url, $args );
if ( isset( $options['proxy_url'] ) && is_string( $options['proxy_url'] ) && '' !== $options['proxy_url'] ) {
$proxy_filter = self::createProxyCurlFilter( $options['proxy_url'] );
add_action( 'http_api_curl', $proxy_filter, 10, 1 );
}

try {
$response = ( 'GET' === $method )
? wp_remote_get( $url, $args )
: wp_remote_request( $url, $args );
} finally {
if ( null !== $proxy_filter ) {
remove_action( 'http_api_curl', $proxy_filter, 10 );
}
}

if ( is_wp_error( $response ) ) {
return self::handleWpError( $response, $method, $url, $context );
return self::handleWpError( $response, $method, $url, $context, $args );
}

$status_code = wp_remote_retrieve_response_code( $response );
$status_code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
$success_codes = self::SUCCESS_CODES[ $method ] ?? array( 200 );
$success_codes = self::SUCCESS_CODES[ $method ];

if ( ! in_array( $status_code, $success_codes, true ) ) {
return self::handleHttpError( $status_code, $body, $method, $url, $context );
Expand Down Expand Up @@ -178,6 +192,7 @@ private static function buildRequestArgs( string $method, array $options ): arra
);

$headers = array_merge( $default_headers, $options['headers'] ?? array() );
$headers = self::applyAuthentication( $headers, $options['auth'] ?? null );

$args = array(
'timeout' => $timeout,
Expand All @@ -195,10 +210,81 @@ private static function buildRequestArgs( string $method, array $options ): arra
return $args;
}

/**
* Apply standard request authentication when the caller has not provided an Authorization header.
*/
private static function applyAuthentication( array $headers, mixed $auth ): array {
if ( ! is_array( $auth ) || self::hasHeader( $headers, 'Authorization' ) ) {
return $headers;
}

$type = strtolower( (string) ( $auth['type'] ?? '' ) );
if ( 'basic' === $type ) {
$username = (string) ( $auth['username'] ?? '' );
$password = (string) ( $auth['password'] ?? '' );
if ( '' !== $username || '' !== $password ) {
$headers['Authorization'] = 'Basic ' . base64_encode( $username . ':' . $password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Basic auth requires RFC 7617 base64 encoding.
}
}

if ( 'bearer' === $type ) {
$token = (string) ( $auth['token'] ?? '' );
if ( '' !== $token ) {
$headers['Authorization'] = 'Bearer ' . $token;
}
}

return $headers;
}

/**
* Determine whether a header exists case-insensitively.
*/
private static function hasHeader( array $headers, string $needle ): bool {
foreach ( array_keys( $headers ) as $name ) {
if ( strtolower( (string) $name ) === strtolower( $needle ) ) {
return true;
}
}

return false;
}

/**
* Create a request-scoped cURL proxy configurator for WordPress HTTP requests.
*/
private static function createProxyCurlFilter( string $proxy_url ): callable {
return static function ( $handle ) use ( $proxy_url ): void {
if ( function_exists( 'curl_setopt' ) && defined( 'CURLOPT_PROXY' ) ) {
curl_setopt( $handle, CURLOPT_PROXY, $proxy_url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- WordPress exposes the cURL handle only through this hook.
}

$scheme = strtolower( (string) wp_parse_url( $proxy_url, PHP_URL_SCHEME ) );
$type = self::curlProxyTypeForScheme( $scheme );
if ( function_exists( 'curl_setopt' ) && null !== $type && defined( 'CURLOPT_PROXYTYPE' ) ) {
curl_setopt( $handle, CURLOPT_PROXYTYPE, $type ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- WordPress exposes the cURL handle only through this hook.
}
};
}

/**
* Map common proxy URL schemes to cURL proxy type constants.
*/
private static function curlProxyTypeForScheme( string $scheme ): ?int {
return match ( $scheme ) {
'socks4' => defined( 'CURLPROXY_SOCKS4' ) ? CURLPROXY_SOCKS4 : null,
'socks5' => defined( 'CURLPROXY_SOCKS5' ) ? CURLPROXY_SOCKS5 : null,
'socks5h' => defined( 'CURLPROXY_SOCKS5_HOSTNAME' ) ? CURLPROXY_SOCKS5_HOSTNAME : ( defined( 'CURLPROXY_SOCKS5' ) ? CURLPROXY_SOCKS5 : null ),
'http' => defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : null,
'https' => defined( 'CURLPROXY_HTTPS' ) ? CURLPROXY_HTTPS : ( defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : null ),
default => null,
};
}

/**
* Handle WP_Error response
*/
private static function handleWpError( \WP_Error $response, string $method, string $url, string $context ): array {
private static function handleWpError( \WP_Error $response, string $method, string $url, string $context, array $args = array() ): array {
$error_message = sprintf(
'Failed to connect to %1$s: %2$s',
$context,
Expand All @@ -215,6 +301,7 @@ private static function handleWpError( \WP_Error $response, string $method, stri
'method' => $method,
'error' => $response->get_error_message(),
'error_code' => $response->get_error_code(),
'args' => self::redactRequestArgsForLog( $args ),
)
);

Expand All @@ -224,6 +311,21 @@ private static function handleWpError( \WP_Error $response, string $method, stri
);
}

/**
* Redact sensitive HTTP request data before log emission.
*/
private static function redactRequestArgsForLog( array $args ): array {
if ( ! empty( $args['headers'] ) && is_array( $args['headers'] ) ) {
foreach ( $args['headers'] as $name => $value ) {
if ( in_array( strtolower( (string) $name ), array( 'authorization', 'proxy-authorization', 'cookie', 'set-cookie' ), true ) ) {
$args['headers'][ $name ] = '[redacted]';
}
}
}

return $args;
}

/**
* Handle non-success HTTP status code
*/
Expand Down
151 changes: 151 additions & 0 deletions tests/http-client-proxy-auth-smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php
/**
* Smoke coverage for generic HttpClient proxy/auth request options.
*
* Run with: php tests/http-client-proxy-auth-smoke.php
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/../' );
}

if ( ! function_exists( 'home_url' ) ) {
function home_url(): string {
return 'https://example.test';
}
}

if ( ! function_exists( 'wp_parse_url' ) ) {
function wp_parse_url( string $url, int $component = -1 ): mixed {
$parts = parse_url( $url );
if ( -1 === $component ) {
return $parts;
}

$map = array(
PHP_URL_SCHEME => 'scheme',
PHP_URL_HOST => 'host',
PHP_URL_PORT => 'port',
PHP_URL_USER => 'user',
PHP_URL_PASS => 'pass',
PHP_URL_PATH => 'path',
PHP_URL_QUERY => 'query',
PHP_URL_FRAGMENT => 'fragment',
);

$key = $map[ $component ] ?? null;
return null === $key ? null : ( $parts[ $key ] ?? null );
}
}

require_once __DIR__ . '/../inc/Core/HttpClient.php';

use DataMachine\Core\HttpClient;

$failed = 0;
$total = 0;

function http_client_smoke_assert( string $label, bool $condition ): void {
global $failed, $total;
++$total;
if ( $condition ) {
echo "PASS: {$label}\n";
return;
}

++$failed;
echo "FAIL: {$label}\n";
}

function http_client_private( string $method, mixed ...$args ): mixed {
$ref = new ReflectionClass( HttpClient::class );
$m = $ref->getMethod( $method );
return $m->invokeArgs( null, $args );
}

echo "\n[1] Standard auth options\n";

$basic_args = http_client_private(
'buildRequestArgs',
'GET',
array(
'auth' => array(
'type' => 'basic',
'username' => 'chubes4',
'password' => 'secret-password',
),
)
);

http_client_smoke_assert(
'basic auth creates Authorization header',
'Basic ' . base64_encode( 'chubes4:secret-password' ) === ( $basic_args['headers']['Authorization'] ?? null )
);

$bearer_args = http_client_private(
'buildRequestArgs',
'GET',
array(
'auth' => array(
'type' => 'bearer',
'token' => 'bearer-token',
),
)
);

http_client_smoke_assert(
'bearer auth creates Authorization header',
'Bearer bearer-token' === ( $bearer_args['headers']['Authorization'] ?? null )
);

$manual_args = http_client_private(
'buildRequestArgs',
'GET',
array(
'headers' => array( 'authorization' => 'Custom manual' ),
'auth' => array(
'type' => 'bearer',
'token' => 'ignored-token',
),
)
);

http_client_smoke_assert(
'pre-set Authorization header is preserved case-insensitively',
'Custom manual' === ( $manual_args['headers']['authorization'] ?? null )
);

echo "\n[2] Redaction\n";

$redacted = http_client_private(
'redactRequestArgsForLog',
array(
'headers' => array(
'Authorization' => 'Bearer top-secret',
'Proxy-Authorization' => 'Basic hidden',
'Cookie' => 'wordpress_logged_in=hidden',
'X-Test' => 'visible',
),
)
);

http_client_smoke_assert( 'Authorization is redacted', '[redacted]' === ( $redacted['headers']['Authorization'] ?? null ) );
http_client_smoke_assert( 'Proxy-Authorization is redacted', '[redacted]' === ( $redacted['headers']['Proxy-Authorization'] ?? null ) );
http_client_smoke_assert( 'Cookie is redacted', '[redacted]' === ( $redacted['headers']['Cookie'] ?? null ) );
http_client_smoke_assert( 'Non-sensitive header remains visible', 'visible' === ( $redacted['headers']['X-Test'] ?? null ) );

echo "\n[3] Proxy scheme mapping\n";

http_client_smoke_assert(
'socks5 proxy scheme is recognized when cURL exposes the constant',
! defined( 'CURLPROXY_SOCKS5' ) || CURLPROXY_SOCKS5 === http_client_private( 'curlProxyTypeForScheme', 'socks5' )
);
http_client_smoke_assert( 'unknown proxy scheme is ignored', null === http_client_private( 'curlProxyTypeForScheme', 'ftp' ) );

if ( 0 === $failed ) {
echo "\n=== http-client-proxy-auth-smoke: ALL PASS ({$total}) ===\n";
exit( 0 );
}

echo "\n=== http-client-proxy-auth-smoke: {$failed} FAIL of {$total} ===\n";
exit( 1 );
Loading