csrf_migration_helper.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. #!/usr/bin/env python
  2. # This script aims to help developers locate forms and view code that needs to
  3. # use the new CSRF protection in Django 1.2. It tries to find all the code that
  4. # may need the steps described in the CSRF documentation. It does not modify
  5. # any code directly, it merely attempts to locate it. Developers should be
  6. # aware of its limitations, described below.
  7. #
  8. # For each template that contains at least one POST form, the following info is printed:
  9. #
  10. # <Absolute path to template>
  11. # AKA: <Aliases (relative to template directory/directories that contain it)>
  12. # POST forms: <Number of POST forms>
  13. # With token: <Number of POST forms with the CSRF token already added>
  14. # Without token:
  15. # <File name and line number of form without token>
  16. #
  17. # Searching for:
  18. # <Template names that need to be searched for in view code
  19. # (includes templates that 'include' current template)>
  20. #
  21. # Found:
  22. # <File name and line number of any view code found>
  23. #
  24. # The format used allows this script to be used in Emacs grep mode:
  25. # M-x grep
  26. # Run grep (like this): /path/to/my/virtualenv/python /path/to/django/src/extras/csrf_migration_helper.py --settings=mysettings /path/to/my/srcs
  27. # Limitations
  28. # ===========
  29. #
  30. # - All templates must be stored on disk in '.html' or '.htm' files.
  31. # (extensions configurable below)
  32. #
  33. # - All Python code must be stored on disk in '.py' files. (extensions
  34. # configurable below)
  35. #
  36. # - All templates must be accessible from TEMPLATE_DIRS or from the 'templates/'
  37. # directory in apps specified in INSTALLED_APPS. Non-file based template
  38. # loaders are out of the picture, because there is no way to ask them to
  39. # return all templates.
  40. #
  41. # - It's impossible to programmatically determine which forms should and should
  42. # not have the token added. The developer must decide when to do this,
  43. # ensuring that the token is only added to internally targeted forms.
  44. #
  45. # - It's impossible to programmatically work out when a template is used. The
  46. # attempts to trace back to view functions are guesses, and could easily fail
  47. # in the following ways:
  48. #
  49. # * If the 'include' template tag is used with a variable
  50. # i.e. {% include tname %} where tname is a variable containing the actual
  51. # template name, rather than {% include "my_template.html" %}.
  52. #
  53. # * If the template name has been built up by view code instead of as a simple
  54. # string. For example, generic views and the admin both do this. (These
  55. # apps are both contrib and both use RequestContext already, as it happens).
  56. #
  57. # * If the 'ssl' tag (or any template tag other than 'include') is used to
  58. # include the template in another template.
  59. #
  60. # - All templates belonging to apps referenced in INSTALLED_APPS will be
  61. # searched, which may include third party apps or Django contrib. In some
  62. # cases, this will be a good thing, because even if the templates of these
  63. # apps have been fixed by someone else, your own view code may reference the
  64. # same template and may need to be updated.
  65. #
  66. # You may, however, wish to comment out some entries in INSTALLED_APPS or
  67. # TEMPLATE_DIRS before running this script.
  68. # Improvements to this script are welcome!
  69. # Configuration
  70. # =============
  71. TEMPLATE_EXTENSIONS = [
  72. ".html",
  73. ".htm",
  74. ]
  75. PYTHON_SOURCE_EXTENSIONS = [
  76. ".py",
  77. ]
  78. TEMPLATE_ENCODING = "UTF-8"
  79. PYTHON_ENCODING = "UTF-8"
  80. # Method
  81. # ======
  82. # Find templates:
  83. # - template dirs
  84. # - installed apps
  85. #
  86. # Search for POST forms
  87. # - Work out what the name of the template is, as it would appear in an
  88. # 'include' or get_template() call. This can be done by comparing template
  89. # filename to all template dirs. Some templates can have more than one
  90. # 'name' e.g. if a directory and one of its child directories are both in
  91. # TEMPLATE_DIRS. This is actually a common hack used for
  92. # overriding-and-extending admin templates.
  93. #
  94. # For each POST form,
  95. # - see if it already contains '{% csrf_token %}' immediately after <form>
  96. # - work back to the view function(s):
  97. # - First, see if the form is included in any other templates, then
  98. # recursively compile a list of affected templates.
  99. # - Find any code function that references that template. This is just a
  100. # brute force text search that can easily return false positives
  101. # and fail to find real instances.
  102. import os
  103. import sys
  104. import re
  105. from optparse import OptionParser
  106. USAGE = """
  107. This tool helps to locate forms that need CSRF tokens added and the
  108. corresponding view code. This processing is NOT fool proof, and you should read
  109. the help contained in the script itself. Also, this script may need configuring
  110. (by editing the script) before use.
  111. Usage:
  112. python csrf_migration_helper.py [--settings=path.to.your.settings] /path/to/python/code [more paths...]
  113. Paths can be specified as relative paths.
  114. With no arguments, this help is printed.
  115. """
  116. _POST_FORM_RE = \
  117. re.compile(r'(<form\W[^>]*\bmethod\s*=\s*(\'|"|)POST(\'|"|)\b[^>]*>)', re.IGNORECASE)
  118. _FORM_CLOSE_RE = re.compile(r'</form\s*>')
  119. _TOKEN_RE = re.compile('\{% csrf_token')
  120. def get_template_dirs():
  121. """
  122. Returns a set of all directories that contain project templates.
  123. """
  124. from django.conf import settings
  125. dirs = set()
  126. if ('django.template.loaders.filesystem.load_template_source' in settings.TEMPLATE_LOADERS
  127. or 'django.template.loaders.filesystem.Loader' in settings.TEMPLATE_LOADERS):
  128. dirs.update(map(unicode, settings.TEMPLATE_DIRS))
  129. if ('django.template.loaders.app_directories.load_template_source' in settings.TEMPLATE_LOADERS
  130. or 'django.template.loaders.app_directories.Loader' in settings.TEMPLATE_LOADERS):
  131. from django.template.loaders.app_directories import app_template_dirs
  132. dirs.update(app_template_dirs)
  133. return dirs
  134. def make_template_info(filename, root_dirs):
  135. """
  136. Creates a Template object for a filename, calculating the possible
  137. relative_filenames from the supplied filename and root template directories
  138. """
  139. return Template(filename,
  140. [filename[len(d)+1:] for d in root_dirs if filename.startswith(d)])
  141. class Template(object):
  142. def __init__(self, absolute_filename, relative_filenames):
  143. self.absolute_filename, self.relative_filenames = absolute_filename, relative_filenames
  144. def content(self):
  145. try:
  146. return self._content
  147. except AttributeError:
  148. with open(self.absolute_filename) as fd:
  149. try:
  150. content = fd.read().decode(TEMPLATE_ENCODING)
  151. except UnicodeDecodeError as e:
  152. message = '%s in %s' % (
  153. e[4], self.absolute_filename.encode('UTF-8', 'ignore'))
  154. raise UnicodeDecodeError(*(e.args[:4] + (message,)))
  155. self._content = content
  156. return content
  157. content = property(content)
  158. def post_form_info(self):
  159. """
  160. Get information about any POST forms in the template.
  161. Returns [(linenumber, csrf_token added)]
  162. """
  163. forms = {}
  164. form_line = 0
  165. for ln, line in enumerate(self.content.split("\n")):
  166. if not form_line and _POST_FORM_RE.search(line):
  167. # record the form with no CSRF token yet
  168. form_line = ln + 1
  169. forms[form_line] = False
  170. if form_line and _TOKEN_RE.search(line):
  171. # found the CSRF token
  172. forms[form_line] = True
  173. form_line = 0
  174. if form_line and _FORM_CLOSE_RE.search(line):
  175. # no token found by form closing tag
  176. form_line = 0
  177. return forms.items()
  178. def includes_template(self, t):
  179. """
  180. Returns true if this template includes template 't' (via {% include %})
  181. """
  182. for r in t.relative_filenames:
  183. if re.search(r'\{%\s*include\s+(\'|")' + re.escape(r) + r'(\1)\s*%\}', self.content):
  184. return True
  185. return False
  186. def related_templates(self):
  187. """
  188. Returns all templates that include this one, recursively. (starting
  189. with this one)
  190. """
  191. try:
  192. return self._related_templates
  193. except AttributeError:
  194. pass
  195. retval = set([self])
  196. for t in self.all_templates:
  197. if t.includes_template(self):
  198. # If two templates mutually include each other, directly or
  199. # indirectly, we have a problem here...
  200. retval = retval.union(t.related_templates())
  201. self._related_templates = retval
  202. return retval
  203. def __repr__(self):
  204. return repr(self.absolute_filename)
  205. def __eq__(self, other):
  206. return self.absolute_filename == other.absolute_filename
  207. def __hash__(self):
  208. return hash(self.absolute_filename)
  209. def get_templates(dirs):
  210. """
  211. Returns all files in dirs that have template extensions, as Template
  212. objects.
  213. """
  214. templates = set()
  215. for root in dirs:
  216. for (dirpath, dirnames, filenames) in os.walk(root):
  217. for f in filenames:
  218. if len([True for e in TEMPLATE_EXTENSIONS if f.endswith(e)]) > 0:
  219. t = make_template_info(os.path.join(dirpath, f), dirs)
  220. # templates need to be able to search others:
  221. t.all_templates = templates
  222. templates.add(t)
  223. return templates
  224. def get_python_code(paths):
  225. """
  226. Returns all Python code, as a list of tuples, each one being:
  227. (filename, list of lines)
  228. """
  229. retval = []
  230. for p in paths:
  231. if not os.path.isdir(p):
  232. raise Exception("'%s' is not a directory." % p)
  233. for (dirpath, dirnames, filenames) in os.walk(p):
  234. for f in filenames:
  235. if len([True for e in PYTHON_SOURCE_EXTENSIONS if f.endswith(e)]) > 0:
  236. fn = os.path.join(dirpath, f)
  237. with open(fn) as fd:
  238. content = [l.decode(PYTHON_ENCODING) for l in fd.readlines()]
  239. retval.append((fn, content))
  240. return retval
  241. def search_python_list(python_code, template_names):
  242. """
  243. Searches python code for a list of template names.
  244. Returns a list of tuples, each one being:
  245. (filename, line number)
  246. """
  247. retval = []
  248. for tn in template_names:
  249. retval.extend(search_python(python_code, tn))
  250. retval = list(set(retval))
  251. retval.sort()
  252. return retval
  253. def search_python(python_code, template_name):
  254. """
  255. Searches Python code for a template name.
  256. Returns a list of tuples, each one being:
  257. (filename, line number)
  258. """
  259. retval = []
  260. for fn, content in python_code:
  261. for ln, line in enumerate(content):
  262. if ((u'"%s"' % template_name) in line) or \
  263. ((u"'%s'" % template_name) in line):
  264. retval.append((fn, ln + 1))
  265. return retval
  266. def main(pythonpaths):
  267. template_dirs = get_template_dirs()
  268. templates = get_templates(template_dirs)
  269. python_code = get_python_code(pythonpaths)
  270. for t in templates:
  271. # Logic
  272. form_matches = t.post_form_info()
  273. num_post_forms = len(form_matches)
  274. form_lines_without_token = [ln for (ln, has_token) in form_matches if not has_token]
  275. if num_post_forms == 0:
  276. continue
  277. to_search = [rf for rt in t.related_templates() for rf in rt.relative_filenames]
  278. found = search_python_list(python_code, to_search)
  279. # Display:
  280. print(t.absolute_filename)
  281. for r in t.relative_filenames:
  282. print(" AKA %s" % r)
  283. print(" POST forms: %s" % num_post_forms)
  284. print(" With token: %s" % (num_post_forms - len(form_lines_without_token)))
  285. if form_lines_without_token:
  286. print(" Without token:")
  287. for ln in form_lines_without_token:
  288. print("%s:%d:" % (t.absolute_filename, ln))
  289. print('')
  290. print(" Searching for:")
  291. for r in to_search:
  292. print(" " + r)
  293. print('')
  294. print(" Found:")
  295. if len(found) == 0:
  296. print(" Nothing")
  297. else:
  298. for fn, ln in found:
  299. print("%s:%d:" % (fn, ln))
  300. print('')
  301. print("----")
  302. parser = OptionParser(usage=USAGE)
  303. parser.add_option("", "--settings", action="store", dest="settings", help="Dotted path to settings file")
  304. if __name__ == '__main__':
  305. options, args = parser.parse_args()
  306. if len(args) == 0:
  307. parser.print_help()
  308. sys.exit(1)
  309. settings = getattr(options, 'settings', None)
  310. if settings is None:
  311. if os.environ.get("DJANGO_SETTINGS_MODULE", None) is None:
  312. print("You need to set DJANGO_SETTINGS_MODULE or use the '--settings' parameter")
  313. sys.exit(1)
  314. else:
  315. os.environ["DJANGO_SETTINGS_MODULE"] = settings
  316. main(args)