Passed
Pull Request — master (#20)
by Jason
01:30
created

FoxyCart_Helper::getSecret()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 2
rs 10
1
<?php
2
3
use Dynamic\Foxy\Model\Foxy;
4
5
/**
6
 * FoxyCart_Helper
7
 *
8
 * @author FoxyCart.com
9
 * @copyright FoxyCart.com LLC, 2011
10
 * @version 2.0.0.20171024
11
 * @license MIT http://opensource.org/licenses/MIT
12
 * @example http://wiki.foxycart.com/docs/cart/validation
13
 *
14
 * Requirements:
15
 *   - Form "code" values should not have leading or trailing whitespace.
16
 *   - Cannot use double-pipes in an input's name
17
 *   - Empty textareas are assumed to be "open"
18
 */
19
class FoxyCart_Helper {
20
21
    /**
22
     * API Key (Secret)
23
     *
24
     * @var string
25
     **/
26
    private static $secret;
27
28
    public static function setSecret($secret) {
29
        self::$secret = $secret;
30
    }
31
    public static function getSecret() {
32
        return self::$secret;
33
    }
34
35
    /**
36
     * Cart URL
37
     *
38
     * @var string
39
     * Notes: Could be 'https://yourdomain.foxycart.com/cart' or 'https://secure.yourdomain.com/cart'
40
     **/
41
    protected static $cart_url;
42
43
    public static function setCartUrl($cart_url) {
44
        self::$cart_url = $cart_url;
45
    }
46
    public static function getCartUrl() {
47
        return self::$cart_url;
48
    }
49
50
    public function __construct()
51
    {
52
        self::setCartURL(Foxy::getStoreDomain());
53
        self::setSecret(Foxy::getStoreKey());
54
    }
55
56
    /**
57
     * Cart Excludes
58
     *
59
     * Arrays of values and prefixes that should be ignored when signing links and forms.
60
     * @var array
61
     */
62
    protected static $cart_excludes = array(
63
        // Analytics values
64
        '_', '_ga', '_ke',
65
        // Cart values
66
        'cart', 'fcsid', 'empty', 'coupon', 'output', 'sub_token', 'redirect', 'callback', 'locale', 'template_set',
67
        // Checkout pre-population values
68
        'customer_email', 'customer_first_name', 'customer_last_name', 'customer_address1', 'customer_address2',
69
        'customer_city', 'customer_state', 'customer_postal_code', 'customer_country', 'customer_phone', 'customer_company',
70
        'billing_first_name', 'billing_last_name', 'billing_address1', 'billing_address2',
71
        'billing_city', 'billing_postal_code', 'billing_region', 'billing_phone', 'billing_company',
72
        'shipping_first_name', 'shipping_last_name', 'shipping_address1', 'shipping_address2',
73
        'shipping_city', 'shipping_state', 'shipping_country', 'shipping_postal_code', 'shipping_region', 'shipping_phone', 'shipping_company',
74
    );
75
    protected static $cart_excludes_prefixes = array(
76
        'h:', 'x:', '__', 'utm_'
77
    );
78
79
    /**
80
     * Debugging
81
     *
82
     * Set to $debug to TRUE to enable debug logging.
83
     *
84
     */
85
    protected static $debug = FALSE;
86
    protected static $log = array();
87
88
89
    /**
90
     * "Link Method": Generate HMAC SHA256 for GET Query Strings
91
     *
92
     * Notes: Can't parse_str because PHP doesn't support non-alphanumeric characters as array keys.
93
     * @return string
94
     **/
95
    public static function fc_hash_querystring($qs, $output = TRUE) {
96
        self::$log[] = '<strong>Signing link</strong> with data: '.htmlspecialchars(substr($qs, 0, 1500)).'...';
97
        $fail = self::$cart_url.'?'.$qs;
98
99
        // If the link appears to be hashed already, don't bother
100
        if (strpos($qs, '||')) {
101
            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...
102
            return $fail;
103
        }
104
105
        // Stick an ampersand on the beginning of the querystring to make matching the first element a little easier
106
        $qs = '&'.$qs;
107
108
        // Get all the prefixes, codes, and name=value pairs
109
        preg_match_all('%(?P<amp>&(?:amp;)?)(?P<prefix>[a-z0-9]{1,3}:)?(?P<name>[^=]+)=(?P<value>[^&]+)%', $qs, $pairs, PREG_SET_ORDER);
110
        self::$log[] = 'Found the following pairs to sign:<pre>'.htmlspecialchars(print_r($pairs, true)).'</pre>';
111
112
        // Get all the "code" values, set the matches in $codes
113
        $codes = array();
114
        foreach ($pairs as $pair) {
115
            if ($pair['name'] == 'code') {
116
                $codes[$pair['prefix']] = $pair['value'];
117
            }
118
        }
119
        foreach ($pairs as $pair) {
120
            if ($pair['name'] == 'parent_code') {
121
                $codes[$pair['prefix']] .= $pair['value'];
122
            }
123
        }
124
        if ( ! count($codes)) {
125
            self::$log[] = '<strong style="color:#600;">No code found</strong> for the above link.';
126
            return $fail;
127
        }
128
        self::$log[] = '<strong style="color:orange;">CODES found:</strong> '.htmlspecialchars(print_r($codes, true));
129
130
        // Sign the name/value pairs
131
        foreach ($pairs as $pair) {
132
            // Skip the cart excludes
133
            $include_pair = true;
134
            if (in_array($pair['name'], self::$cart_excludes)) {
135
                $include_pair = false;
136
            }
137
            foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
138
                if (substr(strtolower($pair['prefix'].$pair['name']), 0, strlen($exclude_prefix)) == $exclude_prefix) {
139
                    $include_pair = false;
140
                }
141
            }
142
            if (!$include_pair) {
143
                self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$pair['prefix'].$pair['name'].'" = '.$pair['value'];
144
                continue;
145
            }
146
            // Continue to sign the value and replace the name=value in the querystring with name=value||hash
147
            $value = self::fc_hash_value($codes[$pair['prefix']], urldecode($pair['name']), urldecode($pair['value']), 'value', FALSE, 'urlencode');
148
            if (urldecode($pair['value']) == '--OPEN--') {
149
                $replacement = $pair['amp'].$value.'=';
150
            } else {
151
                $replacement = $pair['amp'].$pair['prefix'].urlencode($pair['name']).'='.$value;
152
            }
153
            $qs = str_replace($pair[0], $replacement, $qs);
154
            self::$log[] = 'Signed <strong>'.$pair['name'].'</strong> = <strong>'.$pair['value'].'</strong> with '.$replacement.'.<br />Replacing: '.$pair[0].'<br />With... '.$replacement;
155
        }
156
        $qs = ltrim($qs, '&'); // Get rid of that leading ampersand we added earlier
157
158
        if ($output) {
159
            echo self::$cart_url.'?'.$qs;
160
        } else {
161
            return self::$cart_url.'?'.$qs;
162
        }
163
    }
164
165
166
    /**
167
     * "Form Method": Generate HMAC SHA256 for form elements or individual <input />s
168
     *
169
     * @return string
170
     **/
171
    public static function fc_hash_value($product_code, $option_name, $option_value = '', $method = 'name', $output = TRUE, $urlencode = false) {
172
        if (!$product_code || !$option_name) {
173
            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...
174
        }
175
        $option_name = str_replace(' ', '_', $option_name);
176
        if ($option_value == '--OPEN--') {
177
            $hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::$secret);
178
            $value = ($urlencode) ? urlencode($option_name).'||'.$hash.'||open' : $option_name.'||'.$hash.'||open';
179
        } else {
180
            $hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::$secret);
181
            self::$log[] = '<strong>Challenge: </strong><span style="font-family:monospace; color:blue"><code>'.$product_code.$option_name.$option_value.'</code></span>';
182
            if ($method == 'name') {
183
                $value = ($urlencode) ? urlencode($option_name).'||'.$hash : $option_name.'||'.$hash;
184
            } else {
185
                $value = ($urlencode) ? urlencode($option_value).'||'.$hash : $option_value.'||'.$hash;
186
            }
187
        }
188
189
        if ($output) {
190
            echo $value;
191
        } else {
192
            return $value;
193
        }
194
    }
195
196
    /**
197
     * Raw HTML Signing: Sign all links and form elements in a block of HTML
198
     *
199
     * Accepts a string of HTML and signs all links and forms.
200
     * Requires link 'href' and form 'action' attributes to use 'https' and not 'http'.
201
     * Requires a 'code' to be set in every form.
202
     *
203
     * @return string
204
     **/
205
    public static function fc_hash_html($html) {
206
        // Initialize some counting
207
        $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...
208
        $count['links'] = 0;
209
        $count['forms'] = 0;
210
        $count['inputs'] = 0;
211
        $count['lists'] = 0;
212
        $count['textareas'] = 0;
213
214
        // Find and sign all the links
215
        preg_match_all('%<a .*?href=([\'"])'.preg_quote(self::$cart_url).'(?:\.php)?\?(.+?)\1.*?>%i', $html, $querystrings);
216
        self::$log[] = '<strong>Querystrings: </strong><pre>' . htmlspecialchars(print_r($querystrings, true)) . '</pre>';
217
        // print_r($querystrings);
218
        foreach ($querystrings[2] as $querystring) {
219
            // If it's already signed, skip it.
220
            if (strpos($querystring, '||')) {
221
                continue;
222
            }
223
            $pattern = '%(href=([\'"]))'.preg_quote(self::$cart_url, '%').'(?:\.php)?\?'.preg_quote($querystring, '%').'\2%i';
224
            $signed = self::fc_hash_querystring($querystring, FALSE);
225
            $html = preg_replace($pattern, '$1'.$signed.'$2', $html, -1, $count['temp']);
226
            $count['links'] += $count['temp'];
227
        }
228
        unset($querystrings);
229
230
        // Find and sign all form values
231
        preg_match_all('%<form [^>]*?action=[\'"]'.preg_quote(self::$cart_url).'(?:\.php)?[\'"].*?>(.+?)</form>%is', $html, $forms);
232
        foreach ($forms[1] as $form) {
233
            $count['forms']++;
234
            self::$log[] = '<strong>Signing form</strong> with data: '.htmlspecialchars(substr($form, 0, 150)).'...';
235
236
            // Store the original form so we can replace it when we're done
237
            $form_original = $form;
238
239
            // Check for the "code" input, set the matches in $codes
240
            if (!preg_match_all('%<[^>]*?name=([\'"])([0-9]{1,3}:)?code\1[^>]*?>%i', $form, $codes, PREG_SET_ORDER)) {
241
                self::$log[] = '<strong style="color:#600;">No code found</strong> for the above form.';
242
                continue;
243
            }
244
            // For each code found, sign the appropriate inputs
245
            foreach ($codes as $code) {
246
                // If the form appears to be hashed already, don't bother
247
                if (strpos($code[0], '||')) {
248
                    self::$log[] = '<strong>Form appears to be signed already</strong>: '.htmlspecialchars($code[0]);
249
                    continue;
250
                }
251
                // Get the code and the prefix
252
                $prefix = (isset($code[2])) ? $code[2] : '';
253
                preg_match('%<[^>]*?value=([\'"])(.+?)\1[^>]*?>%i', $code[0], $code);
254
                $code = trim($code[2]);
255
                self::$log[] = '<strong>Prefix for '.htmlspecialchars($code).'</strong>: '.htmlspecialchars($prefix);
256
                if (!$code) { // If the code is empty, skip this form or specific prefixed elements
257
                    continue;
258
                }
259
260
                // Sign all <input /> elements with matching prefix
261
                preg_match_all('%<input [^>]*?name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(?:.+?)\1[^>]*>%i', $form, $inputs);
262
263
                // get parent codes if they exist and append them to our code
264
                $parent_code_index = false;
265
                foreach ($inputs[0] as $key => $item) {
266
                    if (strpos($item, 'parent_code') !== false) {
267
                        $parent_code_index = $key;
268
                    }
269
                }
270
                if ($parent_code_index !== false) {
271
                    if (preg_match('%value=([\'"])(.*?)\1%i', $inputs[0][$parent_code_index], $value)) {
272
                        $code .= $value[2];
273
                    }
274
                }
275
276
                foreach ($inputs[0] as $input) {
277
                    $count['inputs']++;
278
                    // Test to make sure both name and value attributes are found
279
                    if (preg_match('%name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1%i', $input, $name) > 0) {
280
                        preg_match('%value=([\'"])(.*?)\1%i', $input, $value);
281
                        $value = (count($value) > 0) ? $value : array('', '', '');
282
                        preg_match('%type=([\'"])(.*?)\1%i', $input, $type);
283
                        $type = (count($type) > 0) ? $type : array('', '', '');
284
                        // Skip the cart excludes
285
                        $include_input = true;
286
                        if (in_array($prefix.$name[2], self::$cart_excludes)) {
287
                            $include_input = false;
288
                        }
289
                        foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
290
                            if (substr(strtolower($prefix.$name[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
291
                                $include_input = false;
292
                            }
293
                        }
294
                        if (!$include_input) {
295
                            self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$prefix.$name[2].'" = '.$value[2];
296
                            continue;
297
                        }
298
                        self::$log[] = '<strong>INPUT['.$type[2].']:</strong> Name: <strong>'.$prefix.htmlspecialchars(preg_quote($name[2])).'</strong>';
299
                        $value[2] = ($value[2] == '') ? '--OPEN--' : $value[2];
300
                        if ($type[2] == 'radio') {
301
                            self::$log[] = '<strong>Replacement Pattern:</strong> ([\'"])'.$prefix.preg_quote($value[2]).'\1';
302
                            $input_signed = preg_replace('%([\'"])'.preg_quote($value[2]).'\1%', '${1}'.self::fc_hash_value($code, $name[2], $value[2], 'value', FALSE)."$1", $input);
303
                        } else {
304
                            self::$log[] = '<strong>Replacement Pattern:</strong> name=([\'"])'.$prefix.preg_quote($name[2]).'\1';
305
                            $input_signed = preg_replace('%name=([\'"])'.$prefix.preg_quote($name[2]).'\1%', 'name=${1}'.$prefix.self::fc_hash_value($code, $name[2], $value[2], 'name', FALSE)."$1", $input);
306
                        }
307
                        self::$log[] = '<strong>INPUT:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
308
                            '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$name[2]).
309
                            '</strong> :: Value: <strong>'.htmlspecialchars($value[2]).
310
                            '</strong><br />Initial input: '.htmlspecialchars($input).
311
                            '<br />Signed: <span style="color:#060;">'.htmlspecialchars($input_signed).'</span>';
312
                        $form = str_replace($input, $input_signed, $form);
313
                    }
314
                }
315
                self::$log[] = '<strong>FORM after INPUTS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
316
317
                // Sign all <option /> elements
318
                preg_match_all('%<select [^>]*name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1[^>]*>(.+?)</select>%is', $form, $lists, PREG_SET_ORDER);
319
                foreach ($lists as $list) {
320
                    $count['lists']++;
321
                    // Skip the cart excludes
322
                    $include_input = true;
323
                    if (in_array($prefix.$list[2], self::$cart_excludes)) {
324
                        $include_input = false;
325
                    }
326
                    foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
327
                        if (substr(strtolower($prefix.$list[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
328
                            $include_input = false;
329
                        }
330
                    }
331
                    if (!$include_input) {
332
                        self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$prefix.$list[2];
333
                        continue;
334
                    }
335
                    preg_match_all('%<option [^>]*value=([\'"])(.+?)\1[^>]*>(?:.*?)</option>%i', $list[0], $options, PREG_SET_ORDER);
336
                    self::$log[] = '<strong>Options:</strong> <pre>'.htmlspecialchars(print_r($options, true)).'</pre>';
337
                    unset( $form_part_signed );
338
                    foreach ($options as $option) {
339
                        if( !isset($form_part_signed) ) $form_part_signed = $list[0];
340
                        $option_signed = preg_replace(
341
                            '%'.preg_quote($option[1]).preg_quote($option[2]).preg_quote($option[1]).'%',
342
                            $option[1].self::fc_hash_value($code, $list[2], $option[2], 'value', FALSE).$option[1],
343
                            $option[0]);
344
                        $form_part_signed = str_replace($option[0], $option_signed, $form_part_signed );
345
                        self::$log[] = '<strong>OPTION:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
346
                            '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$list[2]).
347
                            '</strong> :: Value: <strong>'.htmlspecialchars($option[2]).
348
                            '</strong><br />Initial option: '.htmlspecialchars($option[0]).
349
                            '<br />Signed: <span style="color:#060;">'.htmlspecialchars($option_signed).'</span>';
350
                    }
351
                    $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...
352
                }
353
                self::$log[] = '<strong>FORM after OPTIONS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
354
355
                // Sign all <textarea /> elements
356
                preg_match_all('%<textarea [^>]*name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1[^>]*>(.*?)</textarea>%is', $form, $textareas, PREG_SET_ORDER);
357
                // echo "\n\nTextareas: ".print_r($textareas, true);
358
                foreach ($textareas as $textarea) {
359
                    $count['textareas']++;
360
                    // Skip the cart excludes
361
                    $include_input = true;
362
                    if (in_array($prefix.$textarea[2], self::$cart_excludes)) {
363
                        $include_input = false;
364
                    }
365
                    foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
366
                        if (substr(strtolower($prefix.$textarea[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
367
                            $include_input = false;
368
                        }
369
                    }
370
                    if (!$include_input) {
371
                        self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.$prefix.$textarea[2];
372
                        continue;
373
                    }
374
                    // Tackle implied "--OPEN--" first, if textarea is empty
375
                    $textarea[3] = ($textarea[3] == '') ? '--OPEN--' : $textarea[3];
376
                    $textarea_signed = preg_replace('%name=([\'"])'.preg_quote($prefix.$textarea[2]).'\1%', "name=$1".self::fc_hash_value($code, $textarea[2], $textarea[3], 'name', FALSE)."$1", $textarea[0]);
377
                    $form = str_replace($textarea[0], $textarea_signed, $form);
378
                    self::$log[] = '<strong>TEXTAREA:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
379
                        '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$textarea[2]).
380
                        '</strong> :: Value: <strong>'.htmlspecialchars($textarea[3]).
381
                        '</strong><br />Initial textarea: '.htmlspecialchars($textarea[0]).
382
                        '<br />Signed: <span style="color:#060;">'.htmlspecialchars($textarea_signed).'</span>';
383
                }
384
                self::$log[] = '<strong>FORM after TEXTAREAS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
385
386
                // Exclude all <button> elements
387
                $form = preg_replace('%<button ([^>]*)name=([\'"])(.*?)\1([^>]*>.*?</button>)%i', "<button $1name=$2x:$3$4", $form);
388
389
            }
390
            // Replace the entire form
391
            self::$log[] = '<strong>FORM after ALL:</strong> <pre>'.htmlspecialchars($form).'</pre>'.'replacing <pre>'.htmlspecialchars($form_original).'</pre>';
392
            $html = str_replace($form_original, $form, $html);
393
            self::$log[] = '<strong>FORM end</strong><hr />';
394
        }
395
396
        // Return the signed output
397
        $output = '';
398
        if (self::$debug) {
399
            self::$log['Summary'] = $count['links'].' links signed. '.$count['forms'].' forms signed. '.$count['inputs'].' inputs signed. '.$count['lists'].' lists signed. '.$count['textareas'].' textareas signed.';
400
            $output .= '<div style="background:#fff;"><h3>FoxyCart HMAC Debugging:</h3><ul>';
401
            foreach (self::$log as $name => $value) {
402
                $output .= '<li><strong>'.$name.':</strong> '.$value.'</li>'."\n";
403
            }
404
            $output .= '</ul><hr />';
405
        }
406
        return $output.$html;
407
    }
408
409
}