├── LICENSE.rst ├── django_paranoia ├── tests │ ├── __init__.py │ ├── setup.py │ ├── fakesessions.py │ └── test.py ├── reporters │ ├── __init__.py │ ├── log.py │ └── cef_.py ├── __init__.py ├── middleware.py ├── signals.py ├── flags.py ├── decorators.py ├── configure.py ├── forms.py └── sessions.py ├── MANIFEST.in ├── requirements.txt ├── .travis.yml ├── README.rst ├── .gitignore ├── docs ├── topics │ ├── developers.rst │ ├── views.rst │ ├── forms.rst │ ├── sessions.rst │ └── overview.rst ├── index.rst ├── Makefile └── conf.py ├── tox.ini └── setup.py /LICENSE.rst: -------------------------------------------------------------------------------- 1 | BSD 2 | -------------------------------------------------------------------------------- /django_paranoia/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_paranoia/tests/setup.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_paranoia/reporters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.rst 2 | include README.rst 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.11.28 2 | nose 3 | mock 4 | -------------------------------------------------------------------------------- /django_paranoia/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.9.1' 2 | 3 | from configure import config 4 | config() 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | install: pip install tox --use-mirrors 6 | script: tox 7 | notifications: 8 | irc: "irc.mozilla.org#amo-bots" 9 | -------------------------------------------------------------------------------- /django_paranoia/reporters/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | log = logging.getLogger('paranoia') 4 | 5 | 6 | def report(signal, message=None, flag=None, sender=None, values=None, 7 | request_path=None, request_meta=None, **kwargs): 8 | log.warning(message) 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | A Django lib to expose likely intrusion attempts. Using some of the AppSensor 2 | detection points from OWASP: 3 | 4 | https://www.owasp.org/index.php/AppSensor_DetectionPoints 5 | 6 | Please docs on: 7 | 8 | https://django-paranoia.readthedocs.org/en/latest/ 9 | -------------------------------------------------------------------------------- /django_paranoia/tests/fakesessions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.backends.cache import SessionStore as Base 2 | 3 | 4 | class SessionStore(Base): 5 | """ 6 | Fake session store to swap out with the paranoid session store. 7 | """ 8 | def __init__(self, *a, **kw): 9 | pass 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | settings_local.py 2 | settings/local.py 3 | */settings/local.py 4 | *.py[co] 5 | *.sw[po] 6 | .coverage 7 | pip-log.txt 8 | docs/_build 9 | docs/_gh-pages 10 | build.py 11 | build 12 | .DS_Store 13 | .tox 14 | *-min.css 15 | *-all.css 16 | *-min.js 17 | *-all.js 18 | .noseids 19 | tmp/* 20 | *~ 21 | *.mo 22 | -------------------------------------------------------------------------------- /django_paranoia/middleware.py: -------------------------------------------------------------------------------- 1 | from .signals import finished 2 | 3 | 4 | class Middleware(object): 5 | 6 | def process_response(self, request, response): 7 | finished.send(sender=self, request_path=request.path_info, 8 | request_meta=request.META.copy()) 9 | return response 10 | -------------------------------------------------------------------------------- /django_paranoia/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # These are used internally to hook up to the request finished signal. 4 | finished = Signal(providing_args=['request_path', 'request_meta']) 5 | process = Signal(providing_args=['type', 'message', 'values', 'request_path', 6 | 'request_meta']) 7 | 8 | # This is the signal that you hook the warnings in the code up to. 9 | warning = Signal(providing_args=['type', 'message', 'values']) 10 | -------------------------------------------------------------------------------- /docs/topics/developers.rst: -------------------------------------------------------------------------------- 1 | Developers 2 | ---------- 3 | 4 | To run the tests against multiple environments, install `tox`_ using 5 | ``pip install tox``. You need at least Python 2.7 to run tox itself but you'll 6 | need 2.6 as well to run all environments. Run the tests like this:: 7 | 8 | tox 9 | 10 | To run the tests against a single environment:: 11 | 12 | tox -e py27-django15 13 | 14 | To debug something weird, run tests directly from the virtualenv like:: 15 | 16 | .tox/py27-django15/bin/nosetests 17 | 18 | 19 | .. _tox: http://tox.readthedocs.org/ 20 | -------------------------------------------------------------------------------- /docs/topics/views.rst: -------------------------------------------------------------------------------- 1 | .. _views: 2 | 3 | Views 4 | ----- 5 | 6 | Django Paranoia comes with the same decorators as Django: 7 | *require_http_methods*, *require_GET*, *require_POST*, *require_safe*. Use 8 | these the exact same way you would in Django. But instead import them from this 9 | library, for example:: 10 | 11 | from django_paranoia.decorators import require_POST 12 | 13 | @require_POST 14 | def something_sensitive(request, ...): 15 | ... 16 | 17 | This will return a HTTP 405 Not Allowed response as usual. But it will also 18 | send a warning that an attempt to access with something other than a POST was 19 | made. 20 | -------------------------------------------------------------------------------- /docs/topics/forms.rst: -------------------------------------------------------------------------------- 1 | .. _forms: 2 | 3 | Forms 4 | ----- 5 | 6 | Instead of using the builtin form libraries, use the *ParanoidForm* and 7 | *ParanoidModelForm*. If you do this, your forms will raise warnings if: 8 | 9 | * more keys are submitted than are required. This may not be useful if you've 10 | got multiple forms in one request. 11 | * less keys are submitted than are required. 12 | * a key or value contains an ascii character less than 32 (but \t\r\n are ok). 13 | 14 | For example:: 15 | 16 | from django_paranoia.forms import ParanoidForm 17 | 18 | class Scary(ParanoidForm): 19 | ... 20 | 21 | The log will contain the dodgy data. 22 | -------------------------------------------------------------------------------- /django_paranoia/flags.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext as _ 2 | 3 | EXTRA_FIELDS = 'extra-form-fields' # OWASP: RE6 4 | MISSING_FIELDS = 'missing-form-fields' # OWASP: RE5 5 | WRONG_METHOD = 'wrong-method' # OWASP: RE1, RE2, RE3 and RE4 6 | SESSION_CHANGED = 'session-changed' # OWASP: SE5 and SE6 7 | UNEXPECTED_CHARACTER = 'unexpected-character' # OWASP: RE8 8 | WRONG_METHOD = 'wrong-method' # OWASP: RE1, RE2, RE3 and RE4 9 | 10 | trans = { 11 | EXTRA_FIELDS: _('Attempt to process form with extra values'), 12 | MISSING_FIELDS: _('Attempt to process form with missing values'), 13 | SESSION_CHANGED: _('Session data changed'), 14 | UNEXPECTED_CHARACTER: _('Unexpected character'), 15 | WRONG_METHOD: _('Wrong HTTP method'), 16 | } 17 | -------------------------------------------------------------------------------- /docs/topics/sessions.rst: -------------------------------------------------------------------------------- 1 | .. _sessions: 2 | 3 | Sessions 4 | -------- 5 | 6 | Change your session backend to use Django Paranoid sessions, which is a wrapper 7 | around the cache session. At this time other session backends are *not* 8 | supported. 9 | 10 | To configure:: 11 | 12 | SESSION_ENGINE = 'django_paranoia.sessions' 13 | 14 | MIDDLEWARE_CLASSES = ( 15 | ... 16 | 'django_paranoia.sessions.ParanoidSessionMiddleware', 17 | ) 18 | 19 | 20 | When a session is created it will store the user agent and IP address of the 21 | session. If that changes at any time when the request is accessed, it will log. 22 | It will log each time the session is accessed while it's different. 23 | 24 | It's assumed that IP address is allowed to change during a session. The user 25 | agent should not. 26 | -------------------------------------------------------------------------------- /django_paranoia/reporters/cef_.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.conf import settings 4 | 5 | from cef import log_cef 6 | 7 | 8 | def report(signal, message=None, flag=None, sender=None, values=None, 9 | request_path=None, request_meta=None, **kwargs): 10 | g = functools.partial(getattr, settings) 11 | severity = g('CEF_DEFAULT_SEVERITY', 5) 12 | cef_kw = { 13 | 'msg': message, 14 | 'signature': request_path, 15 | 'config': { 16 | 'cef.product': g('CEF_PRODUCT', 'paranoia'), 17 | 'cef.vendor': g('CEF_VENDOR', 'Mozilla'), 18 | 'cef.version': g('CEF_VERSION', '0'), 19 | 'cef.device_version': g('CEF_DEVICE_VERSION', '0'), 20 | 'cef.file': g('CEF_FILE', 'syslog'), 21 | } 22 | } 23 | log_cef(message, severity, request_meta, **cef_kw) 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | py26-django14, 4 | py27-django14, 5 | py26-django15, 6 | py27-django15, 7 | docs, 8 | 9 | [testenv] 10 | setenv= 11 | DJANGO_SETTINGS_MODULE=testsettings 12 | commands= 13 | nosetests [] 14 | 15 | [testenv:docs] 16 | changedir=docs 17 | deps=Sphinx>=1.1 18 | commands=sphinx-build -b html -d _build/doctrees . _build/html 19 | 20 | [base] 21 | deps= 22 | mock 23 | nose 24 | 25 | [django14] 26 | deps= 27 | Django>=1.4,<1.5 28 | {[base]deps} 29 | 30 | [django15] 31 | deps= 32 | Django>=1.5,<1.6 33 | {[base]deps} 34 | 35 | [testenv:py26-django14] 36 | basepython=python2.6 37 | deps={[django14]deps} 38 | 39 | [testenv:py27-django14] 40 | basepython=python2.7 41 | deps={[django14]deps} 42 | 43 | [testenv:py26-django15] 44 | basepython=python2.6 45 | deps={[django15]deps} 46 | 47 | [testenv:py27-django15] 48 | basepython=python2.7 49 | deps={[django15]deps} 50 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django Paranoia 2 | ========================================== 3 | 4 | Django Paranoia implements some of the OWASP Detection Points as outlined in 5 | this document: 6 | 7 | https://www.owasp.org/index.php/AppSensor_DetectionPoints 8 | 9 | .. image:: http://www.agmweb.ca/files/keep-calm-and-tell-security.png 10 | :align: right 11 | 12 | Basically it's an attempt to find out when someone is trying to do something 13 | nasty to your site and log it. 14 | 15 | *Note*: this is does not prevent any actions that might cause damage to your 16 | site. All the usual prevention measures must be taken to ensure you are not 17 | susceptible to XSS, SQL injection and so on. 18 | 19 | Check out the source at: 20 | 21 | https://github.com/andymckay/django-paranoia 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | 26 | topics/overview.rst 27 | topics/sessions.rst 28 | topics/forms.rst 29 | topics/views.rst 30 | topics/developers.rst 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | -------------------------------------------------------------------------------- /docs/topics/overview.rst: -------------------------------------------------------------------------------- 1 | .. _overview: 2 | 3 | Overview 4 | -------- 5 | 6 | Django Paranoia is a library to try and detect nasty things going on your site. 7 | It does not prevent attacks, just logs them. 8 | 9 | Setup 10 | ===== 11 | 12 | To install:: 13 | 14 | pip install django-paranoia 15 | 16 | 17 | Add in the middleware:: 18 | 19 | MIDDLEWARE_CLASSES = ( 20 | ... 21 | 'django_paranoia.middleware.Middleware', 22 | ) 23 | 24 | Hook up the reporters:: 25 | 26 | DJANGO_PARANOIA_REPORTERS = [ 27 | 'django_paranoia.reporters.log', 28 | ] 29 | 30 | Using 31 | ===== 32 | 33 | The OWASP Detection points cover a large amount of the Django site, so each 34 | part is covered in seperately: 35 | 36 | * :ref:`sessions` 37 | * :ref:`forms` 38 | * :ref:`views` 39 | 40 | Output 41 | ====== 42 | 43 | When you configure Django Paranoia, you can pass through a list of reporters. 44 | Current choices are: 45 | 46 | * `django_paranoia.reporters.log`: send reports to a log file. 47 | * `django_paranoia.reporters.cef_`: send reports to a CEF log (mostly Mozilla 48 | specific) 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from setuptools import setup 5 | 6 | 7 | version = None 8 | with open(os.path.join(os.path.dirname(__file__), 9 | 'django_paranoia/__init__.py')) as f: 10 | for line in f.readlines(): 11 | m = re.search("__version__\s*=\s*(.*)", line) 12 | if m: 13 | version = m.group(1).strip()[1:-1] # quotes 14 | break 15 | assert version, 'Could not find __version__ in __init__.py' 16 | 17 | 18 | setup( 19 | name='django-paranoia', 20 | version=version, 21 | description='OWASP detection point reporting for Django', 22 | long_description=open('README.rst').read(), 23 | author='Andy McKay', 24 | author_email='andym@mozilla.com', 25 | license='BSD', 26 | install_requires=['Django'], 27 | packages=['django_paranoia', 28 | 'django_paranoia/reporters'], 29 | url='https://github.com/andymckay/django-paranoia', 30 | include_package_data=True, 31 | zip_safe=False, 32 | classifiers=[ 33 | 'Intended Audience :: Developers', 34 | 'Natural Language :: English', 35 | 'Operating System :: OS Independent', 36 | 'Framework :: Django' 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /django_paranoia/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.http import HttpResponseNotAllowed 4 | from django.utils.decorators import available_attrs 5 | from django.utils.log import getLogger 6 | 7 | from flags import WRONG_METHOD 8 | from signals import warning 9 | 10 | 11 | logger = getLogger('django.request') 12 | 13 | 14 | def require_http_methods(request_method_list): 15 | """Like the Django decorators, but they also raise a warning.""" 16 | def decorator(func): 17 | @wraps(func, assigned=available_attrs(func)) 18 | def inner(request, *args, **kwargs): 19 | if request.method not in request_method_list: 20 | # Raise our warning. 21 | warning.send(sender=require_http_methods, flag=WRONG_METHOD, 22 | message=u'%s not allowed' % request.method, 23 | values=[request_method_list]) 24 | logger.warning('Method Not Allowed (%s): %s', 25 | request.method, request.path, 26 | extra={ 27 | 'status_code': 405, 28 | 'request': request 29 | }) 30 | return HttpResponseNotAllowed(request_method_list) 31 | return func(request, *args, **kwargs) 32 | return inner 33 | return decorator 34 | 35 | 36 | require_GET = require_http_methods(['GET']) 37 | require_GET.__doc__ = ('Paranoid decorator to require that a ' 38 | 'view only accept the GET method.') 39 | 40 | require_POST = require_http_methods(['POST']) 41 | require_POST.__doc__ = ('Paranoid decorator to require that a ' 42 | 'view only accept the POST method.') 43 | 44 | require_safe = require_http_methods(['GET', 'HEAD']) 45 | require_safe.__doc__ = ('Paranoid decorator to require that a ' 46 | 'view only accept safe methods: GET and HEAD.') 47 | -------------------------------------------------------------------------------- /django_paranoia/configure.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from threading import local 3 | 4 | from django.conf import settings 5 | from django.utils.importlib import import_module 6 | 7 | from signals import finished, process, warning 8 | 9 | log = logging.getLogger('paranoia') 10 | 11 | _locals = local() 12 | 13 | 14 | def setup(): 15 | if not hasattr(_locals, 'signals'): 16 | _locals.signals = [] 17 | return True 18 | 19 | 20 | def add_signal(signal, **kw): 21 | setup() 22 | # Let's not pickle the sender. 23 | kw['sender'] = kw['sender'].__class__.__name__ 24 | _locals.signals.append(kw) 25 | 26 | 27 | def reset(**kw): 28 | setup() 29 | _locals.signals = [] 30 | 31 | 32 | def process_signals(signal, **kw): 33 | # We need to batch up all signals here and then send them when the 34 | # request is finished. This allows us to pass through the request 35 | # to the reporters, allowing more detailed logs. 36 | if setup(): 37 | return 38 | 39 | for data in _locals.signals: 40 | process.send(request_path=kw['request_path'], 41 | request_meta=kw['request_meta'], **data) 42 | 43 | 44 | def config(*args, **kw): 45 | try: 46 | reporters = getattr(settings, 'DJANGO_PARANOIA_REPORTERS', []) 47 | except ImportError: 48 | return 49 | 50 | for reporter in reporters: 51 | try: 52 | to = import_module(reporter).report 53 | except ImportError: 54 | log.error('Failed to register the reporter: %s' % reporter) 55 | continue 56 | 57 | # Each reporter gets connected to the process signal. 58 | process.connect(to, dispatch_uid='paranoia.reporter.%s' % reporter) 59 | 60 | # The warning signal is sent by forms, sessions etc when they 61 | # encounter something. 62 | warning.connect(add_signal, dispatch_uid='paranoia.warning') 63 | # The finished signal is sent by the middleware when the request is 64 | # finished. This then processes all the warning signals built up so far 65 | # on that request. 66 | finished.connect(process_signals, dispatch_uid='paranoia.finished') 67 | -------------------------------------------------------------------------------- /django_paranoia/forms.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.forms import Form, ModelForm 4 | 5 | from flags import EXTRA_FIELDS, MISSING_FIELDS, UNEXPECTED_CHARACTER, trans 6 | from signals import warning 7 | 8 | # Spot chars below 32, but allow, \t (9), \r (13) and \n (10). 9 | chars = range(0, 9) + range(11, 13) + range(14, 32) 10 | low_chars = re.compile('|'.join(map(chr, chars))) 11 | 12 | 13 | class Paranoid(object): 14 | 15 | def __init__(self, data=None, files=None, **kwargs): 16 | super(Paranoid, self).__init__(data=data, files=files, **kwargs) 17 | # We need to check for extra data when the form is created. 18 | data = data or {} 19 | extra = [k for k in data if k not in self.fields] 20 | if extra: 21 | self.warn(EXTRA_FIELDS, extra) 22 | 23 | for k, v in data.items(): 24 | # This assumes all binary data is going through FILES which 25 | # are not covered by django-paranoia. There really shouldn't 26 | # be any binary data here at all right? 27 | self.detect_low(k) 28 | self.detect_low(v) 29 | 30 | def detect_low(self, data): 31 | if not isinstance(data, basestring): 32 | return 33 | 34 | if low_chars.search(data): 35 | warning.send(sender=self.__class__, 36 | flag=UNEXPECTED_CHARACTER, 37 | message='Unexpected characters') 38 | # Not sure if we should send through the data in the message. 39 | # Would that be bad? 40 | 41 | def warn(self, flag, data): 42 | klass = self.__class__ 43 | msg = (u'%s: %s in %s' % (trans[flag], data, klass.__name__)) 44 | warning.send(sender=klass, flag=flag, message=msg, values=data) 45 | 46 | def is_valid(self): 47 | # We can't tell what is missing until the end. 48 | result = super(Paranoid, self).is_valid() 49 | missing = [] 50 | if not result: 51 | for k, errors in self.errors.items(): 52 | field = self.fields.get(k) 53 | for error in errors: 54 | if field and error == field.error_messages['required']: 55 | missing.append(k) 56 | else: 57 | missing.append(k) 58 | 59 | if missing: 60 | self.warn(MISSING_FIELDS, missing) 61 | 62 | return result 63 | 64 | 65 | class ParanoidForm(Paranoid, Form): 66 | pass 67 | 68 | 69 | class ParanoidModelForm(Paranoid, ModelForm): 70 | pass 71 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoParanoia.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoParanoia.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /django_paranoia/sessions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.middleware import SessionMiddleware 3 | from django.contrib.sessions.backends.cache import SessionStore as Base 4 | from django.core.cache import cache 5 | from django.utils.importlib import import_module 6 | 7 | from signals import warning 8 | from flags import trans, SESSION_CHANGED 9 | 10 | KEY_PREFIX = 'django_paranoid.sessions:' 11 | DATA_PREFIX = '%sdata' % KEY_PREFIX 12 | META_KEYS = ['REMOTE_ADDR', 'HTTP_USER_AGENT'] 13 | 14 | 15 | class SessionStore(Base): 16 | 17 | def __init__(self, session_key=None, request_meta=None): 18 | self._cache = cache 19 | self.request_meta = request_meta 20 | super(SessionStore, self).__init__(session_key) 21 | 22 | @property 23 | def cache_key(self): 24 | return KEY_PREFIX + self._get_or_create_session_key() 25 | 26 | def load(self): 27 | return super(SessionStore, self).load() 28 | 29 | def prepare_data(self, must_create=False): 30 | """ 31 | Prepare session data for later inspection. 32 | 33 | This makes a copy of sensitive data so that tampering can be detected. 34 | """ 35 | data = self._get_session(no_load=must_create) 36 | data.setdefault(DATA_PREFIX, {}) 37 | if self.request_meta: 38 | for k in META_KEYS: 39 | dest = 'meta:%s' % k 40 | if dest not in data[DATA_PREFIX]: 41 | data[DATA_PREFIX][dest] = self.request_meta.get(k, '') 42 | 43 | def save(self, must_create=False): 44 | self.prepare_data(must_create=must_create) 45 | return super(SessionStore, self).save(must_create=must_create) 46 | 47 | def request_data(self): 48 | return self._get_session(no_load=False)[DATA_PREFIX] 49 | 50 | def check_request_data(self, request): 51 | """ 52 | Inspect session data and warn if it was tampered with. 53 | """ 54 | data = self._get_session() 55 | stash = data.get(DATA_PREFIX, None) 56 | if stash is None and (self.modified or 57 | settings.SESSION_SAVE_EVERY_REQUEST): 58 | # If a subclass overrides save(), this should catch it. 59 | # We only check this when we know save() was called. 60 | # During automated testing, save() is not typically called. 61 | raise ValueError('Cannot check data because it was not stashed. ' 62 | 'This typically happens in save()') 63 | if not stash: 64 | stash = {} 65 | 66 | for k in META_KEYS: 67 | saved = stash.get('meta:%s' % k, '') 68 | current = request.META.get(k, '') 69 | if saved and saved != current: 70 | values = [saved, current] 71 | msg = (u'%s: %s' % (trans[SESSION_CHANGED], values)) 72 | warning.send(sender=self, flag=SESSION_CHANGED, 73 | message=msg, values=values) 74 | 75 | 76 | class ParanoidSessionMiddleware(SessionMiddleware): 77 | 78 | def process_request(self, request): 79 | engine = import_module(settings.SESSION_ENGINE) 80 | session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None) 81 | request.session = engine.SessionStore(request_meta=request.META.copy(), 82 | session_key=session_key) 83 | if not isinstance(request.session, SessionStore): 84 | raise ValueError('SESSION_ENGINE session must be an instance of ' 85 | 'django_paranoia.sessions.SessionStore') 86 | 87 | def process_response(self, request, response): 88 | response = (super(ParanoidSessionMiddleware, self) 89 | .process_response(request, response)) 90 | request.session.check_request_data(request) 91 | return response 92 | -------------------------------------------------------------------------------- /django_paranoia/tests/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django import forms 4 | from django.conf import settings 5 | minimal = { 6 | 'DATABASES': { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': 'mydatabase' 10 | } 11 | }, 12 | 'SESSION_ENGINE': 'django_paranoia.sessions' 13 | } 14 | 15 | if not settings.configured: 16 | settings.configure(**minimal) 17 | 18 | from django.db import models 19 | from django.http import HttpResponse, HttpResponseNotAllowed 20 | from django.test import TestCase 21 | from django.test.client import RequestFactory 22 | 23 | import mock 24 | from nose.tools import eq_, ok_ 25 | from django_paranoia.configure import config 26 | 27 | from django_paranoia.decorators import require_http_methods 28 | from django_paranoia.forms import ParanoidForm, ParanoidModelForm 29 | from django_paranoia.middleware import Middleware 30 | from django_paranoia.sessions import SessionStore, ParanoidSessionMiddleware 31 | from django_paranoia.signals import finished, warning 32 | 33 | 34 | class SimpleForm(ParanoidForm): 35 | yes = forms.BooleanField() 36 | 37 | 38 | class SimpleModel(models.Model): 39 | yes = models.BooleanField() 40 | 41 | 42 | class RequiredForm(SimpleForm): 43 | req = forms.CharField(required=True) 44 | 45 | 46 | class SimpleModelForm(ParanoidModelForm): 47 | 48 | class Meta: 49 | model = SimpleModel 50 | 51 | 52 | class ResultCase(TestCase): 53 | 54 | def result(self, *args, **kwargs): 55 | self.called.append((args, kwargs)) 56 | 57 | def setUp(self): 58 | self.called = [] 59 | self.warning = warning.connect(self.result) 60 | self.finished = finished.connect(self.result) 61 | 62 | 63 | class TestForms(ResultCase): 64 | 65 | def test_fine(self): 66 | SimpleForm() 67 | SimpleForm({'yes': True}) 68 | assert not self.called 69 | 70 | def test_extra(self): 71 | SimpleForm({'no': 'wat'}) 72 | assert self.called 73 | 74 | def test_multiple(self): 75 | SimpleForm({'no': 'wat', 'yes': True, 'sql': 'aargh'}) 76 | res = self.called[0][1] 77 | eq_(set(res['values']), set(['sql', 'no'])) 78 | 79 | def test_model_fine(self): 80 | SimpleModelForm() 81 | SimpleModelForm({'yes': True}) 82 | assert not self.called 83 | 84 | def test_model_extra(self): 85 | SimpleModelForm({'no': 'wat'}) 86 | assert self.called 87 | 88 | def test_required(self): 89 | form = RequiredForm({}) 90 | assert not form.is_valid() 91 | res = self.called[0][1] 92 | eq_(set(res['values']), set(['req', 'yes'])) 93 | 94 | def test_dodgy_value(self): 95 | SimpleForm({'yes': chr(6)}) 96 | assert self.called 97 | 98 | def test_dodgy_key(self): 99 | SimpleForm({chr(6): 'yes'}) 100 | # Once because chr(6) is an extra char, once because of the key. 101 | eq_(len(self.called), 2) 102 | 103 | def test_dodgy_allowed(self): 104 | for x in ['\t', '\r', '\n']: 105 | self.called = [] 106 | SimpleForm({'yes': x}) 107 | assert not self.called 108 | 109 | def test_dody_unicode(self): 110 | SimpleForm({'yes': u'Һејдәр Әлијев'}) 111 | assert not self.called 112 | 113 | 114 | @mock.patch('django_paranoia.configure.warning') 115 | class TestSetup(TestCase): 116 | log = 'django_paranoia.reporters.log' 117 | 118 | def test_setup_fails(self, warning): 119 | config([self.log, 'foo']) 120 | eq_(len(warning.connect.call_args_list), 1) 121 | eq_(warning.connect.call_args[1]['dispatch_uid'], 122 | 'paranoia.warning') 123 | 124 | 125 | class TestLog(TestCase): 126 | # Not sure what to test here. 127 | pass 128 | 129 | 130 | class TestSession(ResultCase): 131 | 132 | def request(self, **kwargs): 133 | req = RequestFactory().get('/') 134 | req.META.update(**kwargs) 135 | return req 136 | 137 | def get(self, request=None, uid=None): 138 | request = request if request else self.request() 139 | return SessionStore(request_meta=request.META.copy(), 140 | session_key=uid) 141 | 142 | def test_basic(self): 143 | self.session = self.get() 144 | self.session['foo'] = 'bar' 145 | self.session.save() 146 | eq_(self.get(uid=self.session.session_key).load()['foo'], 'bar') 147 | 148 | def test_request(self): 149 | session = self.get() 150 | session.save() 151 | res = self.get(uid=session.session_key) 152 | eq_(set(res.request_data().keys()), 153 | set(['meta:REMOTE_ADDR', 'meta:HTTP_USER_AGENT'])) 154 | assert not self.called 155 | 156 | def test_request_changed(self): 157 | ses = self.get() 158 | ses.save() 159 | req = self.request(REMOTE_ADDR='192.168.1.1') 160 | ses.check_request_data(request=req) 161 | assert self.called 162 | 163 | def test_user_agent_changed(self): 164 | ses = self.get(self.request(HTTP_USER_AGENT='foo')) 165 | ses.save() 166 | req = self.request(HTTP_USER_AGENT='bar') 167 | ses.check_request_data(request=req) 168 | assert self.called 169 | 170 | 171 | @require_http_methods(['POST']) 172 | def some(request): 173 | return True 174 | 175 | 176 | class TestDecorators(ResultCase): 177 | 178 | def test_some_ok(self): 179 | assert some(RequestFactory().post('/')) 180 | assert not self.called 181 | 182 | def test_some_not(self): 183 | assert isinstance(some(RequestFactory().get('/')), 184 | HttpResponseNotAllowed) 185 | assert self.called 186 | 187 | 188 | class TestMiddleware(ResultCase): 189 | 190 | def test_middle(self): 191 | Middleware().process_response(RequestFactory().get('/'), 192 | HttpResponse()) 193 | args = self.called[0][1] 194 | ok_('request_meta' in args) 195 | eq_(args['request_path'], '/') 196 | 197 | def test_paranoia(self): 198 | middle = ParanoidSessionMiddleware() 199 | request = RequestFactory().get('/') 200 | middle.process_request(request) 201 | request.session['foo'] = 'bar' 202 | request.session.save() 203 | 204 | # Change the address. 205 | request.META['REMOTE_ADDR'] = 'foo' 206 | middle.process_response(request, HttpResponse()) 207 | assert self.called 208 | 209 | @mock.patch.object(settings, 'SESSION_ENGINE', 210 | 'django_paranoia.tests.fakesessions') 211 | def test_paranoid_session_must_be_correct_instance(self): 212 | middle = ParanoidSessionMiddleware() 213 | request = RequestFactory().get('/') 214 | with self.assertRaises(ValueError): 215 | middle.process_request(request) 216 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Paranoia documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Dec 7 16:07:47 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.append(os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = [] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = u'Django Paranoia' 41 | copyright = u'2012, Andy McKay' 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = '0.1' 49 | # The full version, including alpha/beta/rc tags. 50 | release = '0.1' 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | #language = None 55 | 56 | # There are two options for replacing |today|: either, you set today to some 57 | # non-false value, then it is used: 58 | #today = '' 59 | # Else, today_fmt is used as the format for a strftime call. 60 | #today_fmt = '%B %d, %Y' 61 | 62 | # List of documents that shouldn't be included in the build. 63 | #unused_docs = [] 64 | 65 | # List of directories, relative to source directory, that shouldn't be searched 66 | # for source files. 67 | exclude_trees = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. Major themes that come with 93 | # Sphinx are currently 'default' and 'sphinxdoc'. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'DjangoParanoiadoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index', 'DjangoParanoi.tex', u'Django Paranoi Documentation', 176 | u'Andy McKay', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | --------------------------------------------------------------------------------