Projet

Général

Profil

Paste
Télécharger (15,2 ko) Statistiques
| Branche: | Révision:

root / drupal7 / sites / all / modules / honeypot / honeypot.module @ b42754b9

1
<?php
2

    
3
/**
4
 * @file
5
 * Honeypot module, for deterring spam bots from completing Drupal forms.
6
 */
7

    
8
/**
9
 * Implements hook_menu().
10
 */
11
function honeypot_menu() {
12
  $items['admin/config/content/honeypot'] = array(
13
    'title' => 'Honeypot configuration',
14
    'description' => 'Configure Honeypot spam prevention and the forms on which Honeypot will be used.',
15
    'page callback' => 'drupal_get_form',
16
    'page arguments' => array('honeypot_admin_form'),
17
    'access arguments' => array('administer honeypot'),
18
    'file' => 'honeypot.admin.inc',
19
  );
20

    
21
  return $items;
22
}
23

    
24
/**
25
 * Implements hook_permission().
26
 */
27
function honeypot_permission() {
28
  return array(
29
    'administer honeypot' => array(
30
      'title' => t('Administer Honeypot'),
31
      'description' => t('Administer Honeypot-protected forms and settings'),
32
    ),
33
    'bypass honeypot protection' => array(
34
      'title' => t('Bypass Honeypot protection'),
35
      'description' => t('Bypass Honeypot form protection.'),
36
    ),
37
  );
38
}
39

    
40
/**
41
 * Implements hook_cron().
42
 */
43
function honeypot_cron() {
44
  // Delete {honeypot_user} entries older than the value of honeypot_expire.
45
  db_delete('honeypot_user')
46
    ->condition('timestamp', REQUEST_TIME - variable_get('honeypot_expire', 300), '<')
47
    ->execute();
48

    
49
  // Regenerate the honeypot css file if it does not exist or is outdated.
50
  $honeypot_css = honeypot_get_css_file_path();
51
  $honeypot_element_name = variable_get('honeypot_element_name', 'url');
52
  if (!file_exists($honeypot_css) || !honeypot_check_css($honeypot_element_name)) {
53
    honeypot_create_css($honeypot_element_name);
54
  }
55
}
56

    
57
/**
58
 * Implements hook_form_alter().
59
 *
60
 * Add Honeypot features to forms enabled in the Honeypot admin interface.
61
 */
62
function honeypot_form_alter(&$form, &$form_state, $form_id) {
63
  // Don't use for maintenance mode forms (install, update, etc.).
64
  if (defined('MAINTENANCE_MODE')) {
65
    return;
66
  }
67

    
68
  $unprotected_forms = array(
69
    'user_login',
70
    'user_login_block',
71
    'search_form',
72
    'search_block_form',
73
    'views_exposed_form',
74
    'honeypot_admin_form',
75
  );
76

    
77
  // If configured to protect all forms, add protection to every form.
78
  if (variable_get('honeypot_protect_all_forms', 0) && !in_array($form_id, $unprotected_forms)) {
79
    // Don't protect system forms - only admins should have access, and system
80
    // forms may be programmatically submitted by drush and other modules.
81
    if (strpos($form_id, 'system_') === FALSE && strpos($form_id, 'search_') === FALSE && strpos($form_id, 'views_exposed_form_') === FALSE) {
82
      honeypot_add_form_protection($form, $form_state, array('honeypot', 'time_restriction'));
83
    }
84
  }
85

    
86
  // Otherwise add form protection to admin-configured forms.
87
  elseif ($forms_to_protect = honeypot_get_protected_forms()) {
88
    foreach ($forms_to_protect as $protect_form_id) {
89
      // For most forms, do a straight check on the form ID.
90
      if ($form_id == $protect_form_id) {
91
        honeypot_add_form_protection($form, $form_state, array('honeypot', 'time_restriction'));
92
      }
93
      // For webforms use a special check for variable form ID.
94
      elseif ($protect_form_id == 'webforms' && (strpos($form_id, 'webform_client_form') !== FALSE)) {
95
        honeypot_add_form_protection($form, $form_state, array('honeypot', 'time_restriction'));
96
      }
97
    }
98
  }
99
}
100

    
101
/**
102
 * Implements hook_trigger_info().
103
 */
104
function honeypot_trigger_info() {
105
  return array(
106
    'honeypot' => array(
107
      'honeypot_reject' => array(
108
        'label' => t('Honeypot rejection'),
109
      ),
110
    ),
111
  );
112
}
113

    
114
/**
115
 * Implements hook_rules_event_info().
116
 */
117
function honeypot_rules_event_info() {
118
  return array(
119
    'honeypot_reject' => array(
120
      'label' => t('Honeypot rejection'),
121
      'group' => t('Honeypot'),
122
      'variables' => array(
123
        'form_id' => array(
124
          'type' => 'text',
125
          'label' => t('Form ID of the form the user was disallowed from submitting.'),
126
        ),
127
        // Don't provide 'uid' in context because it is available as
128
        // site:current-user:uid.
129
        'type' => array(
130
          'type' => 'text',
131
          'label' => t('String indicating the reason the submission was blocked.'),
132
        ),
133
      ),
134
    ),
135
  );
136
}
137

    
138
/**
139
 * Build an array of all the protected forms on the site, by form_id.
140
 *
141
 * @todo - Add in API call/hook to allow modules to add to this array.
142
 */
143
function honeypot_get_protected_forms() {
144
  $forms = &drupal_static(__FUNCTION__);
145

    
146
  // If the data isn't already in memory, get from cache or look it up fresh.
147
  if (!isset($forms)) {
148
    if ($cache = cache_get('honeypot_protected_forms')) {
149
      $forms = $cache->data;
150
    }
151
    else {
152
      $forms = array();
153
      // Look up all the honeypot forms in the variables table.
154
      $result = db_query("SELECT name FROM {variable} WHERE name LIKE 'honeypot_form_%'")->fetchCol();
155
      // Add each form that's enabled to the $forms array.
156
      foreach ($result as $variable) {
157
        if (variable_get($variable, 0)) {
158
          $forms[] = substr($variable, 14);
159
        }
160
      }
161

    
162
      // Save the cached data.
163
      cache_set('honeypot_protected_forms', $forms, 'cache');
164
    }
165
  }
166

    
167
  return $forms;
168
}
169

    
170
/**
171
 * Form builder function to add different types of protection to forms.
172
 *
173
 * @param array $options
174
 *   Array of options to be added to form. Currently accepts 'honeypot' and
175
 *   'time_restriction'.
176
 *
177
 * @return array
178
 *   Returns elements to be placed in a form's elements array to prevent spam.
179
 */
180
function honeypot_add_form_protection(&$form, &$form_state, $options = array()) {
181
  global $user;
182

    
183
  // Allow other modules to alter the protections applied to this form.
184
  drupal_alter('honeypot_form_protections', $options, $form);
185

    
186
  // Don't add any protections if the user can bypass the Honeypot.
187
  if (user_access('bypass honeypot protection')) {
188
    return;
189
  }
190

    
191
  // Build the honeypot element.
192
  if (in_array('honeypot', $options)) {
193
    // Get the element name (default is generic 'url').
194
    $honeypot_element = variable_get('honeypot_element_name', 'url');
195

    
196
    // Add 'autocomplete="off"' if configured.
197
    $attributes = array();
198
    if (variable_get('honeypot_autocomplete_attribute', 1)) {
199
      $attributes = array('autocomplete' => 'off');
200
    }
201

    
202
    // Get the path to the honeypot css file.
203
    $honeypot_css = honeypot_get_css_file_path();
204

    
205
    // Build the honeypot element.
206
    $honeypot_class = $honeypot_element . '-textfield';
207
    $form[$honeypot_element] = array(
208
      '#type' => 'textfield',
209
      '#title' => t('Leave this field blank'),
210
      '#size' => 20,
211
      '#weight' => 100,
212
      '#attributes' => $attributes,
213
      '#element_validate' => array('_honeypot_honeypot_validate'),
214
      '#prefix' => '<div class="' . $honeypot_class . '">',
215
      '#suffix' => '</div>',
216
      // Hide honeypot using CSS.
217
      '#attached' => array(
218
        'css' => array(
219
          'data' => $honeypot_css,
220
        ),
221
      ),
222
    );
223
  }
224

    
225
  // Build the time restriction element (if it's not disabled).
226
  if (in_array('time_restriction', $options) && variable_get('honeypot_time_limit', 5) != 0) {
227
    // Set the current time in a hidden value to be checked later.
228
    $form['honeypot_time'] = array(
229
      '#type' => 'hidden',
230
      '#title' => t('Timestamp'),
231
      '#default_value' => honeypot_get_signed_timestamp(REQUEST_TIME),
232
      '#element_validate' => array('_honeypot_time_restriction_validate'),
233
    );
234

    
235
    // Disable page caching to make sure timestamp isn't cached.
236
    if (user_is_anonymous()) {
237
      drupal_page_is_cacheable(FALSE);
238
    }
239
  }
240

    
241
  // Allow other modules to react to addition of form protection.
242
  if (!empty($options)) {
243
    module_invoke_all('honeypot_add_form_protection', $options, $form);
244
  }
245
}
246

    
247
/**
248
 * Validate honeypot field.
249
 */
250
function _honeypot_honeypot_validate($element, &$form_state) {
251
  // Get the honeypot field value.
252
  $honeypot_value = $element['#value'];
253

    
254
  // Make sure it's empty.
255
  if (!empty($honeypot_value)) {
256
    _honeypot_log($form_state['values']['form_id'], 'honeypot');
257
    form_set_error('', t('There was a problem with your form submission. Please refresh the page and try again.'));
258
  }
259
}
260

    
261
/**
262
 * Validate honeypot's time restriction field.
263
 */
264
function _honeypot_time_restriction_validate($element, &$form_state) {
265
  if (!empty($form_state['programmed'])) {
266
    // Don't do anything if the form was submitted programmatically.
267
    return;
268
  }
269

    
270
  // Don't do anything if the triggering element is a preview button.
271
  if ($form_state['triggering_element']['#value'] == t('Preview')) {
272
    return;
273
  }
274

    
275
  // Get the time value.
276
  $honeypot_time = honeypot_get_time_from_signed_timestamp($form_state['values']['honeypot_time']);
277

    
278
  // Get the honeypot_time_limit.
279
  $time_limit = honeypot_get_time_limit($form_state['values']);
280

    
281
  // Make sure current time - (time_limit + form time value) is greater than 0.
282
  // If not, throw an error.
283
  if (!$honeypot_time || REQUEST_TIME < ($honeypot_time + $time_limit)) {
284
    _honeypot_log($form_state['values']['form_id'], 'honeypot_time');
285
    // Get the time limit again, since it increases after first failure.
286
    $time_limit = honeypot_get_time_limit($form_state['values']);
287
    $form_state['values']['honeypot_time'] = honeypot_get_signed_timestamp(REQUEST_TIME);
288
    form_set_error('', t('There was a problem with your form submission. Please wait @limit seconds and try again.', array('@limit' => $time_limit)));
289
  }
290
}
291

    
292
/**
293
 * Log blocked form submissions.
294
 *
295
 * @param string $form_id
296
 *   Form ID for the form on which submission was blocked.
297
 * @param string $type
298
 *   String indicating the reason the submission was blocked. Allowed values:
299
 *     - honeypot: If honeypot field was filled in.
300
 *     - honeypot_time: If form was completed before the configured time limit.
301
 */
302
function _honeypot_log($form_id, $type) {
303
  honeypot_log_failure($form_id, $type);
304
  if (variable_get('honeypot_log', 0)) {
305
    $variables = array(
306
      '%form'  => $form_id,
307
      '@cause' => ($type == 'honeypot') ? t('submission of a value in the honeypot field') : t('submission of the form in less than minimum required time'),
308
    );
309
    watchdog('honeypot', 'Blocked submission of %form due to @cause.', $variables);
310
  }
311
}
312

    
313
/**
314
 * Look up the time limit for the current user.
315
 *
316
 * @param array $form_values
317
 *   Array of form values (optional).
318
 */
319
function honeypot_get_time_limit($form_values = array()) {
320
  global $user;
321
  $honeypot_time_limit = variable_get('honeypot_time_limit', 5);
322

    
323
  // Only calculate time limit if honeypot_time_limit has a value > 0.
324
  if ($honeypot_time_limit) {
325
    $expire_time = variable_get('honeypot_expire', 300);
326
    // Query the {honeypot_user} table to determine the number of failed
327
    // submissions for the current user.
328
    $query = db_select('honeypot_user', 'hs')
329
      ->condition('uid', $user->uid)
330
      ->condition('timestamp', REQUEST_TIME - $expire_time, '>');
331

    
332
    // For anonymous users, take the hostname into account.
333
    if ($user->uid === 0) {
334
      $query->condition('hostname', ip_address());
335
    }
336
    $number = $query->countQuery()->execute()->fetchField();
337

    
338
    // Don't add more than 30 days' worth of extra time.
339
    $honeypot_time_limit = (int) min($honeypot_time_limit + exp($number) - 1, 2592000);
340
    $additions = module_invoke_all('honeypot_time_limit', $honeypot_time_limit, $form_values, $number);
341
    if (count($additions)) {
342
      $honeypot_time_limit += array_sum($additions);
343
    }
344
  }
345

    
346
  return $honeypot_time_limit;
347
}
348

    
349
/**
350
 * Log the failed submision with timestamp and hostname.
351
 *
352
 * @param string $form_id
353
 *   Form ID for the rejected form submission.
354
 * @param string $type
355
 *   String indicating the reason the submission was blocked. Allowed values:
356
 *     - honeypot: If honeypot field was filled in.
357
 *     - honeypot_time: If form was completed before the configured time limit.
358
 */
359
function honeypot_log_failure($form_id, $type) {
360
  global $user;
361

    
362
  db_insert('honeypot_user')
363
    ->fields(array(
364
      'uid' => $user->uid,
365
      'hostname' => ip_address(),
366
      'timestamp' => REQUEST_TIME,
367
    ))
368
    ->execute();
369

    
370
  // Allow other modules to react to honeypot rejections.
371
  module_invoke_all('honeypot_reject', $form_id, $user->uid, $type);
372

    
373
  // Trigger honeypot_reject action.
374
  if (module_exists('trigger')) {
375
    $aids = trigger_get_assigned_actions('honeypot_reject');
376
    $context = array(
377
      'group' => 'honeypot',
378
      'hook' => 'honeypot_reject',
379
      'form_id' => $form_id,
380
      // Do not provide $user in context because it is available as a global.
381
      'type' => $type,
382
    );
383
    // Honeypot does not act on any specific object.
384
    $object = NULL;
385
    actions_do(array_keys($aids), $object, $context);
386
  }
387

    
388
  // Trigger rules honeypot_reject event.
389
  if (module_exists('rules')) {
390
    rules_invoke_event('honeypot_reject', $form_id, $type);
391
  }
392
}
393

    
394
/**
395
 * Retrieve the location of the Honeypot CSS file.
396
 *
397
 * @return string
398
 *   The path to the honeypot.css file.
399
 */
400
function honeypot_get_css_file_path() {
401
  return variable_get('file_public_path', conf_path() . '/files') . '/honeypot/honeypot.css';
402
}
403

    
404
/**
405
 * Create CSS file to hide the Honeypot field.
406
 *
407
 * @param string $element_name
408
 *   The honeypot element class name (e.g. 'url').
409
 */
410
function honeypot_create_css($element_name) {
411
  $path = 'public://honeypot';
412

    
413
  if (!file_prepare_directory($path, FILE_CREATE_DIRECTORY)) {
414
    drupal_set_message(t('Unable to create Honeypot CSS directory, %path. Check the permissions on your files directory.', array('%path' => file_uri_target($path))), 'error');
415
  }
416
  else {
417
    $filename = $path . '/honeypot.css';
418
    $data = '.' . $element_name . '-textfield { display: none !important; }';
419
    file_unmanaged_save_data($data, $filename, FILE_EXISTS_REPLACE);
420
  }
421
}
422

    
423
/**
424
 * Check Honeypot's CSS file for a given Honeypot element name.
425
 *
426
 * This function assumes the Honeypot CSS file already exists.
427
 *
428
 * @param string $element_name
429
 *   The honeypot element class name (e.g. 'url').
430
 *
431
 * @return bool
432
 *   TRUE if CSS is has element class name, FALSE if not.
433
 */
434
function honeypot_check_css($element_name) {
435
  $path = honeypot_get_css_file_path();
436
  $handle = fopen($path, 'r');
437
  $contents = fread($handle, filesize($path));
438
  fclose($handle);
439

    
440
  if (strpos($contents, $element_name) === 1) {
441
    return TRUE;
442
  }
443

    
444
  return FALSE;
445
}
446

    
447
/**
448
 * Sign the timestamp $time.
449
 *
450
 * @param mixed $time
451
 *   The timestamp to sign.
452
 *
453
 * @return string
454
 *   A signed timestamp in the form timestamp|HMAC.
455
 */
456
function honeypot_get_signed_timestamp($time) {
457
  return $time . '|' . drupal_hmac_base64($time, drupal_get_private_key());
458
}
459

    
460
/**
461
 * Validate a signed timestamp.
462
 *
463
 * @param string $signed_timestamp
464
 *   A timestamp concateneted with the signature
465
 *
466
 * @return int
467
 *   The timestamp if the signature is correct, 0 otherwise.
468
 */
469
function honeypot_get_time_from_signed_timestamp($signed_timestamp) {
470
  $honeypot_time = 0;
471

    
472
  // Fail fast if timestamp was forged or saved with an older Honeypot version.
473
  if (strpos($signed_timestamp, '|') === FALSE) {
474
    return $honeypot_time;
475
  }
476

    
477
  list($timestamp, $received_hmac) = explode('|', $signed_timestamp);
478

    
479
  if ($timestamp && $received_hmac) {
480
    $calculated_hmac = drupal_hmac_base64($timestamp, drupal_get_private_key());
481
    // Prevent leaking timing information, compare second order hmacs.
482
    $random_key = drupal_random_bytes(32);
483
    if (drupal_hmac_base64($calculated_hmac, $random_key) === drupal_hmac_base64($received_hmac, $random_key)) {
484
      $honeypot_time = $timestamp;
485
    }
486
  }
487

    
488
  return $honeypot_time;
489
}