honeypot.module 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. <?php
  2. /**
  3. * @file
  4. * Honeypot module, for deterring spam bots from completing Drupal forms.
  5. */
  6. use Drupal\Core\Form\FormStateInterface;
  7. use Drupal\Component\Utility\Crypt;
  8. /**
  9. * Implements hook_cron().
  10. */
  11. function honeypot_cron() {
  12. // Delete {honeypot_user} entries older than the value of honeypot_expire.
  13. $expire_limit = \Drupal::config('honeypot.settings')->get('expire');
  14. \Drupal::database()->delete('honeypot_user')
  15. ->condition('timestamp', REQUEST_TIME - $expire_limit, '<')
  16. ->execute();
  17. }
  18. /**
  19. * Implements hook_form_alter().
  20. *
  21. * Add Honeypot features to forms enabled in the Honeypot admin interface.
  22. */
  23. function honeypot_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  24. // Don't use for maintenance mode forms (install, update, etc.).
  25. if (defined('MAINTENANCE_MODE')) {
  26. return;
  27. }
  28. // Add a tag to all forms, so that if they are cached and honeypot
  29. // configuration is changed, the cached forms are invalidated and honeypot
  30. // protection can be re-evaluated.
  31. $form['#cache']['tags'][] = 'config:honeypot.settings';
  32. // Get list of unprotected forms and setting for whether to protect all forms.
  33. $unprotected_forms = \Drupal::config('honeypot.settings')->get('unprotected_forms');
  34. $protect_all_forms = \Drupal::config('honeypot.settings')->get('protect_all_forms');
  35. // If configured to protect all forms, add protection to every form.
  36. if ($protect_all_forms && !in_array($form_id, $unprotected_forms)) {
  37. // Don't protect system forms - only admins should have access, and system
  38. // forms may be programmatically submitted by drush and other modules.
  39. if (strpos($form_id, 'system_') === FALSE && strpos($form_id, 'search_') === FALSE && strpos($form_id, 'views_exposed_form_') === FALSE) {
  40. honeypot_add_form_protection($form, $form_state, ['honeypot', 'time_restriction']);
  41. }
  42. }
  43. // Otherwise add form protection to admin-configured forms.
  44. elseif ($forms_to_protect = honeypot_get_protected_forms()) {
  45. foreach ($forms_to_protect as $protect_form_id) {
  46. // For most forms, do a straight check on the form ID.
  47. if ($form_id == $protect_form_id) {
  48. honeypot_add_form_protection($form, $form_state, ['honeypot', 'time_restriction']);
  49. }
  50. }
  51. }
  52. }
  53. /**
  54. * Build an array of all the protected forms on the site, by form_id.
  55. */
  56. function honeypot_get_protected_forms() {
  57. $forms = &drupal_static(__FUNCTION__);
  58. // If the data isn't already in memory, get from cache or look it up fresh.
  59. if (!isset($forms)) {
  60. if ($cache = \Drupal::cache()->get('honeypot_protected_forms')) {
  61. $forms = $cache->data;
  62. }
  63. else {
  64. $form_settings = \Drupal::config('honeypot.settings')->get('form_settings');
  65. if (!empty($form_settings)) {
  66. // Add each form that's enabled to the $forms array.
  67. foreach ($form_settings as $form_id => $enabled) {
  68. if ($enabled) {
  69. $forms[] = $form_id;
  70. }
  71. }
  72. }
  73. else {
  74. $forms = [];
  75. }
  76. // Save the cached data.
  77. \Drupal::cache()->set('honeypot_protected_forms', $forms);
  78. }
  79. }
  80. return $forms;
  81. }
  82. /**
  83. * Form builder function to add different types of protection to forms.
  84. *
  85. * @param array $options
  86. * Array of options to be added to form. Currently accepts 'honeypot' and
  87. * 'time_restriction'.
  88. */
  89. function honeypot_add_form_protection(&$form, FormStateInterface $form_state, array $options = []) {
  90. $account = \Drupal::currentUser();
  91. // Allow other modules to alter the protections applied to this form.
  92. \Drupal::moduleHandler()->alter('honeypot_form_protections', $options, $form);
  93. // Don't add any protections if the user can bypass the Honeypot.
  94. if ($account->hasPermission('bypass honeypot protection')) {
  95. return;
  96. }
  97. // Build the honeypot element.
  98. if (in_array('honeypot', $options)) {
  99. // Get the element name (default is generic 'url').
  100. $honeypot_element = \Drupal::config('honeypot.settings')->get('element_name');
  101. // Build the honeypot element.
  102. $honeypot_class = $honeypot_element . '-textfield';
  103. $form[$honeypot_element] = [
  104. '#theme_wrappers' => [
  105. 'container' => [
  106. '#id' => NULL,
  107. '#attributes' => [
  108. 'class' => [
  109. $honeypot_class,
  110. ],
  111. 'style' => [
  112. 'display: none !important;',
  113. ],
  114. ],
  115. ],
  116. ],
  117. '#type' => 'textfield',
  118. '#title' => t('Leave this field blank'),
  119. '#size' => 20,
  120. '#weight' => 100,
  121. '#attributes' => ['autocomplete' => 'off'],
  122. '#element_validate' => ['_honeypot_honeypot_validate'],
  123. ];
  124. }
  125. // Set the time restriction for this form (if it's not disabled).
  126. if (in_array('time_restriction', $options) && \Drupal::config('honeypot.settings')->get('time_limit') != 0) {
  127. // Set the current time in a hidden value to be checked later.
  128. $input = $form_state->getUserInput();
  129. if (empty($input['honeypot_time'])) {
  130. $identifier = Crypt::randomBytesBase64();
  131. \Drupal::service('keyvalue.expirable')->get('honeypot_time_restriction')->set($identifier, time(), 3600*24);
  132. }
  133. else {
  134. $identifier = $input['honeypot_time'];
  135. }
  136. $form['honeypot_time'] = [
  137. '#type' => 'hidden',
  138. '#title' => t('Timestamp'),
  139. '#default_value' => $identifier,
  140. '#element_validate' => ['_honeypot_time_restriction_validate'],
  141. '#cache' => [
  142. 'max-age' => 0,
  143. ],
  144. ];
  145. // Disable page caching to make sure timestamp isn't cached.
  146. $account = \Drupal::currentUser();
  147. if ($account->id() == 0) {
  148. // TODO D8 - Use DIC? See: http://drupal.org/node/1539454
  149. // Should this now set 'omit_vary_cookie' instead?
  150. Drupal::service('page_cache_kill_switch')->trigger();
  151. }
  152. }
  153. // Allow other modules to react to addition of form protection.
  154. if (!empty($options)) {
  155. \Drupal::moduleHandler()->invokeAll('honeypot_add_form_protection', [$options, $form]);
  156. }
  157. }
  158. /**
  159. * Validate honeypot field.
  160. */
  161. function _honeypot_honeypot_validate($element, FormStateInterface $form_state) {
  162. // Get the honeypot field value.
  163. $honeypot_value = $element['#value'];
  164. // Make sure it's empty.
  165. if (!empty($honeypot_value)) {
  166. _honeypot_log($form_state->getValue('form_id'), 'honeypot');
  167. $form_state->setErrorByName('', t('There was a problem with your form submission. Please refresh the page and try again.'));
  168. }
  169. }
  170. /**
  171. * Validate honeypot's time restriction field.
  172. */
  173. function _honeypot_time_restriction_validate($element, FormStateInterface $form_state) {
  174. if ($form_state->isProgrammed()) {
  175. // Don't do anything if the form was submitted programmatically.
  176. return;
  177. }
  178. $triggering_element = $form_state->getTriggeringElement();
  179. // Don't do anything if the triggering element is a preview button.
  180. if ($triggering_element['#value'] == t('Preview')) {
  181. return;
  182. }
  183. // Get the time value.
  184. $identifier = $form_state->getValue('honeypot_time', FALSE);
  185. $honeypot_time = \Drupal::service('keyvalue.expirable')->get('honeypot_time_restriction')->get($identifier, 0);
  186. // Get the honeypot_time_limit.
  187. $time_limit = honeypot_get_time_limit($form_state->getValues());
  188. // Make sure current time - (time_limit + form time value) is greater than 0.
  189. // If not, throw an error.
  190. if (!$honeypot_time || REQUEST_TIME < ($honeypot_time + $time_limit)) {
  191. _honeypot_log($form_state->getValue('form_id'), 'honeypot_time');
  192. $time_limit = honeypot_get_time_limit();
  193. \Drupal::service('keyvalue.expirable')->get('honeypot_time_restriction')->set($identifier, REQUEST_TIME, 3600*24);
  194. $form_state->setErrorByName('', t('There was a problem with your form submission. Please wait @limit seconds and try again.', ['@limit' => $time_limit]));
  195. }
  196. }
  197. /**
  198. * Log blocked form submissions.
  199. *
  200. * @param string $form_id
  201. * Form ID for the form on which submission was blocked.
  202. * @param string $type
  203. * String indicating the reason the submission was blocked. Allowed values:
  204. * - honeypot: If honeypot field was filled in.
  205. * - honeypot_time: If form was completed before the configured time limit.
  206. */
  207. function _honeypot_log($form_id, $type) {
  208. honeypot_log_failure($form_id, $type);
  209. if (\Drupal::config('honeypot.settings')->get('log')) {
  210. $variables = [
  211. '%form' => $form_id,
  212. '@cause' => ($type == 'honeypot') ? t('submission of a value in the honeypot field') : t('submission of the form in less than minimum required time'),
  213. ];
  214. \Drupal::logger('honeypot')->notice(t('Blocked submission of %form due to @cause.', $variables));
  215. }
  216. }
  217. /**
  218. * Look up the time limit for the current user.
  219. *
  220. * @param array $form_values
  221. * Array of form values (optional).
  222. */
  223. function honeypot_get_time_limit(array $form_values = []) {
  224. $account = \Drupal::currentUser();
  225. $honeypot_time_limit = \Drupal::config('honeypot.settings')->get('time_limit');
  226. // Only calculate time limit if honeypot_time_limit has a value > 0.
  227. if ($honeypot_time_limit) {
  228. $expire_time = \Drupal::config('honeypot.settings')->get('expire');
  229. // Query the {honeypot_user} table to determine the number of failed
  230. // submissions for the current user.
  231. $uid = $account->id();
  232. $query = \Drupal::database()->select('honeypot_user', 'hu')
  233. ->condition('uid', $uid)
  234. ->condition('timestamp', REQUEST_TIME - $expire_time, '>');
  235. // For anonymous users, take the hostname into account.
  236. if ($uid === 0) {
  237. $hostname = \Drupal::request()->getClientIp();
  238. $query->condition('hostname', $hostname);
  239. }
  240. $number = $query->countQuery()->execute()->fetchField();
  241. // Don't add more than 30 days' worth of extra time.
  242. $honeypot_time_limit = (int) min($honeypot_time_limit + exp($number) - 1, 2592000);
  243. // TODO - Only accepts two args.
  244. $additions = \Drupal::moduleHandler()->invokeAll('honeypot_time_limit', [
  245. $honeypot_time_limit,
  246. $form_values,
  247. $number,
  248. ]);
  249. if (count($additions)) {
  250. $honeypot_time_limit += array_sum($additions);
  251. }
  252. }
  253. return $honeypot_time_limit;
  254. }
  255. /**
  256. * Log the failed submission with timestamp and hostname.
  257. *
  258. * @param string $form_id
  259. * Form ID for the rejected form submission.
  260. * @param string $type
  261. * String indicating the reason the submission was blocked. Allowed values:
  262. * - honeypot: If honeypot field was filled in.
  263. * - honeypot_time: If form was completed before the configured time limit.
  264. */
  265. function honeypot_log_failure($form_id, $type) {
  266. $account = \Drupal::currentUser();
  267. $uid = $account->id();
  268. // Log failed submissions.
  269. \Drupal::database()->insert('honeypot_user')
  270. ->fields([
  271. 'uid' => $uid,
  272. 'hostname' => Drupal::request()->getClientIp(),
  273. 'timestamp' => REQUEST_TIME,
  274. ])
  275. ->execute();
  276. // Allow other modules to react to honeypot rejections.
  277. // TODO - Only accepts two args.
  278. \Drupal::moduleHandler()->invokeAll('honeypot_reject', [$form_id, $uid, $type]);
  279. }