Passed
Pull Request — master (#309)
by Jason
13:47
created

FoxyCart_Helper::fc_hash_value()   B

Complexity

Conditions 9
Paths 13

Size

Total Lines 20
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
rs 7.756
c 0
b 0
f 0
cc 9
eloc 15
nc 13
nop 6
1
<?php
2
/**
3
 * FoxyCart_Helper
4
 *
5
 * @author FoxyCart.com
6
 * @copyright FoxyCart.com LLC, 2011
7
 * @version 0.7.2.20111013
8
 * @license MIT http://opensource.org/licenses/MIT
9
 * @example http://wiki.foxycart.com/docs/cart/validation
10
 *
11
 * Requirements:
12
 *   - Form "code" values should not have leading or trailing whitespace.
13
 *   - Cannot use double-pipes in an input's name
14
 *   - Empty textareas are assumed to be "open"
15
 */
16
class FoxyCart_Helper {
17
	/**
18
	 * API Key (Secret)
19
	 *
20
	 * @var string
21
	 **/
22
	private static $secret;
23
24
	/**
25
	 * Cart URL
26
	 *
27
	 * @var string
28
	 * Notes: Could be 'https://yourdomain.foxycart.com/cart' or 'https://secure.yourdomain.com/cart'
29
	 **/
30
	// protected static $cart_url = 'https://yourdomain.foxycart.com/cart';
0 ignored issues
show
Unused Code Comprehensibility introduced by
46% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
31
	protected static $cart_url;
32
33
	public static function setCartURL($storeName = null){
34
		self::$cart_url = 'https://'.$storeName.'.faxycart.com/cart';
35
	}
36
37
	public static function setSecret($secret = null){
38
		self::$secret = $secret;
39
	}
40
41
	public function __construct(){
42
		self::setCartURL(FoxyCart::getFoxyCartStoreName());
43
		self::setSecret(FoxyCart::getStoreKey());
44
	}
45
46
	public static function getSecret(){
47
		return FoxyCart::getStoreKey();
48
	}
49
50
51
	/**
52
	 * Cart Excludes
53
	 *
54
	 * Arrays of values and prefixes that should be ignored when signing links and forms.
55
	 * @var array
56
	 */
57
	protected static $cart_excludes = array(
58
		// Cart values
59
		'cart', 'fcsid', 'empty', 'coupon', 'output', 'sub_token', 'redirect', 'callback', '_',
60
		// Checkout pre-population values
61
		'customer_email', 'customer_first_name', 'customer_last_name', 'customer_address1', 'customer_address2',
62
		'customer_city', 'customer_state', 'customer_postal_code', 'customer_country', 'customer_phone', 'customer_company',
63
		'shipping_first_name', 'shipping_last_name', 'shipping_address1', 'shipping_address2',
64
		'shipping_city', 'shipping_state', 'shipping_postal_code', 'shipping_country', 'shipping_phone', 'shipping_company',
65
	);
66
	protected static $cart_excludes_prefixes = array(
67
		'h:', 'x:', '__',
68
	);
69
70
	/**
71
	 * Debugging
72
	 *
73
	 * Set to $debug to TRUE to enable debug logging.
74
	 *
75
	 */
76
	protected static $debug = FALSE;
77
	protected static $log = array();
78
79
80
	/**
81
	 * "Link Method": Generate HMAC SHA256 for GET Query Strings
82
	 *
83
	 * Notes: Can't parse_str because PHP doesn't support non-alphanumeric characters as array keys.
84
	 * @return string
85
	 **/
86
	public static function fc_hash_querystring($qs, $output = TRUE) {
87
		self::$log[] = '<strong>Signing link</strong> with data: '.htmlspecialchars(substr($qs, 0, 150)).'...';
88
		$fail = self::$cart_url.'?'.$qs;
89
90
		// If the link appears to be hashed already, don't bother
91
		if (strpos($qs, '||')) {
92
			self::$log[] = '<strong>Link appears to be signed already</strong>: '.htmlspecialchars($code[0]);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $code does not exist. Did you maybe mean $codes?
Loading history...
93
			return $fail;
94
		}
95
96
		// Stick an ampersand on the beginning of the querystring to make matching the first element a little easier
97
		$qs = '&'.urldecode($qs);
98
99
		// Get all the prefixes, codes, and name=value pairs
100
		preg_match_all('%(?P<amp>&(?:amp;)?)(?P<prefix>[a-z0-9]{1,3}:)?(?P<name>[^=]+)=(?P<value>[^&]+)%', $qs, $pairs, PREG_SET_ORDER);
101
		self::$log[] = 'Found the following pairs to sign:<pre>'.htmlspecialchars(print_r($pairs, true)).'</pre>';
102
103
		// Get all the "code" values, set the matches in $codes
104
		$codes = array();
105
		foreach ($pairs as $pair) {
106
			if ($pair['name'] == 'code') {
107
				$codes[$pair['prefix']] = $pair['value'];
108
			}
109
		}
110
		if ( ! count($codes)) {
111
			self::$log[] = '<strong style="color:#600;">No code found</strong> for the above link.';
112
			return $fail;
113
		}
114
		self::$log[] = '<strong style="color:orange;">CODES found:</strong> '.htmlspecialchars(print_r($codes, true));
115
116
		// Sign the name/value pairs
117
		foreach ($pairs as $pair) {
118
			// Skip the cart excludes
119
			if (in_array($pair['name'], self::$cart_excludes) || in_array($pair['prefix'], self::$cart_excludes_prefixes)) {
120
				self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$pair['prefix'].$pair['name'].'" = '.$pair['value'];
121
				continue;
122
			}
123
124
			// Continue to sign the value and replace the name=value in the querystring with name=value||hash
125
			$value = self::fc_hash_value($codes[$pair['prefix']], $pair['name'], $pair['value'], 'value', FALSE, 'urlencode');
126
			$replacement = $pair['amp'].$pair['prefix'].urlencode($pair['name']).'='.$value;
127
			$qs = str_replace($pair[0], $replacement, $qs);
128
			self::$log[] = 'Signed <strong>'.$pair['name'].'</strong> = <strong>'.$pair['value'].'</strong> with '.$replacement.'.<br />Replacing: '.$pair[0].'<br />With... '.$replacement;
129
		}
130
		$qs = ltrim($qs, '&'); // Get rid of that leading ampersand we added earlier
131
132
		if ($output) {
133
			echo self::$cart_url.'?'.$qs;
134
		} else {
135
			return self::$cart_url.'?'.$qs;
136
		}
137
	}
138
139
140
	/**
141
	 * "Form Method": Generate HMAC SHA256 for form elements or individual <input />s
142
	 *
143
	 * @return string
144
	 **/
145
	public static function fc_hash_value($product_code, $option_name, $option_value = '', $method = 'name', $output = TRUE, $urlencode = false) {
146
		if (!$product_code || !$option_name) {
147
			return FALSE;
0 ignored issues
show
Bug Best Practice introduced by
The expression return FALSE returns the type false which is incompatible with the documented return type string.
Loading history...
148
		}
149
		if ($option_value == '--OPEN--') {
150
			$hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::getSecret());
151
			$value = ($urlencode) ? urlencode($option_name).'||'.$hash.'||open' : $option_name.'||'.$hash.'||open';
152
		} else {
153
			$hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::getSecret());
154
			if ($method == 'name') {
155
				$value = ($urlencode) ? urlencode($option_name).'||'.$hash : $option_name.'||'.$hash;
156
			} else {
157
				$value = ($urlencode) ? urlencode($option_value).'||'.$hash : $option_value.'||'.$hash;
158
			}
159
		}
160
161
		if ($output) {
162
			echo $value;
163
		} else {
164
			return $value;
165
		}
166
	}
167
168
	/**
169
	 * Raw HTML Signing: Sign all links and form elements in a block of HTML
170
	 *
171
	 * Accepts a string of HTML and signs all links and forms.
172
	 * Requires link 'href' and form 'action' attributes to use 'https' and not 'http'.
173
	 * Requires a 'code' to be set in every form.
174
	 *
175
	 * @return string
176
	 **/
177
	public static function fc_hash_html($html) {
178
		// Initialize some counting
179
		$count['temp'] = 0; // temp counter
0 ignored issues
show
Comprehensibility Best Practice introduced by
$count was never initialized. Although not strictly required by PHP, it is generally a good practice to add $count = array(); before regardless.
Loading history...
180
		$count['links'] = 0;
181
		$count['forms'] = 0;
182
		$count['inputs'] = 0;
183
		$count['lists'] = 0;
184
		$count['textareas'] = 0;
185
186
		// Find and sign all the links
187
		preg_match_all('%<a .*?href=[\'"]'.preg_quote(self::$cart_url).'(?:\.php)?\?(.+?)[\'"].*?>%i', $html, $querystrings);
188
		// print_r($querystrings);
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
189
		foreach ($querystrings[1] as $querystring) {
190
			// If it's already signed, skip it.
191
			if (preg_match('%&(?:amp;)?hash=%i', $querystring)) {
192
				continue;
193
			}
194
			$pattern = '%(href=[\'"])'.preg_quote(self::$cart_url, '%').'(?:\.php)?\?'.preg_quote($querystring, '%').'([\'"])%i';
195
			$signed = self::fc_hash_querystring($querystring, FALSE);
196
			$html = preg_replace($pattern, '$1'.$signed.'$2', $html, -1, $count['temp']);
197
			$count['links'] += $count['temp'];
198
		}
199
		unset($querystrings);
200
201
		// Find and sign all form values
202
		preg_match_all('%<form [^>]*?action=[\'"]'.preg_quote(self::$cart_url).'?[\'"].*?>(.+?)</form>%is', $html, $forms);
203
		foreach ($forms[1] as $form) {
204
			$count['forms']++;
205
			self::$log[] = '<strong>Signing form</strong> with data: '.htmlspecialchars(substr($form, 0, 150)).'...';
206
207
			// Store the original form so we can replace it when we're done
208
			$form_original = $form;
209
210
			// Check for the "code" input, set the matches in $codes
211
			if (!preg_match_all('%<[^>]*?name=([\'"])([0-9]{1,3}:)?code\1[^>]*?>%i', $form, $codes, PREG_SET_ORDER)) {
212
				self::$log[] = '<strong style="color:#600;">No code found</strong> for the above form.';
213
				continue;
214
			}
215
			// For each code found, sign the appropriate inputs
216
			foreach ($codes as $code) {
217
				// If the form appears to be hashed already, don't bother
218
				if (strpos($code[0], '||')) {
219
					self::$log[] = '<strong>Form appears to be signed already</strong>: '.htmlspecialchars($code[0]);
220
					continue;
221
				}
222
				// Get the code and the prefix
223
				$prefix = (isset($code[2])) ? $code[2] : '';
224
				preg_match('%<[^>]*?value=([\'"])(.+?)\1[^>]*?>%i', $code[0], $code);
225
				$code = trim($code[2]);
226
				self::$log[] = '<strong>Prefix for '.htmlspecialchars($code).'</strong>: '.htmlspecialchars($prefix);
227
				if (!$code) { // If the code is empty, skip this form or specific prefixed elements
228
					continue;
229
				}
230
231
				// Sign all <input /> elements with matching prefix
232
				preg_match_all('%<input [^>]*?name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(?:.+?)\1[^>]*>%i', $form, $inputs);
233
				foreach ($inputs[0] as $input) {
234
					$count['inputs']++;
235
					// Test to make sure both name and value attributes are found
236
					if (preg_match('%name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1%i', $input, $name) > 0) {
237
						preg_match('%value=([\'"])(.*?)\1%i', $input, $value);
238
						$value = (count($value) > 0) ? $value : array('', '', '');
239
						preg_match('%type=([\'"])(.*?)\1%i', $input, $type);
240
						$type = (count($type) > 0) ? $type : array('', '', '');
241
						// Skip the cart excludes
242
						if (in_array($prefix.$name[2], self::$cart_excludes) || in_array(substr($prefix.$name[2], 0, 2), self::$cart_excludes_prefixes)) {
243
							self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$prefix.$name[2].'" = '.$value[2];
244
							continue;
245
						}
246
						self::$log[] = '<strong>INPUT['.$type[2].']:</strong> Name: <strong>'.$prefix.htmlspecialchars(preg_quote($name[2])).'</strong>';
247
						self::$log[] = '<strong>Replacement Pattern:</strong> ([\'"])'.$prefix.preg_quote($name[2]).'\1';
248
						$value[2] = ($value[2] == '') ? '--OPEN--' : $value[2];
249
						if ($type[2] == 'radio') {
250
							$input_signed = preg_replace('%([\'"])'.preg_quote($value[2]).'\1%', '${1}'.self::fc_hash_value($code, $name[2], $value[2], 'value', FALSE)."$1", $input);
251
						} else {
252
							$input_signed = preg_replace('%([\'"])'.$prefix.preg_quote($name[2]).'\1%', '${1}'.$prefix.self::fc_hash_value($code, $name[2], $value[2], 'name', FALSE)."$1", $input);
253
						}
254
						self::$log[] = '<strong>INPUT:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
255
						               '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$name[2]).
256
						               '</strong> :: Value: <strong>'.htmlspecialchars($value[2]).
257
						               '</strong><br />Initial input: '.htmlspecialchars($input).
258
						               '<br />Signed: <span style="color:#060;">'.htmlspecialchars($input_signed).'</span>';
259
						$form = str_replace($input, $input_signed, $form);
260
					}
261
				}
262
				self::$log[] = '<strong>FORM after INPUTS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
263
264
				// Sign all <option /> elements
265
				preg_match_all('%<select [^>]*name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1[^>]*>(.+?)</select>%is', $form, $lists, PREG_SET_ORDER);
266
				foreach ($lists as $list) {
267
					$count['lists']++;
268
					preg_match_all('%<option [^>]*value=([\'"])(.+?)\1[^>]*>(?:.*?)</option>%i', $list[0], $options, PREG_SET_ORDER);
269
					self::$log[] = '<strong>Options:</strong> <pre>'.htmlspecialchars(print_r($options, true)).'</pre>';
270
					unset( $form_part_signed );
271
					foreach ($options as $option) {
272
						if( !isset($form_part_signed) ) $form_part_signed = $list[0];
273
						$option_signed = preg_replace(
274
							'%'.preg_quote($option[1]).preg_quote($option[2]).preg_quote($option[1]).'%',
275
							$option[1].self::fc_hash_value($code, $list[2], $option[2], 'value', FALSE).$option[1],
276
							$option[0]);
277
						$form_part_signed = str_replace($option[0], $option_signed, $form_part_signed );
278
						self::$log[] = '<strong>OPTION:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
279
						               '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$list[2]).
280
						               '</strong> :: Value: <strong>'.htmlspecialchars($option[2]).
281
						               '</strong><br />Initial option: '.htmlspecialchars($option[0]).
282
						               '<br />Signed: <span style="color:#060;">'.htmlspecialchars($option_signed).'</span>';
283
					}
284
					$form = str_replace($list[0], $form_part_signed, $form);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $form_part_signed does not seem to be defined for all execution paths leading up to this point.
Loading history...
285
				}
286
				self::$log[] = '<strong>FORM after OPTIONS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
287
288
				// Sign all <textarea /> elements
289
				preg_match_all('%<textarea [^>]*name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1[^>]*>(.*?)</textarea>%is', $form, $textareas, PREG_SET_ORDER);
290
				// echo "\n\nTextareas: ".print_r($textareas, true);
0 ignored issues
show
Unused Code Comprehensibility introduced by
62% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
291
				foreach ($textareas as $textarea) {
292
					$count['textareas']++;
293
					// Tackle implied "--OPEN--" first, if textarea is empty
294
					$textarea[3] = ($textarea[3] == '') ? '--OPEN--' : $textarea[3];
295
					$textarea_signed = preg_replace('%([\'"])'.preg_quote($prefix.$textarea[2]).'\1%', "$1".self::fc_hash_value($code, $textarea[2], $textarea[3], 'name', FALSE)."$1", $textarea[0]);
296
					$form = str_replace($textarea[0], $textarea_signed, $form);
297
					self::$log[] = '<strong>TEXTAREA:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
298
					               '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$textarea[2]).
299
					               '</strong> :: Value: <strong>'.htmlspecialchars($textarea[3]).
300
					               '</strong><br />Initial textarea: '.htmlspecialchars($textarea[0]).
301
					               '<br />Signed: <span style="color:#060;">'.htmlspecialchars($textarea_signed).'</span>';
302
				}
303
				self::$log[] = '<strong>FORM after TEXTAREAS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
304
305
				// Exclude all <button> elements
306
				$form = preg_replace('%<button ([^>]*)name=([\'"])(.*?)\1([^>]*>.*?</button>)%i', "<button $1name=$2x:$3$4", $form);
307
308
			}
309
			// Replace the entire form
310
			self::$log[] = '<strong>FORM after ALL:</strong> <pre>'.htmlspecialchars($form).'</pre>'.'replacing <pre>'.htmlspecialchars($form_original).'</pre>';
311
			$html = str_replace($form_original, $form, $html);
312
			self::$log[] = '<strong>FORM end</strong><hr />';
313
		}
314
315
		// Return the signed output
316
		$output = '';
317
		if (self::$debug) {
318
			self::$log['Summary'] = $count['links'].' links signed. '.$count['forms'].' forms signed. '.$count['inputs'].' inputs signed. '.$count['lists'].' lists signed. '.$count['textareas'].' textareas signed.';
319
			$output .= '<h3>FoxyCart HMAC Debugging:</h3><ul>';
320
			foreach (self::$log as $name => $value) {
321
				$output .= '<li><strong>'.$name.':</strong> '.$value.'</li>';
322
			}
323
			$output .= '</ul><hr />';
324
		}
325
		return $output.$html;
326
	}
327
328
}