link_token.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. # SPDX-License-Identifier: AGPL-3.0-or-later
  2. """
  3. Method ``link_token``
  4. ---------------------
  5. The ``link_token`` method evaluates a request as :py:obj:`suspicious
  6. <is_suspicious>` if the URL ``/client<token>.css`` is not requested by the
  7. client. By adding a random component (the token) in the URL, a bot can not send
  8. a ping by request a static URL.
  9. .. note::
  10. This method requires a redis DB and needs a HTTP X-Forwarded-For_ header.
  11. To get in use of this method a flask URL route needs to be added:
  12. .. code:: python
  13. @app.route('/client<token>.css', methods=['GET', 'POST'])
  14. def client_token(token=None):
  15. link_token.ping(request, token)
  16. return Response('', mimetype='text/css')
  17. And in the HTML template from flask a stylesheet link is needed (the value of
  18. ``link_token`` comes from :py:obj:`get_token`):
  19. .. code:: html
  20. <link rel="stylesheet"
  21. href="{{ url_for('client_token', token=link_token) }}"
  22. type="text/css" >
  23. .. _X-Forwarded-For:
  24. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
  25. """
  26. from __future__ import annotations
  27. from ipaddress import (
  28. IPv4Network,
  29. IPv6Network,
  30. ip_address,
  31. )
  32. import string
  33. import random
  34. import flask
  35. from searx import logger
  36. from searx import redisdb
  37. from searx.redislib import secret_hash
  38. from ._helpers import (
  39. get_network,
  40. get_real_ip,
  41. )
  42. TOKEN_LIVE_TIME = 600
  43. """Lifetime (sec) of limiter's CSS token."""
  44. PING_LIVE_TIME = 3600
  45. """Lifetime (sec) of the ping-key from a client (request)"""
  46. PING_KEY = 'SearXNG_limiter.ping'
  47. """Prefix of all ping-keys generated by :py:obj:`get_ping_key`"""
  48. TOKEN_KEY = 'SearXNG_limiter.token'
  49. """Key for which the current token is stored in the DB"""
  50. logger = logger.getChild('botdetection.link_token')
  51. def is_suspicious(network: IPv4Network | IPv6Network, request: flask.Request, renew: bool = False):
  52. """Checks whether a valid ping is exists for this (client) network, if not
  53. this request is rated as *suspicious*. If a valid ping exists and argument
  54. ``renew`` is ``True`` the expire time of this ping is reset to
  55. :py:obj:`PING_LIVE_TIME`.
  56. """
  57. redis_client = redisdb.client()
  58. if not redis_client:
  59. return False
  60. ping_key = get_ping_key(network, request)
  61. if not redis_client.get(ping_key):
  62. logger.info("missing ping (IP: %s) / request: %s", network.compressed, ping_key)
  63. return True
  64. if renew:
  65. redis_client.set(ping_key, 1, ex=PING_LIVE_TIME)
  66. logger.debug("found ping for (client) network %s -> %s", network.compressed, ping_key)
  67. return False
  68. def ping(request: flask.Request, token: str):
  69. """This function is called by a request to URL ``/client<token>.css``. If
  70. ``token`` is valid a :py:obj:`PING_KEY` for the client is stored in the DB.
  71. The expire time of this ping-key is :py:obj:`PING_LIVE_TIME`.
  72. """
  73. from . import redis_client, cfg # pylint: disable=import-outside-toplevel, cyclic-import
  74. if not redis_client:
  75. return
  76. if not token_is_valid(token):
  77. return
  78. real_ip = ip_address(get_real_ip(request))
  79. network = get_network(real_ip, cfg)
  80. ping_key = get_ping_key(network, request)
  81. logger.debug("store ping_key for (client) network %s (IP %s) -> %s", network.compressed, real_ip, ping_key)
  82. redis_client.set(ping_key, 1, ex=PING_LIVE_TIME)
  83. def get_ping_key(network: IPv4Network | IPv6Network, request: flask.Request) -> str:
  84. """Generates a hashed key that fits (more or less) to a *WEB-browser
  85. session* in a network."""
  86. return (
  87. PING_KEY
  88. + "["
  89. + secret_hash(
  90. network.compressed + request.headers.get('Accept-Language', '') + request.headers.get('User-Agent', '')
  91. )
  92. + "]"
  93. )
  94. def token_is_valid(token) -> bool:
  95. valid = token == get_token()
  96. logger.debug("token is valid --> %s", valid)
  97. return valid
  98. def get_token() -> str:
  99. """Returns current token. If there is no currently active token a new token
  100. is generated randomly and stored in the redis DB.
  101. - :py:obj:`TOKEN_LIVE_TIME`
  102. - :py:obj:`TOKEN_KEY`
  103. """
  104. redis_client = redisdb.client()
  105. if not redis_client:
  106. # This function is also called when limiter is inactive / no redis DB
  107. # (see render function in webapp.py)
  108. return '12345678'
  109. token = redis_client.get(TOKEN_KEY)
  110. if token:
  111. token = token.decode('UTF-8')
  112. else:
  113. token = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
  114. redis_client.set(TOKEN_KEY, token, ex=TOKEN_LIVE_TIME)
  115. return token