Completed
Push — add/nested-query-string-suppor... ( 782fd1 )
by
unknown
14:47 queued 07:27
created

Jetpack_Signature::join_array_with_equal_sign()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
nc 4
nop 2
dl 0
loc 16
rs 9.4222
c 0
b 0
f 0
1
<?php
2
/**
3
 * The Jetpack Connection signature class file.
4
 *
5
 * @package automattic/jetpack-connection
6
 */
7
8
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
9
10
/**
11
 * The Jetpack Connection signature class that is used to sign requests.
12
 */
13
class Jetpack_Signature {
14
	/**
15
	 * Token part of the access token.
16
	 *
17
	 * @access public
18
	 * @var string
19
	 */
20
	public $token;
21
22
	/**
23
	 * Access token secret.
24
	 *
25
	 * @access public
26
	 * @var string
27
	 */
28
	public $secret;
29
30
	/**
31
	 * The current request URL.
32
	 *
33
	 * @access public
34
	 * @var string
35
	 */
36
	public $current_request_url;
37
38
	/**
39
	 * Constructor.
40
	 *
41
	 * @param array $access_token Access token.
42
	 * @param int   $time_diff    Timezone difference (in seconds).
43
	 */
44
	public function __construct( $access_token, $time_diff = 0 ) {
45
		$secret = explode( '.', $access_token );
46
		if ( 2 !== count( $secret ) ) {
47
			return;
48
		}
49
50
		$this->token     = $secret[0];
51
		$this->secret    = $secret[1];
52
		$this->time_diff = $time_diff;
0 ignored issues
show
Bug introduced by
The property time_diff does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
53
	}
54
55
	/**
56
	 * Sign the current request.
57
	 *
58
	 * @todo Implement a proper nonce verification.
59
	 *
60
	 * @param array $override Optional arguments to override the ones from the current request.
61
	 * @return string|WP_Error Request signature, or a WP_Error on failure.
62
	 */
63
	public function sign_current_request( $override = array() ) {
64
		if ( isset( $override['scheme'] ) ) {
65
			$scheme = $override['scheme'];
66
			if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
67
				return new WP_Error( 'invalid_scheme', 'Invalid URL scheme' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_scheme'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
68
			}
69
		} else {
70
			if ( is_ssl() ) {
71
				$scheme = 'https';
72
			} else {
73
				$scheme = 'http';
74
			}
75
		}
76
77
		$host_port = isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) ? $_SERVER['HTTP_X_FORWARDED_PORT'] : $_SERVER['SERVER_PORT'];
78
		$host_port = intval( $host_port );
79
80
		/**
81
		 * Note: This port logic is tested in the Jetpack_Cxn_Tests->test__server_port_value() test.
82
		 * Please update the test if any changes are made in this logic.
83
		 */
84
		if ( is_ssl() ) {
85
			// 443: Standard Port
86
			// 80: Assume we're behind a proxy without X-Forwarded-Port. Hardcoding "80" here means most sites
87
			// with SSL termination proxies (self-served, Cloudflare, etc.) don't need to fiddle with
88
			// the JETPACK_SIGNATURE__HTTPS_PORT constant. The code also implies we can't talk to a
89
			// site at https://example.com:80/ (which would be a strange configuration).
90
			// JETPACK_SIGNATURE__HTTPS_PORT: Set this constant in wp-config.php to the back end webserver's port
91
			// if the site is behind a proxy running on port 443 without
92
			// X-Forwarded-Port and the back end's port is *not* 80. It's better,
93
			// though, to configure the proxy to send X-Forwarded-Port.
94
			$https_port = defined( 'JETPACK_SIGNATURE__HTTPS_PORT' ) ? JETPACK_SIGNATURE__HTTPS_PORT : 443;
95
			$port       = in_array( $host_port, array( 443, 80, $https_port ), false ) ? '' : $host_port; // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse
96
		} else {
97
			// 80: Standard Port
98
			// JETPACK_SIGNATURE__HTTPS_PORT: Set this constant in wp-config.php to the back end webserver's port
99
			// if the site is behind a proxy running on port 80 without
100
			// X-Forwarded-Port. It's better, though, to configure the proxy to
101
			// send X-Forwarded-Port.
102
			$http_port = defined( 'JETPACK_SIGNATURE__HTTP_PORT' ) ? JETPACK_SIGNATURE__HTTP_PORT : 80;
103
			$port      = in_array( $host_port, array( 80, $http_port ), false ) ? '' : $host_port; // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse
104
		}
105
106
		$this->current_request_url = "{$scheme}://{$_SERVER['HTTP_HOST']}:{$port}" . stripslashes( $_SERVER['REQUEST_URI'] );
107
108
		if ( array_key_exists( 'body', $override ) && ! empty( $override['body'] ) ) {
109
			$body = $override['body'];
110
		} elseif ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) ) {
111
			$body = isset( $GLOBALS['HTTP_RAW_POST_DATA'] ) ? $GLOBALS['HTTP_RAW_POST_DATA'] : null;
112
113
			// Convert the $_POST to the body, if the body was empty. This is how arrays are hashed
114
			// and encoded on the Jetpack side.
115
			if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
116
				// phpcs:ignore WordPress.Security.NonceVerification.Missing
117
				if ( empty( $body ) && is_array( $_POST ) && count( $_POST ) > 0 ) {
118
					$body = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
119
				}
120
			}
121
		} elseif ( 'PUT' === strtoupper( $_SERVER['REQUEST_METHOD'] ) ) {
122
			// This is a little strange-looking, but there doesn't seem to be another way to get the PUT body.
123
			$raw_put_data = file_get_contents( 'php://input' );
124
			parse_str( $raw_put_data, $body );
125
126
			if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
127
				$put_data = json_decode( $raw_put_data, true );
128
				if ( is_array( $put_data ) && count( $put_data ) > 0 ) {
129
					$body = $put_data;
130
				}
131
			}
132
		} else {
133
			$body = null;
134
		}
135
136
		if ( empty( $body ) ) {
137
			$body = null;
138
		}
139
140
		$a = array();
141
		foreach ( array( 'token', 'timestamp', 'nonce', 'body-hash' ) as $parameter ) {
142
			if ( isset( $override[ $parameter ] ) ) {
143
				$a[ $parameter ] = $override[ $parameter ];
144
			} else {
145
				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
146
				$a[ $parameter ] = isset( $_GET[ $parameter ] ) ? stripslashes( $_GET[ $parameter ] ) : '';
147
			}
148
		}
149
150
		$method = isset( $override['method'] ) ? $override['method'] : $_SERVER['REQUEST_METHOD'];
151
		return $this->sign_request( $a['token'], $a['timestamp'], $a['nonce'], $a['body-hash'], $method, $this->current_request_url, $body, true );
152
	}
153
154
	/**
155
	 * Sign a specified request.
156
	 *
157
	 * @todo Having body_hash v. body-hash is annoying. Refactor to accept an array?
158
	 * @todo Use wp_json_encode() instead of json_encode()?
159
	 *
160
	 * @param string $token            Request token.
161
	 * @param int    $timestamp        Timestamp of the request.
162
	 * @param string $nonce            Request nonce.
163
	 * @param string $body_hash        Request body hash.
164
	 * @param string $method           Request method.
165
	 * @param string $url              Request URL.
166
	 * @param mixed  $body             Request body.
167
	 * @param bool   $verify_body_hash Whether to verify the body hash against the body.
168
	 * @return string|WP_Error Request signature, or a WP_Error on failure.
169
	 */
170
	public function sign_request( $token = '', $timestamp = 0, $nonce = '', $body_hash = '', $method = '', $url = '', $body = null, $verify_body_hash = true ) {
171
		if ( ! $this->secret ) {
172
			return new WP_Error( 'invalid_secret', 'Invalid secret' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_secret'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
173
		}
174
175
		if ( ! $this->token ) {
176
			return new WP_Error( 'invalid_token', 'Invalid token' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_token'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
177
		}
178
179
		list( $token ) = explode( '.', $token );
180
181
		$signature_details = compact( 'token', 'timestamp', 'nonce', 'body_hash', 'method', 'url' );
182
183
		if ( 0 !== strpos( $token, "$this->token:" ) ) {
184
			return new WP_Error( 'token_mismatch', 'Incorrect token', compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'token_mismatch'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
185
		}
186
187
		// If we got an array at this point, let's encode it, so we can see what it looks like as a string.
188
		if ( is_array( $body ) ) {
189
			if ( count( $body ) > 0 ) {
190
				// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
191
				$body = json_encode( $body );
192
193
			} else {
194
				$body = '';
195
			}
196
		}
197
198
		$required_parameters = array( 'token', 'timestamp', 'nonce', 'method', 'url' );
199
		if ( ! is_null( $body ) ) {
200
			$required_parameters[] = 'body_hash';
201
			if ( ! is_string( $body ) ) {
202
				return new WP_Error( 'invalid_body', 'Body is malformed.', compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_body'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
203
			}
204
		}
205
206
		foreach ( $required_parameters as $required ) {
207
			if ( ! is_scalar( $$required ) ) {
208
				return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', str_replace( '_', '-', $required ) ), compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
209
			}
210
211 View Code Duplication
			if ( ! strlen( $$required ) ) {
212
				return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is missing.', str_replace( '_', '-', $required ) ), compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
213
			}
214
		}
215
216
		if ( empty( $body ) ) {
217
			if ( $body_hash ) {
218
				return new WP_Error( 'invalid_body_hash', 'Invalid body hash for empty body.', compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_body_hash'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
219
			}
220
		} else {
221
			$connection = new Connection_Manager();
222
			if ( $verify_body_hash && $connection->sha1_base64( $body ) !== $body_hash ) {
223
				return new WP_Error( 'invalid_body_hash', 'The body hash does not match.', compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_body_hash'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
224
			}
225
		}
226
227
		$parsed = wp_parse_url( $url );
228
		if ( ! isset( $parsed['host'] ) ) {
229
			return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'url' ), compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
230
		}
231
232
		if ( ! empty( $parsed['port'] ) ) {
233
			$port = $parsed['port'];
234
		} else {
235
			if ( 'http' === $parsed['scheme'] ) {
236
				$port = 80;
237
			} elseif ( 'https' === $parsed['scheme'] ) {
238
				$port = 443;
239
			} else {
240
				return new WP_Error( 'unknown_scheme_port', "The scheme's port is unknown", compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'unknown_scheme_port'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
241
			}
242
		}
243
244 View Code Duplication
		if ( ! ctype_digit( "$timestamp" ) || 10 < strlen( $timestamp ) ) { // If Jetpack is around in 275 years, you can blame mdawaffe for the bug.
245
			return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'timestamp' ), compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
246
		}
247
248
		$local_time = $timestamp - $this->time_diff;
249 View Code Duplication
		if ( $local_time < time() - 600 || $local_time > time() + 300 ) {
250
			return new WP_Error( 'invalid_signature', 'The timestamp is too old.', compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
251
		}
252
253 View Code Duplication
		if ( 12 < strlen( $nonce ) || preg_match( '/[^a-zA-Z0-9]/', $nonce ) ) {
254
			return new WP_Error( 'invalid_signature', sprintf( 'The required "%s" parameter is malformed.', 'nonce' ), compact( 'signature_details' ) );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_signature'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
255
		}
256
257
		$normalized_request_pieces = array(
258
			$token,
259
			$timestamp,
260
			$nonce,
261
			$body_hash,
262
			strtoupper( $method ),
263
			strtolower( $parsed['host'] ),
264
			$port,
265
			$parsed['path'],
266
			// Normalized Query String.
267
		);
268
269
		$normalized_request_pieces      = array_merge( $normalized_request_pieces, $this->normalized_query_parameters( isset( $parsed['query'] ) ? $parsed['query'] : '' ) );
270
		$flat_normalized_request_pieces = array();
271
		foreach ( $normalized_request_pieces as $piece ) {
272
			if ( is_array( $piece ) ) {
273
				foreach ( $piece as $subpiece ) {
274
					$flat_normalized_request_pieces[] = $subpiece;
275
				}
276
			} else {
277
				$flat_normalized_request_pieces[] = $piece;
278
			}
279
		}
280
		$normalized_request_pieces = $flat_normalized_request_pieces;
281
282
		$normalized_request_string = join( "\n", $normalized_request_pieces ) . "\n";
283
284
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
285
		return base64_encode( hash_hmac( 'sha1', $normalized_request_string, $this->secret, true ) );
286
	}
287
288
	/**
289
	 * Retrieve and normalize the parameters from a query string.
290
	 *
291
	 * @param string $query_string Query string.
292
	 * @return array Normalized query string parameters.
293
	 */
294
	public function normalized_query_parameters( $query_string ) {
295
		parse_str( $query_string, $array );
296
297
		unset( $array['signature'] );
298
299
		$names  = array_keys( $array );
300
		$values = array_values( $array );
301
302
		$names  = array_map( array( $this, 'encode_3986' ), $names );
303
		$values = array_map( array( $this, 'encode_3986' ), $values );
304
305
		$pairs = array_map( array( $this, 'join_with_equal_sign' ), $names, $values );
306
307
		sort( $pairs );
308
309
		return $pairs;
310
	}
311
312
	/**
313
	 * Encodes a string or array of strings according to RFC 3986.
314
	 *
315
	 * @param string|array $string_or_array String or array to encode.
316
	 * @return string|array URL-encoded string or array.
317
	 */
318
	public function encode_3986( $string_or_array ) {
319
		if ( is_array( $string_or_array ) ) {
320
			return array_map( array( $this, 'encode_3986' ), $string_or_array );
321
		}
322
323
		return rawurlencode( $string_or_array );
324
	}
325
326
	/**
327
	 * Concatenates a parameter name and a parameter value with an equals sign between them.
328
	 *
329
	 * @param string       $name  Parameter name.
330
	 * @param string|array $value Parameter value.
331
	 * @return string|array A string pair (e.g. `name=value`) or an array of string pairs.
332
	 */
333
	public function join_with_equal_sign( $name, $value ) {
334
		if ( is_array( $value ) ) {
335
			return $this->join_array_with_equal_sign( $name, $value );
336
		}
337
		return "{$name}={$value}";
338
	}
339
340
	/**
341
	 * Helper function for join_with_equal_sign for handling arrayed values.
342
	 * Explicitly supports nested arrays.
343
	 *
344
	 * @param string $name  Parameter name.
345
	 * @param array  $value Parameter value.
346
	 * @return array An array of string pairs (e.g. `[ name[example]=value ]`).
347
	 */
348
	private function join_array_with_equal_sign( $name, $value ) {
349
		$result = array();
350
		foreach ( $value as $value_key => $value_value ) {
351
			$joined_value = $this->join_with_equal_sign( $name . '[' . $value_key . ']', $value_value );
352
			if ( is_array( $joined_value ) ) {
353
				foreach ( array_values( $joined_value ) as $individual_joined_value ) {
354
					$result[] = $individual_joined_value;
355
				}
356
			} elseif ( is_string( $joined_value ) ) {
357
				$result[] = $joined_value;
358
			}
359
		}
360
361
		sort( $result );
362
		return $result;
363
	}
364
}
365