diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1714ae0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +# Defines the coding style for different editors and IDEs. +# http://editorconfig.org + +# top-most EditorConfig file +root = true + +# Rules for source code. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +# Rules for Python code. +[*.py] +indent_size = 4 + +# Rules for markdown documents. +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +# Rules for makefile +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d9296f6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +### Summary +- Write a quick summary of what this PR is for + + +##### Related Links +- Paste link to ticket or any other related sites here + +##### Ready for QA Checklist +- [ ] Code Review +- [ ] Dev QA +- [ ] Rebase and Squash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4005830 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - '*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools poetry tox-gh-actions + poetry install + - name: Build wheels and source tarball + run: poetry build + - name: publish to PyPi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + skip_existing: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..0a59d99 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: Validate + +on: + push: + branches: + - develop + - master + - main + - 'release/**' + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools poetry + poetry install + - name: Linting + run: | + make lint + - name: Security + run: make bandit + - name: Testing + run: make tests diff --git a/.gitignore b/.gitignore index 8b7f3a5..54f234e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,58 @@ -# python compiled files -*.pyc -*.pyo -*.egg-info - -# back up files from VIM / Emacs -*~ +*.py[co] *.swp +*.bak -# ignore setup.py build dir -build/ +# docs +_build -# ignore sphinx built documentation -_build/ +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg -# ignore tox files -.tox/ +# Installer logs +pip-log.txt -# ignore coverage files +# Unit test / coverage reports .coverage +.tox + +.idea + +.DS_Store + +#Translations +*.mo + +#Mr Developer +.mr.developer.cfg + +# Configuration +sdelint.cnf + +#generated data +usecases/output.csv + +# Test files +info.log htmlcov/ -# development tools -.vscode/ +#ides +.idea +.vscode + +#custom cert bundle +my_root_certs.crt + +# symbolic links +.flake8 + +conf/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5a073f0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: +- repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.1.13 + hooks: + - id: forbid-crlf + - id: remove-crlf + - id: forbid-tabs + exclude_types: [csv] + - id: remove-tabs + exclude_types: [csv] + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + args: [--unsafe] + +- repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + +- repo: https://github.com/ambv/black + rev: 22.3.0 + hooks: + - id: black + language_version: python3.12 + +- repo: https://github.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + additional_dependencies: [flake8-typing-imports==1.10.0] + exclude: ^tests + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 806f9b5..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python -matrix: - include: - - python: 3.6 - env: DJANGO=1.11 TOXENV=py36-django111 - - python: 3.6 - env: DJANGO=2.2 TOXENV=py36-django22 - - python: 3.6 - env: DJANGO=3.0 TOXENV=py36-django30 - - python: 3.6 - env: DJANGO=3.0 TOXENV=docs - -install: - - pip install tox - - pip install coveralls -script: - - tox -after_success: - - coveralls diff --git a/LICENSE.txt b/LICENSE.md similarity index 100% rename from LICENSE.txt rename to LICENSE.md diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index bb3ec5f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include README.md diff --git a/Makefile b/Makefile index 6ef5ac5..da3ad5f 100644 --- a/Makefile +++ b/Makefile @@ -87,3 +87,14 @@ doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." + +lint: + poetry run black . + poetry run isort . + poetry run flake8 . --extend-ignore=D,E501,W601 --extend-exclude=docs/,**/migrations/*,tests/ --statistics --count + +bandit: + poetry run bandit -c pyproject.toml -r . + +test: + poetry run python ./runtests.py diff --git a/README.md b/README.md index 81b673a..dfc6528 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,8 @@ Automatically generated documentation of `django-security` is available on Read # Requirements -* Python >= 3.6 -* Django >= 1.11 - -For Django < 1.8 use django-security==0.9.4. For Django < 1.11 use django-security==0.11.3. - -Note: For versions prior to 0.10.0, `datetime` objects were being added to the session and required Django's PickleSerializer for (de)serializing. This has now been changed so that the strings of these `datetime`s are being stored instead. If you are still using PickleSerializer for this reason, we suggest switching to Django's default JSONSerializer (default since Django 1.6) for better security. - +* Python >=3.12 +* Django ~4.2 # Installation @@ -31,7 +26,7 @@ If you prefer the latest development version, install from git clone https://github.com/sdelements/django-security.git cd django-security - sudo python setup.py install + poetry install Adding to Django application's `settings.py` file: @@ -41,28 +36,14 @@ Adding to Django application's `settings.py` file: ... ) -Pre-Django 1.10, middleware modules can be added to `MIDDLEWARE_CLASSES` list in settings file: - - MIDDLEWARE_CLASSES = ( - ... - 'security.middleware.DoNotTrackMiddleware', - 'security.middleware.ContentNoSniff', - 'security.middleware.XssProtectMiddleware', - 'security.middleware.XFrameOptionsMiddleware', - ) - -After Django 1.10, middleware modules can be added to `MIDDLEWARE` list in settings file: +Middleware modules can be added to `MIDDLEWARE` list in settings file: MIDDLEWARE = ( ... - 'security.middleware.DoNotTrackMiddleware', - 'security.middleware.ContentNoSniff', - 'security.middleware.XssProtectMiddleware', - 'security.middleware.XFrameOptionsMiddleware', + 'security.middleware.LoginRequiredMiddleware', + ... ) - - Unlike the modules listed above, some other modules **require** configuration settings, fully described in [django-security documentation](http://django-security.readthedocs.org/en/latest/). Brief description is provided below. @@ -73,76 +54,60 @@ Provided middleware modules will modify web application's output and input and i or minimum configuration. - - - - - - - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + + - + + +
Middleware -Description -Configuration -
ClearSiteDataMiddleware -Send Clear-Site-Data header in HTTP response for any page that has been whitelisted. Recommended. -Required. - -
ContentNoSniff -DEPRECATED: Will be removed in future releases, consider django.middleware.security.SecurityMiddleware via SECURE_CONTENT_TYPE_NOSNIFF setting.
Disable possibly insecure autodetection of MIME types in browsers. Recommended. -
None. - -
ContentSecurityPolicyMiddleware -Send Content Security Policy (CSP) header in HTTP response. Recommended, requires careful tuning. -Required. - -
DoNotTrackMiddleware -Read user browser's DoNotTrack preference and pass it to application. Recommended, requires implementation in views and templates. -None. - -
LoginRequiredMiddleware -Requires a user to be authenticated to view any page on the site that hasn't been white listed. -Required. +MiddlewareDescriptionConfiguration
MandatoryPasswordChangeMiddleware -Redirects any request from an authenticated user to the password change form if that user's password has expired. -Required. +ClearSiteDataMiddlewareSend Clear-Site-Data header in HTTP response for any page that has been whitelisted. Recommended.Required.
NoConfidentialCachingMiddleware -Adds No-Cache and No-Store headers to confidential pages. -Required. +ContentSecurityPolicyMiddlewareSend Content Security Policy (CSP) header in HTTP response. Recommended, requires careful tuning.Required.
P3PPolicyMiddleware -DEPRECATED: Will be removed in future releases.
Adds the HTTP header attribute specifying compact P3P policy. -
Required. +LoginRequiredMiddlewareRequires a user to be authenticated to view any page on the site that hasn't been white listed.Required.
ReferrerPolicyMiddleware -Specify when the browser will set a `Referer` header. -Optional. +MandatoryPasswordChangeMiddlewareRedirects any request from an authenticated user to the password change form if that user's password has expired.Required.
SessionExpiryPolicyMiddleware -Expire sessions on browser close, and on expiry times stored in the cookie itself. -Required. +NoConfidentialCachingMiddlewareAdds No-Cache and No-Store headers to confidential pages.Required.
StrictTransportSecurityMiddleware -DEPRECATED: Will be removed in future releases, consider django.middleware.security.SecurityMiddleware via SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS and SECURE_HSTS_PRELOAD settings.
Enforce SSL/TLS connection and disable plaintext fall-back. Recommended for SSL/TLS sites. -
Optional. +ReferrerPolicyMiddlewareSpecify when the browser will set a `Referer` header.Optional.
XFrameOptionsMiddleware -Disable framing of the website, mitigating Clickjacking attacks. Recommended. -Optional. +SessionExpiryPolicyMiddlewareExpire sessions on browser close, and on expiry times stored in the cookie itself.Required.
XssProtectMiddleware -DEPRECATED: Will be removed in future releases, consider django.middleware.security.SecurityMiddleware via SECURE_BROWSER_XSS_FILTER setting.
Enforce browser's Cross Site Scripting protection. Recommended. -
None. +ProfilingMiddlewareA simple middleware to capture useful profiling information in Django.Optional.
diff --git a/conf.py b/docs/conf.py similarity index 78% rename from conf.py rename to docs/conf.py index fe10b06..bd66d82 100644 --- a/conf.py +++ b/docs/conf.py @@ -11,112 +11,115 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.append(os.path.dirname(__file__)) -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testing.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test.settings") # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', ] +extensions = [ + "sphinx.ext.autodoc", +] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -source_encoding = 'utf-8' +source_encoding = "utf-8" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'django-security' -copyright = u'2013, SD Elements' +project = "django-security" +copyright = "2013, SD Elements" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '1.0' +version = "1.0" # The full version, including alpha/beta/rc tags. -release = '1.0' +release = "1.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = Python +# language = Python # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. -#unused_docs = [] +# unused_docs = [] # List of directories, relative to source directory, that shouldn't be searched # for source files. -exclude_patterns = ['_build/*', '.tox/*'] +exclude_patterns = ["_build/*", ".tox/*"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -125,71 +128,76 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_use_modindex = True +# html_use_modindex = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = '' +# html_file_suffix = '' # Output file base name for HTML help builder. -htmlhelp_basename = 'django-securitydoc' +htmlhelp_basename = "django-securitydoc" # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-security.tex', u'django-security Documentation', - u'Pawel Krawczyk', 'manual'), + ( + "index", + "django-security.tex", + "django-security Documentation", + "Pawel Krawczyk", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_use_modindex = True +# latex_use_modindex = True diff --git a/index.rst b/docs/index.rst similarity index 100% rename from index.rst rename to docs/index.rst diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..be001ae --- /dev/null +++ b/poetry.lock @@ -0,0 +1,704 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "bandit" +version = "1.7.8" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bandit-1.7.8-py3-none-any.whl", hash = "sha256:509f7af645bc0cd8fd4587abc1a038fc795636671ee8204d502b933aee44f381"}, + {file = "bandit-1.7.8.tar.gz", hash = "sha256:36de50f720856ab24a24dbaa5fee2c66050ed97c1477e0a1159deab1775eab6b"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + +[[package]] +name = "black" +version = "24.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "django" +version = "4.2.13" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.13-py3-none-any.whl", hash = "sha256:a17fcba2aad3fc7d46fdb23215095dbbd64e6174bf4589171e732b18b07e426a"}, + {file = "Django-4.2.13.tar.gz", hash = "sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-upgrade" +version = "1.18.0" +description = "Automatically upgrade your Django project code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_upgrade-1.18.0-py3-none-any.whl", hash = "sha256:bae6a466bb9dd63dd7e23b665499b84499b2661348f06b371b88d2e609aa0df9"}, + {file = "django_upgrade-1.18.0.tar.gz", hash = "sha256:ae2a2de13e7804773201aef6af2245fa5d503b0a7c88b85b12cf1fdb84197065"}, +] + +[package.dependencies] +tokenize-rt = ">=4.1" + +[[package]] +name = "filelock" +version = "3.14.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, + {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "flake8" +version = "7.0.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, + {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.2.0,<3.3.0" + +[[package]] +name = "flake8-bandit" +version = "4.1.1" +description = "Automated security testing with bandit and flake8." +optional = false +python-versions = ">=3.6" +files = [ + {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, + {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, +] + +[package.dependencies] +bandit = ">=1.7.3" +flake8 = ">=5.0.0" + +[[package]] +name = "flake8-bugbear" +version = "24.4.26" +description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8_bugbear-24.4.26-py3-none-any.whl", hash = "sha256:cb430dd86bc821d79ccc0b030789a9c87a47a369667f12ba06e80f11305e8258"}, + {file = "flake8_bugbear-24.4.26.tar.gz", hash = "sha256:ff8d4ba5719019ebf98e754624c30c05cef0dadcf18a65d91c7567300e52a130"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +flake8 = ">=6.0.0" + +[package.extras] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] + +[[package]] +name = "flake8-docstrings" +version = "1.7.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, + {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, +] + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-polyfill" +version = "1.0.2" +description = "Polyfill package for Flake8 plugins" +optional = false +python-versions = "*" +files = [ + {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, + {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, +] + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "identify" +version = "2.5.36" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbr" +version = "6.0.0" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, + {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pre-commit" +version = "3.7.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3)"] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "south" +version = "1.0.2" +description = "South: Migrations for Django" +optional = false +python-versions = "*" +files = [ + {file = "South-1.0.2.tar.gz", hash = "sha256:d360bd31898f9df59f6faa786551065bba45b35e7ee3c39b381b4fbfef7392f4"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.0" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, + {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "stevedore" +version = "5.2.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.2.0-py3-none-any.whl", hash = "sha256:1c15d95766ca0569cad14cb6272d4d31dae66b011a929d7c18219c176ea1b5c9"}, + {file = "stevedore-5.2.0.tar.gz", hash = "sha256:46b93ca40e1114cea93d738a6c1e365396981bb6bb78c27045b7587c9473544d"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "tokenize-rt" +version = "5.2.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, + {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "ua-parser" +version = "0.18.0" +description = "Python port of Browserscope's user agent parser" +optional = false +python-versions = "*" +files = [ + {file = "ua-parser-0.18.0.tar.gz", hash = "sha256:db51f1b59bfaa82ed9e2a1d99a54d3e4153dddf99ac1435d51828165422e624e"}, + {file = "ua_parser-0.18.0-py2.py3-none-any.whl", hash = "sha256:9d94ac3a80bcb0166823956a779186c746b50ea4c9fd9bf30fdb758553c38950"}, +] + +[[package]] +name = "virtualenv" +version = "20.26.2" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, + {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "~3.12" +content-hash = "035ae5f00aa880116e169b83eda8bb722a1675fee3eeab783fe558f64bef5e43" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3f6db1c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[tool.poetry] +name = "django-security" +version = "1.0.0" +homepage = "https://github.com/sdelements/django-security" +description = "Models, views, middlewares and forms to facilitate security hardening of Django applications." +authors = ["Security Compass "] +license = "BSD-3-Clause" +readme = "README.md" +# See https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 5 - Production/Stable', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries :: Python Modules', + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: BSD License', + + # Supported Languages + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.12', + 'Framework :: Django', +] +packages = [ + { include = "security" }, + { include = "tests", format = "sdist" }, +] +exclude = [ + "security/**/tests", + "tests" +] + +[tool.poetry.dependencies] +python = "~3.12" +django = "~4.2" +python-dateutil = "2.9.0.post0" +south = "1.0.2" +ua_parser = "0.18.0" + +[tool.poetry.dev-dependencies] +pre-commit = "3.7.1" +# lint +black = "24.4.2" +flake8 = "7.0.0" +flake8-bandit = "4.1.1" +flake8-bugbear = "24.4.26" +flake8-docstrings = "1.7.0" +flake8-polyfill = "1.0.2" +isort = "5.13.2" +# security +bandit = "1.7.8" +# test +django-upgrade = "1.18.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.bandit] +exclude_dirs = [ + './tests/', +] diff --git a/requirements b/requirements deleted file mode 100644 index 92cd4c5..0000000 --- a/requirements +++ /dev/null @@ -1,3 +0,0 @@ -django>=1.11 -ua_parser>=0.7.1 -python-dateutil>=2.8.1 diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..cdf26b6 --- /dev/null +++ b/runtests.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +from django.core.management import execute_from_command_line + + +def runtests(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") + argv = sys.argv[:1] + ["test"] + sys.argv[1:] + execute_from_command_line(argv) + + +if __name__ == "__main__": + runtests() diff --git a/security/admin.py b/security/admin.py index 2315d11..4ebb677 100644 --- a/security/admin.py +++ b/security/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from security.models import PasswordExpiry, CspReport +from security.models import CspReport, PasswordExpiry admin.site.register(PasswordExpiry) admin.site.register(CspReport) diff --git a/security/auth.py b/security/auth.py index 98ac89d..0b42bf1 100644 --- a/security/auth.py +++ b/security/auth.py @@ -11,10 +11,12 @@ def min_length(n): because django.core.validators.MinLengthValidator doesn't take a message argument. """ + def validate(password): if len(password) < n: message = _("It must contain at least %d characters.") % n raise ValidationError(message) + return validate @@ -23,17 +25,17 @@ def validate(password): lowercase = RegexValidator( r"[a-z]", _("It must contain at least one lowercase letter."), - '', + "", ) uppercase = RegexValidator( r"[A-Z]", _("It must contain at least one uppercase letter."), - '', + "", ) digit = RegexValidator( r"[0-9]", _("It must contain at least one decimal digit."), - '', + "", ) diff --git a/security/auth_throttling/__init__.py b/security/auth_throttling/__init__.py index 8d79939..8a8869c 100644 --- a/security/auth_throttling/__init__.py +++ b/security/auth_throttling/__init__.py @@ -2,8 +2,8 @@ import hashlib import logging -from math import ceil import time # Monkeypatched by the tests. +from math import ceil from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.forms import AuthenticationForm @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.shortcuts import render -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_protect from security.middleware import BaseMiddleware @@ -55,7 +55,7 @@ def _key(counter_type, counter_name): counter_type, counter_name, ) - return hashlib.sha1(key.encode('ascii')).hexdigest() + return hashlib.sha256(key.encode("ascii")).hexdigest() def reset_counters(**counters): @@ -185,8 +185,7 @@ class Middleware(BaseMiddleware): REQUIRED_SETTINGS = ("AUTHENTICATION_THROTTLING",) def load_setting(self, setting, value): - """ - """ + """ """ value = value or {} try: @@ -239,15 +238,15 @@ def process_request(self, request): current_site = get_current_site(request) # Template-compatible with 'django.contrib.auth.views.login'. return csrf_protect( - lambda request: render( + lambda request, template_name=template_name, form=form, redirect_url=redirect_url, current_site=current_site: render( request, template_name, { "form": form, self.redirect_field_name: redirect_url, "site": current_site, - "site_name": current_site.name - } + "site_name": current_site.name, + }, ), )(request) @@ -258,6 +257,10 @@ def process_response(self, request, response): __all__ = [ - delay_message, increment_counters, reset_counters, attempt_count, - default_delay_function, throttling_delay + delay_message, + increment_counters, + reset_counters, + attempt_count, + default_delay_function, + throttling_delay, ] diff --git a/security/forms.py b/security/forms.py index 9f159c9..368afd7 100644 --- a/security/forms.py +++ b/security/forms.py @@ -1,12 +1,11 @@ # Copyright (c) 2011, SD Elements. See LICENSE.txt for details. -from django import forms import django.contrib.auth.forms - -from django.utils.translation import ugettext_lazy as _ +from django import forms +from django.utils.translation import gettext_lazy as _ from . import auth -from .password_expiry import password_is_expired, never_expire_password +from .password_expiry import never_expire_password, password_is_expired class PasswordChangeForm(django.contrib.auth.forms.PasswordChangeForm): diff --git a/security/middleware.py b/security/middleware.py index d441a0a..bf28218 100644 --- a/security/middleware.py +++ b/security/middleware.py @@ -1,32 +1,32 @@ # Copyright (c) 2011, SD Elements. See LICENSE.txt for details. -import dateutil.parser +import cProfile import importlib import json import logging +import pstats import warnings +from io import StringIO from re import compile +import dateutil.parser import django.conf +import django.views.static +import sqlparse from django.contrib.auth import logout -from django.core.exceptions import ImproperlyConfigured -from django.urls import reverse, resolve -from django.http import HttpResponseRedirect, HttpResponse +from django.core.exceptions import ImproperlyConfigured, MiddlewareNotUsed +from django.db import connection +from django.http import HttpResponse, HttpResponseRedirect from django.test.signals import setting_changed +from django.urls import resolve, reverse from django.utils import timezone from django.utils.deprecation import MiddlewareMixin -import django.views.static - from ua_parser import user_agent_parser - logger = logging.getLogger(__name__) -DJANGO_SECURITY_MIDDLEWARE_URL = ( - "https://docs.djangoproject.com/en/1.11/ref" - "/middleware/#django.middleware.security.SecurityMiddleware") DJANGO_CLICKJACKING_MIDDLEWARE_URL = ( - "https://docs.djangoproject.com/en/1.11/" - "ref/clickjacking/") + "https://docs.djangoproject.com/en/4.2/ref/clickjacking/" +) class CustomLogoutMixin(object): @@ -36,20 +36,25 @@ class CustomLogoutMixin(object): """ class Messages(object): - NOT_A_MODULE_PATH = (u"Invalid CUSTOM_LOGOUT_MODULE setting '{0}'. " - u"Expected module path to a function") - FAILED_TO_LOAD = (u"Invalid CUSTOM_LOGOUT_MODULE setting. " - u"Failed to load module '{0}': {1}") - MISSING_FUNCTION = (u"Invalid CUSTOM_LOGOUT_MODULE setting. " - u"Could not find function '{0}' in module '{1}'") + NOT_A_MODULE_PATH = ( + "Invalid CUSTOM_LOGOUT_MODULE setting '{0}'. " + "Expected module path to a function" + ) + FAILED_TO_LOAD = ( + "Invalid CUSTOM_LOGOUT_MODULE setting. " "Failed to load module '{0}': {1}" + ) + MISSING_FUNCTION = ( + "Invalid CUSTOM_LOGOUT_MODULE setting. " + "Could not find function '{0}' in module '{1}'" + ) def perform_logout(self, request): - if not getattr(self, 'CUSTOM_LOGOUT_MODULE', None): + if not getattr(self, "CUSTOM_LOGOUT_MODULE", None): logout(request) return try: - module_path, func_name = self.CUSTOM_LOGOUT_MODULE.rsplit('.', 1) + module_path, func_name = self.CUSTOM_LOGOUT_MODULE.rsplit(".", 1) except ValueError: err = self.Messages.NOT_A_MODULE_PATH raise Exception(err.format(self.CUSTOM_LOGOUT_MODULE)) @@ -113,9 +118,7 @@ def __init__(self, get_response=None): ) for key in self.OPTIONAL_SETTINGS: - self.load_setting( - key, getattr(django.conf.settings, key, None) - ) + self.load_setting(key, getattr(django.conf.settings, key, None)) setting_changed.connect(self._on_setting_changed) @@ -169,8 +172,8 @@ def process_request(self, request): Read DNT header from browser request and create request attribute """ request.dnt = None - if 'HTTP_DNT' in request.META: - request.dnt = request.META['HTTP_DNT'] == '1' + if "HTTP_DNT" in request.META: + request.dnt = request.META["HTTP_DNT"] == "1" # returns None in normal conditions def process_response(self, request, response): @@ -178,84 +181,8 @@ def process_response(self, request, response): Echo DNT header in response per section 8.4 of draft-mayer-do-not- track-00 """ - if 'HTTP_DNT' in request.META: - response['DNT'] = request.META['HTTP_DNT'] - return response - - -class XssProtectMiddleware(BaseMiddleware): - """ - DEPRECATED: Will be removed in future releases. Consider - django.middleware.security.SecurityMiddleware as a replacement for this via - SECURE_BROWSER_XSS_FILTER setting. - - Sends X-XSS-Protection HTTP header that controls Cross-Site Scripting - filter on MSIE. Use XSS_PROTECT option in settings file with the following - values: - - ``sanitize`` enable XSS filter that tries to sanitize requests instead - of blocking (*default*) - - ``on`` enable full XSS filter blocking XSS requests (may `leak - document.referrer `_) - - ``off`` completely disable XSS filter - - **Note:** As of 1.8, Django's `SECURE_BROWSER_XSS_FILTER - `_ - controls the X-XSS-Protection header. - - Reference: - - - `Controlling the XSS Filter - `_ - """ - - OPTIONAL_SETTINGS = ("XSS_PROTECT",) - - OPTIONS = { - 'on': '1; mode=block', - 'off': '0', - 'sanitize': '1', - } - - DEFAULT = 'sanitize' - - def __init__(self, get_response=None): - super().__init__(get_response) - warnings.warn(( - 'DEPRECATED: The middleware "{name}" will no longer be ' - 'supported in future releases of this library. Refer to {url} for ' - 'an alternative approach with regards to the settings: {settings}' - ).format( - name=self.__class__.__name__, - url=DJANGO_SECURITY_MIDDLEWARE_URL, - settings="SECURE_BROWSER_XSS_FILTER")) - - def load_setting(self, setting, value): - if not value: - self.option = self.DEFAULT - return - - value = value.lower() - - if value in self.OPTIONS.keys(): - self.option = value - return - - raise ImproperlyConfigured( - self.__class__.__name__ + " invalid option for XSS_PROTECT." - ) - - def process_response(self, request, response): - """ - Add X-XSS-Protection to the response header. - """ - header = self.OPTIONS[self.option] - response['X-XSS-Protection'] = header + if "HTTP_DNT" in request.META: + response["DNT"] = request.META["HTTP_DNT"] return response @@ -274,22 +201,18 @@ class ClearSiteDataMiddleware(BaseMiddleware): `_ """ - REQUIRED_SETTINGS = ('CLEAR_SITE_DATA_URL_WHITELIST',) - OPTIONAL_SETTINGS = ('CLEAR_SITE_DATA_DIRECTIVES') + REQUIRED_SETTINGS = ("CLEAR_SITE_DATA_URL_WHITELIST",) + OPTIONAL_SETTINGS = "CLEAR_SITE_DATA_DIRECTIVES" - DEFAULT_DIRECTIVES = ['cookies', 'storage'] - ALLOWED_DIRECTIVES = ( - 'cache', 'cookies', 'storage', 'executionContexts', '*' - ) + DEFAULT_DIRECTIVES = ["cookies", "storage"] + ALLOWED_DIRECTIVES = ("cache", "cookies", "storage", "executionContexts", "*") def load_setting(self, setting, value): - if setting == 'CLEAR_SITE_DATA_URL_WHITELIST': + if setting == "CLEAR_SITE_DATA_URL_WHITELIST": self.clear_site_urls = value directives = getattr( - django.conf.settings, - 'CLEAR_SITE_DATA_DIRECTIVES', - self.DEFAULT_DIRECTIVES + django.conf.settings, "CLEAR_SITE_DATA_DIRECTIVES", self.DEFAULT_DIRECTIVES ) directives = [ @@ -298,9 +221,8 @@ def load_setting(self, setting, value): if directive.strip() in self.ALLOWED_DIRECTIVES ] - self.clear_site_directives = ', '.join( - '"{0}"'.format(directive) - for directive in directives + self.clear_site_directives = ", ".join( + '"{0}"'.format(directive) for directive in directives ) def process_response(self, request, response): @@ -310,50 +232,8 @@ def process_response(self, request, response): """ if request.path in self.clear_site_urls: - response['Clear-Site-Data'] = self.clear_site_directives - - return response - - -class ContentNoSniff(MiddlewareMixin): - """ - DEPRECATED: Will be removed in future releases. Consider - django.middleware.security.SecurityMiddleware as a replacement for this via - SECURE_CONTENT_TYPE_NOSNIFF setting. - - Sends X-Content-Options HTTP header to disable autodetection of MIME type - of files returned by the server in Microsoft Internet Explorer. - Specifically if this flag is enabled, MSIE will not load external CSS and - JavaScript files unless server correctly declares their MIME type. This - mitigates attacks where web page would for example load a script that was - disguised as an user- supplied image. - - **Note:** As of 1.8, Django's `SECURE_CONTENT_TYPE_NOSNIFF - `_ - controls the X-Content-Type-Options header. - - Reference: - - - `MIME-Handling Change: X-Content-Type-Options: nosniff - `_ - """ - - def __init__(self, get_response=None): - super().__init__(get_response) - warnings.warn(( - 'DEPRECATED: The middleware "{name}" will no longer be ' - 'supported in future releases of this library. Refer to {url} for ' - 'an alternative approach with regards to the settings: {settings}' - ).format( - name=self.__class__.__name__, - url=DJANGO_SECURITY_MIDDLEWARE_URL, - settings="SECURE_CONTENT_TYPE_NOSNIFF")) + response["Clear-Site-Data"] = self.clear_site_directives - def process_response(self, request, response): - """ - Add ``X-Content-Options: nosniff`` to the response header. - """ - response['X-Content-Options'] = 'nosniff' return response @@ -402,7 +282,7 @@ def process_view(self, request, view, *args, **kwargs): # because the reason the URL is exempt may be because a special URL # config is in use (i.e. during a test) that doesn't have URL_NAME. - path = request.path_info.lstrip('/') + path = request.path_info.lstrip("/") if any(m.match(path) for m in self.exempt_urls): return @@ -418,6 +298,7 @@ def process_view(self, request, view, *args, **kwargs): return from .password_expiry import password_is_expired + if password_is_expired(request.user): return HttpResponseRedirect(password_change_url) @@ -460,12 +341,14 @@ def load_setting(self, setting, value): value = value or {} self.whitelist = value.get("WHITELIST_ON", False) if self.whitelist: - self.whitelist_url_regexes = \ - [compile(x) for x in value['WHITELIST_REGEXES']] + self.whitelist_url_regexes = [ + compile(x) for x in value["WHITELIST_REGEXES"] + ] self.blacklist = value.get("BLACKLIST_ON", False) if self.blacklist: - self.blacklist_url_regexes = \ - [compile(x) for x in value['BLACKLIST_REGEXES']] + self.blacklist_url_regexes = [ + compile(x) for x in value["BLACKLIST_REGEXES"] + ] def process_response(self, request, response): """ @@ -473,7 +356,7 @@ def process_response(self, request, response): whitelist non-confidential pages and treat all others as non- confidential, or specifically blacklist pages as confidential """ - path = request.path.lstrip('/') + path = request.path.lstrip("/") if self.whitelist: if not any(re.match(path) for re in self.whitelist_url_regexes): self._remove_response_caching(response) @@ -487,10 +370,9 @@ def _remove_response_caching(self, response): """ Overwrites specific headers to make the HTTP response confidential. """ - response['Cache-control'] = \ - 'no-cache, no-store, max-age=0, must-revalidate' - response['Pragma'] = "no-cache" - response['Expires'] = -1 + response["Cache-control"] = "no-cache, no-store, max-age=0, must-revalidate" + response["Pragma"] = "no-cache" + response["Expires"] = -1 # http://tools.ietf.org/html/draft-ietf-websec-x-frame-options-01 @@ -528,29 +410,32 @@ class XFrameOptionsMiddleware(BaseMiddleware): `_ """ - OPTIONAL_SETTINGS = ('X_FRAME_OPTIONS', 'X_FRAME_OPTIONS_EXCLUDE_URLS') + OPTIONAL_SETTINGS = ("X_FRAME_OPTIONS", "X_FRAME_OPTIONS_EXCLUDE_URLS") - DEFAULT = 'deny' + DEFAULT = "deny" def __init__(self, get_response=None): super().__init__(get_response) - warnings.warn(( - 'An official middleware "{name}" is supported by Django. ' - 'Refer to {url} to see if its approach fits the use case.' - ).format( - name="XFrameOptionsMiddleware", - url=DJANGO_CLICKJACKING_MIDDLEWARE_URL)) + warnings.warn( + ( + 'An official middleware "{name}" is supported by Django. ' + "Refer to {url} to see if its approach fits the use case." + ).format( + name="XFrameOptionsMiddleware", url=DJANGO_CLICKJACKING_MIDDLEWARE_URL + ), + stacklevel=2, + ) def load_setting(self, setting, value): - if setting == 'X_FRAME_OPTIONS': + if setting == "X_FRAME_OPTIONS": if not value: self.option = XFrameOptionsMiddleware.DEFAULT return value = value.lower() - options = ['sameorigin', 'deny'] + options = ["sameorigin", "deny"] - if value in options or value.startswith('allow-from:'): + if value in options or value.startswith("allow-from:"): self.option = value return @@ -558,7 +443,7 @@ def load_setting(self, setting, value): self.__class__.__name__ + " invalid option for X_FRAME_OPTIONS" ) - elif setting == 'X_FRAME_OPTIONS_EXCLUDE_URLS': + elif setting == "X_FRAME_OPTIONS_EXCLUDE_URLS": if not value: self.exclude_urls = [] return @@ -567,8 +452,10 @@ def load_setting(self, setting, value): self.exclude_urls = [compile(url) for url in value] except TypeError: raise ImproperlyConfigured( - "{0} invalid option for X_FRAME_OPTIONS_EXCLUDE_URLS" - .format(self.__class__.__name__)) + "{0} invalid option for X_FRAME_OPTIONS_EXCLUDE_URLS".format( + self.__class__.__name__ + ) + ) def process_response(self, request, response): """ @@ -578,7 +465,7 @@ def process_response(self, request, response): if url.match(request.path): break else: - response['X-Frame-Options'] = self.option + response["X-Frame-Options"] = self.option return response @@ -680,43 +567,44 @@ class ContentSecurityPolicyMiddleware(MiddlewareMixin): - `HTML5.1 - Sandboxing `_ """ + # these types accept CSP locations as arguments _CSP_LOC_TYPES = [ - 'default-src', - 'connect-src', - 'child-src', - 'font-src', - 'form-action', - 'frame-ancestors', - 'frame-src', - 'img-src', - 'media-src', - 'object-src', - 'script-src', - 'style-src', - 'plugin-types', - 'worker-src' + "default-src", + "connect-src", + "child-src", + "font-src", + "form-action", + "frame-ancestors", + "frame-src", + "img-src", + "media-src", + "object-src", + "script-src", + "style-src", + "plugin-types", + "worker-src", ] # arguments to location types - _CSP_LOCATIONS = ['self', 'none', 'unsafe-eval', 'unsafe-inline'] + _CSP_LOCATIONS = ["self", "none", "unsafe-eval", "unsafe-inline"] # sandbox allowed arguments # http://www.w3.org/html/wg/drafts/html/master/single-page.html#sandboxing # https://www.w3.org/TR/CSP2/ _CSP_SANDBOX_ARGS = [ - '', - 'allow-forms', - 'allow-pointer-lock', - 'allow-popups', - 'allow-same-origin', - 'allow-scripts', - 'allow-top-navigation', + "", + "allow-forms", + "allow-pointer-lock", + "allow-popups", + "allow-same-origin", + "allow-scripts", + "allow-top-navigation", ] # reflected-xss allowed arguments # http://www.w3.org/TR/CSP11/#directive-reflected-xss - _CSP_XSS_ARGS = ['allow', 'block', 'filter'] + _CSP_XSS_ARGS = ["allow", "block", "filter"] # referrer allowed arguments # http://www.w3.org/TR/CSP11/#directive-referrer @@ -734,24 +622,24 @@ class ContentSecurityPolicyMiddleware(MiddlewareMixin): def _csp_loc_builder(self, key, value): if not isinstance(value, (list, tuple)): - logger.warn('Arguments to %s must be given as list or tuple', key) + logger.warn("Arguments to %s must be given as list or tuple", key) raise django.core.exceptions.MiddlewareNotUsed csp_loc_string = "{0}".format(key) for loc in value: if loc in self._CSP_LOCATIONS: csp_loc_string += " '{0}'".format(loc) # quoted - elif loc == '*': - csp_loc_string += ' *' # not quoted + elif loc == "*": + csp_loc_string += " *" # not quoted else: # XXX: check for valid hostname or URL - csp_loc_string += " {0}".format(loc) # not quoted + csp_loc_string += " {0}".format(loc) # not quoted return csp_loc_string def _csp_sandbox_builder(self, key, value): if not isinstance(value, (list, tuple)): - logger.warn('Arguments to %s must be given as list or tuple', key) + logger.warn("Arguments to %s must be given as list or tuple", key) raise django.core.exceptions.MiddlewareNotUsed csp_sandbox_string = "{0}".format(key) @@ -759,25 +647,25 @@ def _csp_sandbox_builder(self, key, value): if opt in self._CSP_SANDBOX_ARGS: csp_sandbox_string += " {0}".format(opt) else: - logger.warn('Invalid CSP sandbox argument %s', opt) + logger.warn("Invalid CSP sandbox argument %s", opt) raise django.core.exceptions.MiddlewareNotUsed return csp_sandbox_string def _csp_report_uri_builder(self, key, value): # XXX: add valid URL check - return '{0} {1}'.format(key, value) + return "{0} {1}".format(key, value) def _csp_referrer_builder(self, key, value): if value not in self._CSP_REF_ARGS: - logger.warning('Invalid CSP %s value %s', key, value) + logger.warning("Invalid CSP %s value %s", key, value) raise django.core.exceptions.MiddlewareNotUsed return "{0} {1}".format(key, value) def _csp_reflected_xss_builder(self, key, value): if value not in self._CSP_XSS_ARGS: - logger.warning('Invalid CSP %s value %s', key, value) + logger.warning("Invalid CSP %s value %s", key, value) raise django.core.exceptions.MiddlewareNotUsed return "{0} {1}".format(key, value) @@ -790,48 +678,48 @@ def _csp_builder(self, csp_dict): if key in self._CSP_LOC_TYPES: csp_components.append(self._csp_loc_builder(key, value)) - elif key == 'sandbox': + elif key == "sandbox": csp_components.append(self._csp_sandbox_builder(key, value)) - elif key == 'report-uri': + elif key == "report-uri": csp_components.append(self._csp_report_uri_builder(key, value)) - elif key == 'referrer': + elif key == "referrer": csp_components.append(self._csp_referrer_builder(key, value)) - elif key == 'reflected-xss': + elif key == "reflected-xss": csp_components.append( self._csp_reflected_xss_builder(key, value), ) else: - logger.warning('Invalid CSP type %s', key) + logger.warning("Invalid CSP type %s", key) raise django.core.exceptions.MiddlewareNotUsed - return '; '.join(csp_components) + return "; ".join(csp_components) def __init__(self, get_response=None): # sanity checks self.get_response = get_response - conf_csp_mode = getattr(django.conf.settings, 'CSP_MODE', None) - self._csp_mode = conf_csp_mode or 'enforce' - csp_string = getattr(django.conf.settings, 'CSP_STRING', None) - csp_dict = getattr(django.conf.settings, 'CSP_DICT', None) - csp_report_string = getattr(django.conf.settings, 'CSP_REPORT_STRING', - None) - csp_report_dict = getattr(django.conf.settings, 'CSP_REPORT_DICT', - None) - - set_csp_str = self._csp_mode in ['enforce', 'enforce-and-report-only'] - set_csp_report_str = self._csp_mode in ['report-only', - 'enforce-and-report-only'] + conf_csp_mode = getattr(django.conf.settings, "CSP_MODE", None) + self._csp_mode = conf_csp_mode or "enforce" + csp_string = getattr(django.conf.settings, "CSP_STRING", None) + csp_dict = getattr(django.conf.settings, "CSP_DICT", None) + csp_report_string = getattr(django.conf.settings, "CSP_REPORT_STRING", None) + csp_report_dict = getattr(django.conf.settings, "CSP_REPORT_DICT", None) + + set_csp_str = self._csp_mode in ["enforce", "enforce-and-report-only"] + set_csp_report_str = self._csp_mode in [ + "report-only", + "enforce-and-report-only", + ] if not (set_csp_str or set_csp_report_str): logger.error( 'Invalid CSP_MODE %s, "enforce", "report-only" ' 'or "enforce-and-report-only" allowed', - self._csp_mode + self._csp_mode, ) raise django.core.exceptions.MiddlewareNotUsed @@ -842,20 +730,21 @@ def __init__(self, get_response=None): self._set_csp_report_str(csp_report_dict, csp_report_string) def _set_csp_str(self, csp_dict, csp_string): - err_msg = 'Middleware requires either CSP_STRING or CSP_DICT setting' + err_msg = "Middleware requires either CSP_STRING or CSP_DICT setting" if not (csp_dict or csp_string): - logger.error('%s, none found', err_msg) + logger.error("%s, none found", err_msg) raise django.core.exceptions.MiddlewareNotUsed - self._csp_string = self._choose_csp_str(csp_dict, csp_string, - err_msg + ', not both') + self._csp_string = self._choose_csp_str( + csp_dict, csp_string, err_msg + ", not both" + ) def _set_csp_report_str(self, csp_report_dict, csp_report_string): report_err_msg = ( - 'Middleware requires either CSP_REPORT_STRING, ' - 'CSP_REPORT_DICT setting, or neither. If neither, ' - 'middleware requires CSP_STRING or CSP_DICT, ' - 'but not both.' + "Middleware requires either CSP_REPORT_STRING, " + "CSP_REPORT_DICT setting, or neither. If neither, " + "middleware requires CSP_STRING or CSP_DICT, " + "but not both." ) # Default to the regular CSP string if report string not configured @@ -863,9 +752,7 @@ def _set_csp_report_str(self, csp_report_dict, csp_report_string): self._csp_report_string = self._csp_string else: self._csp_report_string = self._choose_csp_str( - csp_report_dict, - csp_report_string, - report_err_msg + csp_report_dict, csp_report_string, report_err_msg ) def _choose_csp_str(self, csp_dict, csp_str, err_msg): @@ -883,7 +770,7 @@ def _choose_csp_str(self, csp_dict, csp_str, err_msg): Log an error message if both are provided. """ if csp_dict and csp_str: - logger.error('%s', err_msg) + logger.error("%s", err_msg) raise django.core.exceptions.MiddlewareNotUsed if csp_dict: @@ -891,7 +778,7 @@ def _choose_csp_str(self, csp_dict, csp_str, err_msg): elif csp_str: return csp_str else: - return '' + return "" def process_response(self, request, response): """ @@ -900,149 +787,29 @@ def process_response(self, request, response): """ # choose headers based enforcement mode is_ie = False - if 'HTTP_USER_AGENT' in request.META: - parsed_ua = user_agent_parser.ParseUserAgent(request.META['HTTP_USER_AGENT']) - is_ie = parsed_ua['family'] == 'IE' + if "HTTP_USER_AGENT" in request.META: + parsed_ua = user_agent_parser.ParseUserAgent( + request.META["HTTP_USER_AGENT"] + ) + is_ie = parsed_ua["family"] == "IE" - csp_header = 'Content-Security-Policy' + csp_header = "Content-Security-Policy" if is_ie: - csp_header = 'X-Content-Security-Policy' - report_only_header = 'Content-Security-Policy-Report-Only' + csp_header = "X-Content-Security-Policy" + report_only_header = "Content-Security-Policy-Report-Only" # actually add appropriate headers - if self._csp_mode == 'enforce': + if self._csp_mode == "enforce": response[csp_header] = self._csp_string - elif self._csp_mode == 'report-only': + elif self._csp_mode == "report-only": response[report_only_header] = self._csp_report_string - elif self._csp_mode == 'enforce-and-report-only': + elif self._csp_mode == "enforce-and-report-only": response[csp_header] = self._csp_string response[report_only_header] = self._csp_report_string return response -class StrictTransportSecurityMiddleware(MiddlewareMixin): - """ - DEPRECATED: Will be removed in future releases. Consider - django.middleware.security.SecurityMiddleware as a replacement for this via - SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS and - SECURE_HSTS_PRELOAD settings. - - Adds Strict-Transport-Security header to HTTP - response that enforces SSL connections on compliant browsers. Two - parameters can be set in settings file, otherwise reasonable - defaults will be used: - - - ``STS_MAX_AGE`` time in seconds to preserve host's STS - policy (default: 1 year) - - ``STS_INCLUDE_SUBDOMAINS`` True if subdomains should be covered by - the policy as well (default: True) - - ``STS_PRELOAD`` add ``preload`` flag to the STS header - so that your website can be added to preloaded websites list - - **Note:** As of 1.8, Django's `SECURE_HSTS_SECONDS - `_ - controls the HTTP Strict Transport Security header. - - Reference: - - - `HTTP Strict Transport Security (HSTS) - `_ - - `Preloaded HSTS sites `_ - """ - def __init__(self, get_response=None): - warnings.warn(( - 'DEPRECATED: The middleware "{name}" will no longer be ' - 'supported in future releases of this library. Refer to {url} for ' - 'an alternative approach with regards to the settings: {settings}' - ).format( - name=self.__class__.__name__, - url=DJANGO_SECURITY_MIDDLEWARE_URL, - settings=", ".join([ - "SECURE_HSTS_SECONDS", - "SECURE_HSTS_INCLUDE_SUBDOMAINS", - "SECURE_HSTS_PRELOAD", - ]))) - - self.get_response = get_response - - try: - self.max_age = django.conf.settings.STS_MAX_AGE - except AttributeError: - self.max_age = 3600 * 24 * 365 # one year - - try: - self.subdomains = django.conf.settings.STS_INCLUDE_SUBDOMAINS - except AttributeError: - self.subdomains = True - - try: - self.preload = django.conf.settings.STS_PRELOAD - except AttributeError: - self.preload = True - - self.value = 'max-age={0}'.format(self.max_age) - - if self.subdomains: - self.value += ' ; includeSubDomains' - - if self.preload: - self.value += ' ; preload' - - def process_response(self, request, response): - """ - Add Strict-Transport-Security header. - """ - response['Strict-Transport-Security'] = self.value - return response - - -class P3PPolicyMiddleware(BaseMiddleware): - """ - DEPRECATED: Will be removed in future releases. - - Adds the HTTP header attribute specifying compact P3P policy - defined in P3P_COMPACT_POLICY setting and location of full - policy defined in P3P_POLICY_URL. If the latter is not defined, - a default value is used (/w3c/p3p.xml). The policy file needs to - be created by website owner. - - **Note:** P3P work stopped in 2002 and the only popular - browser with **limited** P3P support is MSIE. - - Reference: - - - `The Platform for Privacy Preferences 1.0 (P3P1.0) Specification - The - Compact Policies `_ - """ - - REQUIRED_SETTINGS = ("P3P_COMPACT_POLICY",) - OPTIONAL_SETTINGS = ("P3P_POLICY_URL",) - - def __init__(self, get_response=None): - super().__init__(get_response) - warnings.warn(( - 'DEPRECATED: The middleware "{name}" will no longer be ' - 'supported in future releases of this library.' - ).format(name=self.__class__.__name__)) - - def load_setting(self, setting, value): - if setting == 'P3P_COMPACT_POLICY': - self.policy = value - elif setting == 'P3P_POLICY_URL': - self.policy_url = value or '/w3c/p3p.xml' - - def process_response(self, request, response): - """ - Add P3P policy to the response header. - """ - response['P3P'] = 'policyref="{0}" CP="{1}"'.format( - self.policy_url, - self.policy, - ) - return response - - class SessionExpiryPolicyMiddleware(CustomLogoutMixin, BaseMiddleware): """ The session expiry middleware will let you expire sessions on @@ -1067,15 +834,19 @@ class SessionExpiryPolicyMiddleware(CustomLogoutMixin, BaseMiddleware): e.g. 'django.contrib.auth.logout'. """ - OPTIONAL_SETTINGS = ('SESSION_COOKIE_AGE', 'SESSION_INACTIVITY_TIMEOUT', - 'SESSION_EXPIRY_EXEMPT_URLS', 'CUSTOM_LOGOUT_MODULE') + OPTIONAL_SETTINGS = ( + "SESSION_COOKIE_AGE", + "SESSION_INACTIVITY_TIMEOUT", + "SESSION_EXPIRY_EXEMPT_URLS", + "CUSTOM_LOGOUT_MODULE", + ) SECONDS_PER_DAY = 86400 SECONDS_PER_30MINS = 1800 # Session keys - START_TIME_KEY = 'starttime' - LAST_ACTIVITY_KEY = 'lastactivity' + START_TIME_KEY = "starttime" + LAST_ACTIVITY_KEY = "lastactivity" @classmethod def _get_datetime_in_session(cls, key, session): @@ -1087,47 +858,34 @@ def _set_datetime_in_session(cls, key, value, session): @classmethod def get_start_time(cls, request): - return cls._get_datetime_in_session( - cls.START_TIME_KEY, - request.session - ) + return cls._get_datetime_in_session(cls.START_TIME_KEY, request.session) @classmethod def set_start_time(cls, request, date): - cls._set_datetime_in_session( - cls.START_TIME_KEY, - date, - request.session - ) + cls._set_datetime_in_session(cls.START_TIME_KEY, date, request.session) @classmethod def get_last_activity(cls, request): - return cls._get_datetime_in_session( - cls.LAST_ACTIVITY_KEY, - request.session - ) + return cls._get_datetime_in_session(cls.LAST_ACTIVITY_KEY, request.session) @classmethod def set_last_activity(cls, request, date): - cls._set_datetime_in_session( - cls.LAST_ACTIVITY_KEY, - date, - request.session - ) + cls._set_datetime_in_session(cls.LAST_ACTIVITY_KEY, date, request.session) def load_setting(self, setting, value): - if setting == 'SESSION_COOKIE_AGE': + if setting == "SESSION_COOKIE_AGE": self.SESSION_COOKIE_AGE = value or self.SECONDS_PER_DAY - logger.debug("Max Session Cookie Age is %d seconds", - self.SESSION_COOKIE_AGE - ) - elif setting == 'SESSION_INACTIVITY_TIMEOUT': + logger.debug( + "Max Session Cookie Age is %d seconds", self.SESSION_COOKIE_AGE + ) + elif setting == "SESSION_INACTIVITY_TIMEOUT": # half an hour in seconds self.SESSION_INACTIVITY_TIMEOUT = value or self.SECONDS_PER_30MINS - logger.debug("Session Inactivity Timeout is %d seconds", - self.SESSION_INACTIVITY_TIMEOUT - ) - elif setting == 'SESSION_EXPIRY_EXEMPT_URLS': + logger.debug( + "Session Inactivity Timeout is %d seconds", + self.SESSION_INACTIVITY_TIMEOUT, + ) + elif setting == "SESSION_EXPIRY_EXEMPT_URLS": self.exempt_urls = [compile(expr) for expr in (value or ())] else: setattr(self, setting, value) @@ -1139,13 +897,13 @@ def process_request(self, request): is the case. We set the last activity time to now() if the session is still active. """ - if not hasattr(request, 'user'): + if not hasattr(request, "user"): raise ImproperlyConfigured( "The Login Required middleware " "requires authentication middleware to be installed." ) - path = request.path_info.lstrip('/') + path = request.path_info.lstrip("/") if any(m.match(path) for m in self.exempt_urls): return @@ -1177,14 +935,10 @@ def process_existing_session(self, request): start_time = self.get_start_time(request) last_activity_time = self.get_last_activity(request) - logger.debug("Session %s started: %s", - session.session_key, - start_time - ) - logger.debug("Session %s last active: %s", - session.session_key, - last_activity_time - ) + logger.debug("Session %s started: %s", session.session_key, start_time) + logger.debug( + "Session %s last active: %s", session.session_key, last_activity_time + ) session_age = self.get_diff_in_seconds(now, start_time) session_too_old = session_age > self.SESSION_COOKIE_AGE @@ -1213,6 +967,7 @@ def get_diff_in_seconds(self, now, time): age = diff.days * self.SECONDS_PER_DAY + diff.seconds return age + # Modified a little bit by us. # Copyright (c) 2008, Ryan Witt @@ -1262,19 +1017,19 @@ class LoginRequiredMiddleware(BaseMiddleware, CustomLogoutMixin): e.g. 'django.contrib.auth.logout'. """ - REQUIRED_SETTINGS = ('LOGIN_URL',) - OPTIONAL_SETTINGS = ('LOGIN_EXEMPT_URLS', 'CUSTOM_LOGOUT_MODULE') + REQUIRED_SETTINGS = ("LOGIN_URL",) + OPTIONAL_SETTINGS = ("LOGIN_EXEMPT_URLS", "CUSTOM_LOGOUT_MODULE") def load_setting(self, setting, value): - if setting == 'LOGIN_URL': + if setting == "LOGIN_URL": self.login_url = value - elif setting == 'LOGIN_EXEMPT_URLS': + elif setting == "LOGIN_EXEMPT_URLS": self.exempt_urls = [compile(expr) for expr in (value or ())] else: setattr(self, setting, value) def assert_authentication_middleware_installed(self, request): - if not hasattr(request, 'user'): + if not hasattr(request, "user"): raise ImproperlyConfigured( "The Login Required middleware " "requires authentication middleware to be installed." @@ -1292,19 +1047,19 @@ def process_request(self, request): if request.user.is_authenticated: return - path = request.path_info.lstrip('/') + path = request.path_info.lstrip("/") if any(m.match(path) for m in self.exempt_urls): return - if hasattr(request, 'login_url'): + if hasattr(request, "login_url"): login_url = request.login_url next_url = None else: login_url = self.login_url next_url = request.path - if request.is_ajax(): + if request.headers.get("x-requested-with") == "XMLHttpRequest": return HttpResponse( json.dumps({"login_url": login_url}), status=401, @@ -1312,10 +1067,11 @@ def process_request(self, request): ) if next_url: - login_url = login_url + '?next=' + next_url + login_url = login_url + "?next=" + next_url return HttpResponseRedirect(login_url) + class ReferrerPolicyMiddleware(BaseMiddleware): """ Sends Referrer-Policy HTTP header that controls when the browser will set @@ -1339,11 +1095,19 @@ class ReferrerPolicyMiddleware(BaseMiddleware): OPTIONAL_SETTINGS = ("REFERRER_POLICY",) - OPTIONS = [ 'no-referrer', 'no-referrer-when-downgrade', 'origin', - 'origin-when-cross-origin', 'same-origin', 'strict-origin', - 'strict-origin-when-cross-origin', 'unsafe-url', 'off' ] + OPTIONS = [ + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + "off", + ] - DEFAULT = 'same-origin' + DEFAULT = "same-origin" def load_setting(self, setting, value): if not value: @@ -1364,7 +1128,93 @@ def process_response(self, request, response): """ Add Referrer-Policy to the response header. """ - if self.option != 'off': + if self.option != "off": header = self.option - response['Referrer-Policy'] = header + response["Referrer-Policy"] = header + return response + + +class ProfilingMiddleware(BaseMiddleware): + """ + Adds the ability to profile requests via a header. + + Usage: + Add the middleware to the MIDDLEWARE list. New boolean setting + "ENABLE_PROFILING" will be required to be set in the settings file. When + set to False, the middleware will deactivate itself. When set to True, the + middleware will be active. + + When the middleware is active, it will log the data for any request that + supplies the X-Profile header in the HTTP request. This data will be logged + to the 'profiling' logger, so in order to see the results of this profiling + the Django logging will need to configure handlers for the 'profiling' + logger. Profiling will be configured at the DEBUG level. + """ + + REQUIRED_SETTINGS = ("ENABLE_PROFILING", "DEBUG") + request_separator = f"\n{'=' * 80}\n" + query_separator = f"\n{'*' * 80}\n" + + def __init__(self, get_response=None): + super().__init__(get_response) + if not self.enable_profiling: + raise MiddlewareNotUsed() + + def load_setting(self, setting, value): + setattr(self, setting.lower(), value) + + def format_queries_and_time_for_logs(self, queries): + formatted_queries = [] + total_time = 0 + for query in queries: + formatted_sql = sqlparse.format( + query["sql"], reindent=True, keyword_case="upper" + ) + + formatted_queries.append("{}:\n{}".format(query["time"], formatted_sql)) + total_time += float(query["time"]) + + log_messages = [ + f"\n{len(queries)} Queries\nTotal time for queries: {total_time}" + ] + formatted_queries + return self.query_separator.join(log_messages) + + def __call__(self, request): + # Only profile requests that have a 'X-Profile' HTTP header + if "HTTP_X_PROFILE" not in request.META: + return self.get_response(request) + + out = StringIO() + out.write(self.request_separator) + + # Add method & path info to differentiate requests + out.write(f"{request.method} {request.path}\n\n") + + # We can only profile queries in debug mode + if self.debug: + num_previous_queries = len(connection.queries) + + # Begin collecting time profiling data + profile = cProfile.Profile() + profile.enable() + + # Continue down the middleware chain + response = self.get_response(request) + + # Get the profile stats & pull out the top cumulative & total time + # data + profile_stats = pstats.Stats(profile, stream=out) + profile_stats = profile_stats.sort_stats("cumulative") + profile_stats.print_stats(128) + profile_stats.sort_stats("tottime") + profile_stats.print_stats(15) + + # Print out our queries + if self.debug: + queries = connection.queries[num_previous_queries:] + out.write(self.format_queries_and_time_for_logs(queries)) + + out.write(self.request_separator) + logger.debug(out.getvalue()) + return response diff --git a/security/migrations/0001_initial.py b/security/migrations/0001_initial.py index 8de0a0b..0c165b8 100644 --- a/security/migrations/0001_initial.py +++ b/security/migrations/0001_initial.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -13,31 +13,106 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='CspReport', + name="CspReport", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('document_uri', models.URLField(help_text='The address of the protected resource, with any fragment component removed', max_length=1000)), - ('referrer', models.URLField(help_text='The referrer attribute of the protected resource', max_length=1000)), - ('blocked_uri', models.URLField(help_text='URI of the resource that was prevented from loading due to the policy violation, with any fragment component removed', max_length=1000)), - ('violated_directive', models.CharField(help_text='The policy directive that was violated', max_length=1000)), - ('original_policy', models.TextField(help_text='The original policy as received by the user-agent.', max_length=1000, null=True)), - ('date_received', models.DateTimeField(help_text='When this report was received', auto_now_add=True)), - ('sender_ip', models.GenericIPAddressField(help_text='IP of the browser sending this report')), - ('user_agent', models.CharField(help_text='User-Agent of reporting browser', max_length=1000)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "document_uri", + models.URLField( + help_text="The address of the protected resource, with any fragment component removed", + max_length=1000, + ), + ), + ( + "referrer", + models.URLField( + help_text="The referrer attribute of the protected resource", + max_length=1000, + ), + ), + ( + "blocked_uri", + models.URLField( + help_text="URI of the resource that was prevented from loading due to the policy violation, with any fragment component removed", + max_length=1000, + ), + ), + ( + "violated_directive", + models.CharField( + help_text="The policy directive that was violated", + max_length=1000, + ), + ), + ( + "original_policy", + models.TextField( + help_text="The original policy as received by the user-agent.", + max_length=1000, + null=True, + ), + ), + ( + "date_received", + models.DateTimeField( + help_text="When this report was received", auto_now_add=True + ), + ), + ( + "sender_ip", + models.GenericIPAddressField( + help_text="IP of the browser sending this report" + ), + ), + ( + "user_agent", + models.CharField( + help_text="User-Agent of reporting browser", max_length=1000 + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='PasswordExpiry', + name="PasswordExpiry", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('password_expiry_date', models.DateTimeField(help_text="The date and time when the user's password expires. If this is empty, the password never expires.", auto_now_add=True, null=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, unique=True, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "password_expiry_date", + models.DateTimeField( + help_text="The date and time when the user's password expires. If this is empty, the password never expires.", + auto_now_add=True, + null=True, + ), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, + unique=True, + on_delete=models.CASCADE, + ), + ), ], options={ - 'verbose_name_plural': 'PasswordExpiries', + "verbose_name_plural": "PasswordExpiries", }, bases=(models.Model,), ), diff --git a/security/migrations/0002_convert_pass_expiry_user_to_one2one.py b/security/migrations/0002_convert_pass_expiry_user_to_one2one.py index dc325b6..5805a45 100644 --- a/security/migrations/0002_convert_pass_expiry_user_to_one2one.py +++ b/security/migrations/0002_convert_pass_expiry_user_to_one2one.py @@ -1,21 +1,23 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('security', '0001_initial'), + ("security", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='passwordexpiry', - name='user', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="passwordexpiry", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), ), ] diff --git a/security/models.py b/security/models.py index 19803f6..9969752 100644 --- a/security/models.py +++ b/security/models.py @@ -1,14 +1,13 @@ # Copyright (c) 2011, SD Elements. See LICENSE.txt for details. +from django.conf import settings from django.contrib.auth.models import User from django.db import models from django.utils import timezone -from django.conf import settings - # Finding proper User model that we can set Foreign key to. # In newer versions of Django default user model can be specified in settings # as `AUTH_USER_MODEL` -USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', User) +USER_MODEL = getattr(settings, "AUTH_USER_MODEL", User) class PasswordExpiry(models.Model): @@ -23,16 +22,13 @@ class PasswordExpiry(models.Model): class Meta(object): verbose_name_plural = "PasswordExpiries" - user = models.OneToOneField( - USER_MODEL, - on_delete=models.deletion.CASCADE - ) + user = models.OneToOneField(USER_MODEL, on_delete=models.deletion.CASCADE) password_expiry_date = models.DateTimeField( auto_now_add=True, null=True, help_text="The date and time when the user's password expires. If " - "this is empty, the password never expires.", + "this is empty, the password never expires.", ) def is_expired(self): @@ -46,7 +42,7 @@ def never_expire(self): self.save() def __unicode__(self): - return u'Password Expiry: {0}'.format(self.user) + return "Password Expiry: {0}".format(self.user) # http://www.w3.org/TR/CSP/#sample-violation-report @@ -73,7 +69,7 @@ class CspReport(models.Model): document_uri = models.URLField( max_length=1000, help_text="The address of the protected resource, " - "with any fragment component removed", + "with any fragment component removed", ) referrer = models.URLField( max_length=1000, @@ -82,7 +78,7 @@ class CspReport(models.Model): blocked_uri = models.URLField( max_length=1000, help_text="URI of the resource that was prevented from loading due to " - "the policy violation, with any fragment component removed", + "the policy violation, with any fragment component removed", ) violated_directive = models.CharField( max_length=1000, @@ -107,7 +103,7 @@ class CspReport(models.Model): ) def __unicode__(self): - return u'CSP Report: {0} from {1}'.format( + return "CSP Report: {0} from {1}".format( self.blocked_uri, self.document_uri, ) diff --git a/security/password_expiry.py b/security/password_expiry.py index 7f90855..3e2ce29 100644 --- a/security/password_expiry.py +++ b/security/password_expiry.py @@ -1,15 +1,16 @@ # Copyright (c) 2011, SD Elements. See LICENSE.txt for details. -from .models import PasswordExpiry from django.conf import settings +from .models import PasswordExpiry + def password_is_expired(user): password_expiry, _ = PasswordExpiry.objects.get_or_create(user=user) - password_settings = getattr(settings, 'MANDATORY_PASSWORD_CHANGE', {}) - include_superusers = password_settings.get('INCLUDE_SUPERUSERS', False) + password_settings = getattr(settings, "MANDATORY_PASSWORD_CHANGE", {}) + include_superusers = password_settings.get("INCLUDE_SUPERUSERS", False) if include_superusers: return password_expiry.is_expired() diff --git a/security/south_migrations/0001_initial.py b/security/south_migrations/0001_initial.py deleted file mode 100644 index 004ee61..0000000 --- a/security/south_migrations/0001_initial.py +++ /dev/null @@ -1,71 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding model 'PasswordExpiry' - db.create_table('security_passwordexpiry', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], unique=True, on_delete=models.CASCADE)), - ('password_expiry_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(1, 1, 1, 0, 0))), - )) - db.send_create_signal('security', ['PasswordExpiry']) - - - def backwards(self, orm): - - # Deleting model 'PasswordExpiry' - db.delete_table('security_passwordexpiry') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'on_delete': 'django.db.models.CASCADE'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'security.passwordexpiry': { - 'Meta': {'object_name': 'PasswordExpiry'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'password_expiry_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(1, 1, 2, 0, 0)'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True', 'on_delete': 'django.db.models.CASCADE'}) - } - } - - complete_apps = ['security'] diff --git a/security/south_migrations/0002_allow_null_password_expiry.py b/security/south_migrations/0002_allow_null_password_expiry.py deleted file mode 100644 index 68d8262..0000000 --- a/security/south_migrations/0002_allow_null_password_expiry.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Changing field 'PasswordExpiry.password_expiry_date' - db.alter_column('security_passwordexpiry', 'password_expiry_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True)) - - def backwards(self, orm): - - # Changing field 'PasswordExpiry.password_expiry_date' - db.alter_column('security_passwordexpiry', 'password_expiry_date', self.gf('django.db.models.fields.DateTimeField')()) - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'security.passwordexpiry': { - 'Meta': {'object_name': 'PasswordExpiry'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'password_expiry_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) - } - } - - complete_apps = ['security'] \ No newline at end of file diff --git a/security/south_migrations/0003_auto__add_cspreport.py b/security/south_migrations/0003_auto__add_cspreport.py deleted file mode 100644 index 657fbb2..0000000 --- a/security/south_migrations/0003_auto__add_cspreport.py +++ /dev/null @@ -1,88 +0,0 @@ -# -*- coding: utf-8 -*- -from south.utils import datetime_utils as datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'CspReport' - db.create_table(u'security_cspreport', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('document_uri', self.gf('django.db.models.fields.URLField')(max_length=1000)), - ('referrer', self.gf('django.db.models.fields.URLField')(max_length=1000)), - ('blocked_uri', self.gf('django.db.models.fields.URLField')(max_length=1000)), - ('violated_directive', self.gf('django.db.models.fields.CharField')(max_length=1000)), - ('original_policy', self.gf('django.db.models.fields.TextField')(max_length=1000, null=True)), - ('date_received', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), - ('sender_ip', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)), - ('user_agent', self.gf('django.db.models.fields.CharField')(max_length=1000)), - )) - db.send_create_signal(u'security', ['CspReport']) - - - def backwards(self, orm): - # Deleting model 'CspReport' - db.delete_table(u'security_cspreport') - - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']", 'on_delete': 'django.db.models.CASCADE'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'security.cspreport': { - 'Meta': {'object_name': 'CspReport'}, - 'blocked_uri': ('django.db.models.fields.URLField', [], {'max_length': '1000'}), - 'date_received': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - 'document_uri': ('django.db.models.fields.URLField', [], {'max_length': '1000'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'original_policy': ('django.db.models.fields.TextField', [], {'max_length': '1000', 'null': 'True'}), - 'referrer': ('django.db.models.fields.URLField', [], {'max_length': '1000'}), - 'sender_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}), - 'user_agent': ('django.db.models.fields.CharField', [], {'max_length': '1000'}), - 'violated_directive': ('django.db.models.fields.CharField', [], {'max_length': '1000'}) - }, - u'security.passwordexpiry': { - 'Meta': {'object_name': 'PasswordExpiry'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'password_expiry_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'unique': 'True', 'on_delete': 'django.db.models.CASCADE'}) - } - } - - complete_apps = ['security'] diff --git a/security/urls.py b/security/urls.py index fedafe9..07c625b 100644 --- a/security/urls.py +++ b/security/urls.py @@ -1,8 +1,7 @@ -from django.conf.urls import url +from django.urls import path from security import views - urlpatterns = [ - url('^/csp-report/$', views.csp_report), + path("/csp-report/", views.csp_report), ] diff --git a/security/views.py b/security/views.py index 26ccd32..8ff6536 100644 --- a/security/views.py +++ b/security/views.py @@ -1,14 +1,14 @@ # Copyright (c) 2011, SD Elements. See LICENSE.txt for details. import json +import logging -from django.http import HttpResponseForbidden, HttpResponse +from django.http import HttpResponse, HttpResponseForbidden from django.views.decorators.csrf import csrf_exempt -import logging log = logging.getLogger(__name__) -ACCEPTABLE_CONTENT_TYPES = ['application/json', 'application/csp-report'] +ACCEPTABLE_CONTENT_TYPES = ["application/json", "application/csp-report"] def require_ajax(view): @@ -17,8 +17,9 @@ def require_ajax(view): by view is an AJAX request. We return a 403 error if the request is not an AJAX request. """ + def check_ajax(request, *args, **kwargs): - if request.is_ajax(): + if request.headers.get("x-requested-with") == "XMLHttpRequest": return view(request, *args, **kwargs) else: return HttpResponseForbidden() @@ -55,46 +56,48 @@ def csp_report(request, csp_save=False, csp_log=True): """ # http://www.w3.org/TR/CSP/#sample-violation-report - if not request.method == 'POST': - log.debug('Unexpect CSP report method %s', request.method) + if not request.method == "POST": + log.debug("Unexpect CSP report method %s", request.method) return HttpResponseForbidden() - content_type = request.META.get('CONTENT_TYPE', None) + content_type = request.META.get("CONTENT_TYPE", None) if content_type not in ACCEPTABLE_CONTENT_TYPES: - log.debug('Missing CSP report Content-Type %s', request.META) + log.debug("Missing CSP report Content-Type %s", request.META) return HttpResponseForbidden() try: csp_dict = json.loads(request.body) except ValueError: - log.debug('Cannot JSON decode CSP report %s', request.body) + log.debug("Cannot JSON decode CSP report %s", request.body) return HttpResponseForbidden() - if 'csp-report' not in csp_dict: - log.debug('Invalid CSP report structure %s', csp_dict) + if "csp-report" not in csp_dict: + log.debug("Invalid CSP report structure %s", csp_dict) return HttpResponseForbidden() - report = csp_dict['csp-report'] - reporting_ip = request.META['REMOTE_ADDR'] - reporting_ua = request.META['HTTP_USER_AGENT'] + report = csp_dict["csp-report"] + reporting_ip = request.META["REMOTE_ADDR"] + reporting_ua = request.META["HTTP_USER_AGENT"] # log message about received CSP violation to Django log if csp_log: log.warn( - 'Content Security Policy violation: ' - '%s, reporting IP %s, user agent %s', - report, reporting_ip, reporting_ua + "Content Security Policy violation: " "%s, reporting IP %s, user agent %s", + report, + reporting_ip, + reporting_ua, ) # save received CSP violation to database if csp_save: from security.models import CspReport + csp_report = CspReport( - document_uri=report.get('document-uri'), - referrer=report.get('referrer'), - blocked_uri=report.get('blocked-uri'), - violated_directive=report.get('violated-directive'), - original_policy=report.get('original-policy'), + document_uri=report.get("document-uri"), + referrer=report.get("referrer"), + blocked_uri=report.get("blocked-uri"), + violated_directive=report.get("violated-directive"), + original_policy=report.get("original-policy"), sender_ip=reporting_ip, user_agent=reporting_ua, ) diff --git a/setup.py b/setup.py deleted file mode 100644 index 2aba0ec..0000000 --- a/setup.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) 2011, SD Elements. See LICENSE.txt for details. - -import os -import sys -import subprocess -from distutils.core import Command -from setuptools import setup - -with open(os.path.join(os.path.dirname(__file__), "README.md")) as f: - readme = f.read() - - -class Test(Command): - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - errno = subprocess.call([sys.executable, "testing/manage.py", "test"]) - raise SystemExit(errno) - - -setup( - name="django-security", - description="A collection of tools to help secure a Django project.", - long_description=readme, - long_description_content_type="text/markdown", - maintainer="SD Elements", - maintainer_email="django-security@sdelements.com", - version="0.14.0", - packages=[ - "security", - "security.south_migrations", - "security.migrations", - "security.auth_throttling", - ], - url="https://github.com/sdelements/django-security", - classifiers=[ - "Framework :: Django", - "Framework :: Django :: 1.11", - "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", - "Environment :: Web Environment", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "License :: OSI Approved :: BSD License", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Security", - ], - install_requires=[ - "django>=1.11", - "ua_parser>=0.7.1", - "python-dateutil>=2.8.1", - ], - cmdclass={"test": Test}, -) diff --git a/testing/__init__.py b/testing/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/testing/manage.py b/testing/manage.py deleted file mode 100755 index ce678a1..0000000 --- a/testing/manage.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -path, scriptname = os.path.split(__file__) - -sys.path.append(os.path.abspath(path)) -sys.path.append(os.path.abspath(os.path.join(path, '..'))) - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") - - from django.core.management import execute_from_command_line - - execute_from_command_line(sys.argv) diff --git a/testing/settings.py b/testing/settings.py deleted file mode 100644 index a81213b..0000000 --- a/testing/settings.py +++ /dev/null @@ -1,150 +0,0 @@ -import os as _os - - -_PROJECT_PATH = _os.path.abspath(_os.path.dirname(__file__)) - -DEBUG = True -ADMINS = () -MANAGERS = ADMINS -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'testing.db', - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', - } -} -TIME_ZONE = 'America/Chicago' -USE_TZ = True -LANGUAGE_CODE = 'en-us' -SITE_ID = 1 -USE_I18N = True -USE_L10N = True -MEDIA_ROOT = '' -MEDIA_URL = '' -STATIC_ROOT = '' -STATIC_URL = '/static/' -STATICFILES_DIRS = () -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -) -SECRET_KEY = 'p_2zsf+@4uw$kcdl$!tkf0lrh%w^!#@2@iwo4plef2n$(@uj4_' - -MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'security.middleware.SessionExpiryPolicyMiddleware', - 'security.middleware.LoginRequiredMiddleware', - 'security.middleware.XFrameOptionsMiddleware', - 'security.middleware.ContentNoSniff', - 'security.middleware.ContentSecurityPolicyMiddleware', - 'security.middleware.StrictTransportSecurityMiddleware', - 'security.middleware.P3PPolicyMiddleware', - 'security.middleware.XssProtectMiddleware', - 'security.middleware.MandatoryPasswordChangeMiddleware', - 'security.middleware.NoConfidentialCachingMiddleware', - 'security.auth_throttling.Middleware', - 'security.middleware.ReferrerPolicyMiddleware', -) - -ROOT_URLCONF = 'testing.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [_os.path.join(_PROJECT_PATH, "templates")], - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ] - } - } -] - - -INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.staticfiles', - 'django.contrib.messages', - 'django.contrib.admin', - 'security', - 'tests' -) - -TEST_RUNNER = 'django.test.runner.DiscoverRunner' - -LOGIN_REDIRECT_URL = "/home/" - -# The tests for django.contrib.auth use certain URLs, and they'll fail if we -# interfere with these. -_DJANGO_TESTING_URLS = [ - 'login/', 'login_required/', 'login_required_login_url/', - 'admin_password_reset/', 'logout/', 'password_reset/', - 'password_reset_from_email/', 'reset/', 'password_change/', 'remote_user/', - 'auth_processor_messages/', 'auth_processor_perms/', - 'auth_processor_user/', 'auth_processor_perm_in_perms/', - 'admin/auth/user/', -] - -LOGIN_EXEMPT_URLS = [ - "accounts/login", - "custom-login", - "admin/reset-account-throttling", -] + _DJANGO_TESTING_URLS - -SESSION_EXPIRY_EXEMPT_URLS = LOGIN_EXEMPT_URLS - -CUSTOM_LOGOUT_MODULE = 'tests.tests.mocked_custom_logout' - -MANDATORY_PASSWORD_CHANGE = { - "URL_NAME": "change_password", - "EXEMPT_URL_NAMES": (), - "EXEMPT_URLS": _DJANGO_TESTING_URLS, -} - -AUTHENTICATION_THROTTLING = { - "DELAY_FUNCTION": lambda x, y: (0, 0), - "LOGIN_URLS_WITH_TEMPLATES": [ - ("accounts/login/", "login.html") - ] -} - -XSS_PROTECT = 'on' -X_FRAME_OPTIONS = 'allow-from: http://example.com' -X_FRAME_OPTIONS_EXCLUDE_URLS = ( - r'^/test\d/$', -) -CSP_STRING = "allow 'self'; script-src *.google.com" -CSP_MODE = 'enforce' -P3P_POLICY_URL = '/w3c/p3p.xml' -P3P_COMPACT_POLICY = 'PRIVATE' - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - '': { - 'handlers': ['console'], - 'level': 'WARNING', - 'propagate': True, - }, - }, -} - -CLEAR_SITE_DATA_URL_WHITELIST = ('/home/') diff --git a/testing/testing.db b/testing/testing.db deleted file mode 100644 index 0fbdb17..0000000 Binary files a/testing/testing.db and /dev/null differ diff --git a/testing/tests/__init__.py b/testing/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/testing/urls.py b/testing/urls.py deleted file mode 100644 index 4428048..0000000 --- a/testing/urls.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) 2011, SD Elements. See ../LICENSE.txt for details. - -from django.conf.urls import url -from django.http import HttpResponse -from django.contrib.auth.views import LoginView, PasswordChangeView - -from security.auth_throttling.views import reset_username_throttle -from security.views import csp_report - -urlpatterns = [ - url("^accounts/login/$", LoginView.as_view(), {}, "login"), - url("^change_password/$", PasswordChangeView.as_view(), - {"post_change_redirect": "/home/"}, "change_password"), - url(r"^admin/reset-account-throttling/(?P-?[0-9]+)/", - reset_username_throttle, - {"redirect_url": "/admin"}, "reset_username_throttle"), - url("^home/$", lambda request: HttpResponse()), - url("^custom-login/$", lambda request: HttpResponse()), - url("^test1/$", lambda request: HttpResponse(), {}, "test1"), - url("^test2/$", lambda request: HttpResponse(), {}, "test2"), - url("^test3/$", lambda request: HttpResponse(), {}, "test3"), - url("^test4/$", lambda request: HttpResponse(), {}, "test4"), - url("^csp-report/$", csp_report), -] diff --git a/security/south_migrations/__init__.py b/tests/__init__.py similarity index 100% rename from security/south_migrations/__init__.py rename to tests/__init__.py diff --git a/testing/tests/models.py b/tests/models.py similarity index 100% rename from testing/tests/models.py rename to tests/models.py diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..55aea59 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,151 @@ +import os as _os + +_PROJECT_PATH = _os.path.abspath(_os.path.dirname(__file__)) + +DEBUG = True +ADMINS = () +MANAGERS = ADMINS +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "testing.db", + "USER": "", + "PASSWORD": "", + "HOST": "", + "PORT": "", + } +} +TIME_ZONE = "America/Chicago" +USE_TZ = True +LANGUAGE_CODE = "en-us" +SITE_ID = 1 +USE_I18N = True +USE_L10N = True +MEDIA_ROOT = "" +MEDIA_URL = "" +STATIC_ROOT = "" +STATIC_URL = "/static/" +STATICFILES_DIRS = () +STATICFILES_FINDERS = ( + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +) +SECRET_KEY = "foobar" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +MIDDLEWARE = ( + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "security.middleware.SessionExpiryPolicyMiddleware", + "security.middleware.LoginRequiredMiddleware", + "security.middleware.XFrameOptionsMiddleware", + "security.middleware.ContentSecurityPolicyMiddleware", + "security.middleware.MandatoryPasswordChangeMiddleware", + "security.middleware.NoConfidentialCachingMiddleware", + "security.auth_throttling.Middleware", + "security.middleware.ReferrerPolicyMiddleware", + "security.middleware.ProfilingMiddleware", +) + +ROOT_URLCONF = "tests.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [_os.path.join(_PROJECT_PATH, "templates")], + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + + +INSTALLED_APPS = ( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.staticfiles", + "django.contrib.messages", + "django.contrib.admin", + "security", + "tests", +) + +TEST_RUNNER = "django.test.runner.DiscoverRunner" + +LOGIN_REDIRECT_URL = "/home/" + +# The tests for django.contrib.auth use certain URLs, and they'll fail if we +# interfere with these. +_DJANGO_TESTING_URLS = [ + "login/", + "login_required/", + "login_required_login_url/", + "admin_password_reset/", + "logout/", + "password_reset/", + "password_reset_from_email/", + "reset/", + "password_change/", + "remote_user/", + "auth_processor_messages/", + "auth_processor_perms/", + "auth_processor_user/", + "auth_processor_perm_in_perms/", + "admin/auth/user/", +] + +LOGIN_EXEMPT_URLS = [ + "accounts/login", + "custom-login", + "admin/reset-account-throttling", +] + _DJANGO_TESTING_URLS + +SESSION_EXPIRY_EXEMPT_URLS = LOGIN_EXEMPT_URLS + +CUSTOM_LOGOUT_MODULE = "tests.tests.mocked_custom_logout" + +MANDATORY_PASSWORD_CHANGE = { + "URL_NAME": "change_password", + "EXEMPT_URL_NAMES": (), + "EXEMPT_URLS": _DJANGO_TESTING_URLS, +} + +AUTHENTICATION_THROTTLING = { + "DELAY_FUNCTION": lambda x, y: (0, 0), + "LOGIN_URLS_WITH_TEMPLATES": [("accounts/login/", "login.html")], +} + +XSS_PROTECT = "on" +X_FRAME_OPTIONS = "allow-from: http://example.com" +X_FRAME_OPTIONS_EXCLUDE_URLS = (r"^/test\d/$",) +CSP_STRING = "allow 'self'; script-src *.google.com" +CSP_MODE = "enforce" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + }, +} + +CLEAR_SITE_DATA_URL_WHITELIST = "/home/" diff --git a/testing/templates/404.html b/tests/templates/404.html similarity index 100% rename from testing/templates/404.html rename to tests/templates/404.html diff --git a/testing/templates/registration/login.html b/tests/templates/registration/login.html similarity index 100% rename from testing/templates/registration/login.html rename to tests/templates/registration/login.html diff --git a/testing/templates/registration/password_change_form.html b/tests/templates/registration/password_change_form.html similarity index 100% rename from testing/templates/registration/password_change_form.html rename to tests/templates/registration/password_change_form.html diff --git a/testing/tests/tests.py b/tests/tests.py similarity index 60% rename from testing/tests/tests.py rename to tests/tests.py index 3ce346d..2f249cd 100644 --- a/testing/tests/tests.py +++ b/tests/tests.py @@ -5,30 +5,32 @@ import time # We monkeypatch this. from django.conf import settings -from django.contrib.auth.models import User from django.contrib.auth import logout +from django.contrib.auth.models import User from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured, MiddlewareNotUsed -from django.urls import reverse from django.forms import ValidationError -from django.http import HttpResponseForbidden, HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseForbidden from django.test import TestCase from django.test.utils import override_settings +from django.urls import reverse from django.utils import timezone from security.auth import min_length -from security.auth_throttling import ( - attempt_count, default_delay_function, delay_message, increment_counters, - reset_counters, Middleware as AuthThrottlingMiddleware -) -from security.middleware import ( - BaseMiddleware, ContentSecurityPolicyMiddleware, DoNotTrackMiddleware, - SessionExpiryPolicyMiddleware, MandatoryPasswordChangeMiddleware, - XssProtectMiddleware, XFrameOptionsMiddleware, ReferrerPolicyMiddleware -) +from security.auth_throttling import Middleware as AuthThrottlingMiddleware +from security.auth_throttling import (attempt_count, default_delay_function, + delay_message, increment_counters, + reset_counters) +from security.middleware import (BaseMiddleware, + ContentSecurityPolicyMiddleware, + DoNotTrackMiddleware, + MandatoryPasswordChangeMiddleware, + ReferrerPolicyMiddleware, + SessionExpiryPolicyMiddleware, + XFrameOptionsMiddleware) from security.models import PasswordExpiry from security.password_expiry import never_expire_password -from security.views import require_ajax, csp_report +from security.views import csp_report, require_ajax try: # Python 3 @@ -46,10 +48,11 @@ def login_user(func): then log that user in. We expect self to be a DjangoTestCase, or some object with a similar interface. """ + def wrapper(self, *args, **kwargs): - username_local = 'a2fcf54f63993b7' - password_local = 'd8327deb882cf90' - email_local = 'testuser@example.com' + username_local = "a2fcf54f63993b7" + password_local = "d8327deb882cf90" + email_local = "testuser@example.com" user = User.objects.create_user( username=username_local, email=email_local, @@ -62,21 +65,23 @@ def wrapper(self, *args, **kwargs): func(self, *args, **kwargs) self.client.logout() user.delete() + return wrapper class CustomLoginURLMiddleware(BaseMiddleware): """Used to test the custom url support in the login required middleware.""" + def process_request(self, request): - request.login_url = '/custom-login/' + request.login_url = "/custom-login/" class BaseMiddlewareTestMiddleware(BaseMiddleware): - REQUIRED_SETTINGS = ('R1', 'R2') - OPTIONAL_SETTINGS = ('O1', 'O2') + REQUIRED_SETTINGS = ("R1", "R2") + OPTIONAL_SETTINGS = ("O1", "O2") def load_setting(self, setting, value): - if not hasattr(self, 'loaded_settings'): + if not hasattr(self, "loaded_settings"): self.loaded_settings = {} self.loaded_settings[setting] = value @@ -92,96 +97,90 @@ class BaseMiddlewareTests(TestCase): def __init__(self, *args, **kwargs): super(BaseMiddlewareTests, self).__init__(*args, **kwargs) module_name = BaseMiddlewareTests.__module__ - self.MIDDLEWARE_NAME = module_name + '.BaseMiddlewareTestMiddleware' + self.MIDDLEWARE_NAME = module_name + ".BaseMiddlewareTestMiddleware" def test_settings_initially_loaded(self): - expected_settings = {'R1': 1, 'R2': 2, 'O1': 3, 'O2': 4} - with self.settings( - MIDDLEWARE=(self.MIDDLEWARE_NAME,), **expected_settings - ): - response = self.client.get('/home/') + expected_settings = {"R1": 1, "R2": 2, "O1": 3, "O2": 4} + with self.settings(MIDDLEWARE=(self.MIDDLEWARE_NAME,), **expected_settings): + response = self.client.get("/home/") self.assertEqual(expected_settings, response.loaded_settings) def test_required_settings(self): with self.settings(MIDDLEWARE=(self.MIDDLEWARE_NAME,)): - self.assertRaises(ImproperlyConfigured, self.client.get, '/home/') + self.assertRaises(ImproperlyConfigured, self.client.get, "/home/") def test_optional_settings(self): - with self.settings( - MIDDLEWARE=(self.MIDDLEWARE_NAME,), R1=True, R2=True - ): - response = self.client.get('/home/') - self.assertEqual(None, response.loaded_settings['O1']) - self.assertEqual(None, response.loaded_settings['O2']) + with self.settings(MIDDLEWARE=(self.MIDDLEWARE_NAME,), R1=True, R2=True): + response = self.client.get("/home/") + self.assertEqual(None, response.loaded_settings["O1"]) + self.assertEqual(None, response.loaded_settings["O2"]) def test_setting_change(self): - with self.settings( - MIDDLEWARE=(self.MIDDLEWARE_NAME,), R1=123, R2=True - ): - response = self.client.get('/home/') - self.assertEqual(123, response.loaded_settings['R1']) + with self.settings(MIDDLEWARE=(self.MIDDLEWARE_NAME,), R1=123, R2=True): + response = self.client.get("/home/") + self.assertEqual(123, response.loaded_settings["R1"]) with override_settings(R1=456): - response = self.client.get('/home/') - self.assertEqual(456, response.loaded_settings['R1']) + response = self.client.get("/home/") + self.assertEqual(456, response.loaded_settings["R1"]) - response = self.client.get('/home/') - self.assertEqual(123, response.loaded_settings['R1']) + response = self.client.get("/home/") + self.assertEqual(123, response.loaded_settings["R1"]) def test_load_setting_abstract_method(self): base = BaseMiddleware() self.assertRaises(NotImplementedError, base.load_setting, None, None) -@override_settings(MIDDLEWARE=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'security.middleware.LoginRequiredMiddleware', -)) +@override_settings( + MIDDLEWARE=( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "security.middleware.LoginRequiredMiddleware", + ) +) class LoginRequiredMiddlewareTests(TestCase): def setUp(self): self.login_url = reverse("login") def test_aborts_if_auth_middleware_missing(self): middleware_classes = settings.MIDDLEWARE - auth_mw = 'django.contrib.auth.middleware.AuthenticationMiddleware' - middleware_classes = [ - m for m in middleware_classes if m != auth_mw - ] + auth_mw = "django.contrib.auth.middleware.AuthenticationMiddleware" + middleware_classes = [m for m in middleware_classes if m != auth_mw] with self.settings(MIDDLEWARE=middleware_classes): - self.assertRaises(ImproperlyConfigured, self.client.get, '/home/') + self.assertRaises(ImproperlyConfigured, self.client.get, "/home/") def test_redirects_unauthenticated_request(self): - response = self.client.get('/home/') + response = self.client.get("/home/") self.assertRedirects(response, self.login_url + "?next=/home/") def test_redirects_unauthenticated_ajax_request(self): response = self.client.get( - '/home/', - HTTP_X_REQUESTED_WITH='XMLHttpRequest', + "/home/", + HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 401) self.assertEqual( - json.loads(response.content.decode('utf-8')), + json.loads(response.content.decode("utf-8")), {"login_url": self.login_url}, ) def test_redirects_to_custom_login_url(self): middlware_classes = list(settings.MIDDLEWARE) - custom_login_middleware = 'tests.tests.CustomLoginURLMiddleware' + custom_login_middleware = "tests.tests.CustomLoginURLMiddleware" with self.settings( MIDDLEWARE=[custom_login_middleware] + middlware_classes, ): - response = self.client.get('/home/') - self.assertRedirects(response, '/custom-login/') + response = self.client.get("/home/") + self.assertRedirects(response, "/custom-login/") response = self.client.get( - '/home/', - HTTP_X_REQUESTED_WITH='XMLHttpRequest', + "/home/", + HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) self.assertEqual(response.status_code, 401) self.assertEqual( - json.loads(response.content.decode('utf-8')), - {"login_url": '/custom-login/'}, + json.loads(response.content.decode("utf-8")), + {"login_url": "/custom-login/"}, ) def test_logs_out_inactive_users(self): @@ -192,28 +191,30 @@ def test_logs_out_inactive_users(self): ) never_expire_password(user) self.client.login(username="foo", password="foo") - resp = self.client.get('/home/') + resp = self.client.get("/home/") self.assertEqual(resp.status_code, 200) # check we are logged in user.is_active = False user.save() - resp = self.client.get('/home/') + resp = self.client.get("/home/") self.assertRedirects(resp, self.login_url + "?next=/home/") -@override_settings(MIDDLEWARE=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'security.middleware.MandatoryPasswordChangeMiddleware', -)) +@override_settings( + MIDDLEWARE=( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "security.middleware.MandatoryPasswordChangeMiddleware", + ) +) class RequirePasswordChangeTests(TestCase): def test_require_password_change(self): """ A brand-new user should have an already-expired password, and therefore be redirected to the password change form on any request. """ - user = User.objects.create_user(username="foo", - password="foo", - email="foo@foo.com") + user = User.objects.create_user( + username="foo", password="foo", email="foo@foo.com" + ) self.client.login(username="foo", password="foo") try: with self.settings( @@ -233,19 +234,20 @@ def test_superuser_password_change(self): """ A superuser can be forced to change their password via settings. """ - user = User.objects.create_superuser(username="foo", - password="foo", - email="foo@foo.com") + user = User.objects.create_superuser( + username="foo", password="foo", email="foo@foo.com" + ) self.client.login(username="foo", password="foo") - with self.settings(MANDATORY_PASSWORD_CHANGE={ - "URL_NAME": "change_password"}): + with self.settings(MANDATORY_PASSWORD_CHANGE={"URL_NAME": "change_password"}): self.assertEqual(self.client.get("/home/").status_code, 200) try: - with self.settings(MANDATORY_PASSWORD_CHANGE={ - "URL_NAME": "change_password", - "INCLUDE_SUPERUSERS": True - }): + with self.settings( + MANDATORY_PASSWORD_CHANGE={ + "URL_NAME": "change_password", + "INCLUDE_SUPERUSERS": True, + } + ): self.assertRedirects( self.client.get("/home/"), reverse("change_password"), @@ -256,18 +258,18 @@ def test_superuser_password_change(self): def test_dont_redirect_exempt_urls(self): user = User.objects.create_user( - username="foo", - password="foo", - email="foo@foo.com" + username="foo", password="foo", email="foo@foo.com" ) self.client.login(username="foo", password="foo") try: - with self.settings(MANDATORY_PASSWORD_CHANGE={ - "URL_NAME": "change_password", - "EXEMPT_URLS": (r'^test1/$', r'^test2/$'), - "EXEMPT_URL_NAMES": ("test3", "test4"), - }): + with self.settings( + MANDATORY_PASSWORD_CHANGE={ + "URL_NAME": "change_password", + "EXEMPT_URLS": (r"^test1/$", r"^test2/$"), + "EXEMPT_URL_NAMES": ("test3", "test4"), + } + ): # Redirect pages in general self.assertRedirects( self.client.get("/home/"), @@ -290,16 +292,18 @@ def test_dont_redirect_exempt_urls(self): user.delete() def test_dont_choke_on_exempt_urls_that_dont_resolve(self): - user = User.objects.create_user(username="foo", - password="foo", - email="foo@foo.com") + user = User.objects.create_user( + username="foo", password="foo", email="foo@foo.com" + ) self.client.login(username="foo", password="foo") try: - with self.settings(MANDATORY_PASSWORD_CHANGE={ - "URL_NAME": "change_password", - "EXEMPT_URL_NAMES": ("fake1", "fake2"), - }): + with self.settings( + MANDATORY_PASSWORD_CHANGE={ + "URL_NAME": "change_password", + "EXEMPT_URL_NAMES": ("fake1", "fake2"), + } + ): # Redirect pages in general self.assertRedirects( self.client.get("/home/"), @@ -314,8 +318,8 @@ def test_raises_improperly_configured(self): self.assertRaises( ImproperlyConfigured, change.load_setting, - 'MANDATORY_PASSWORD_CHANGE', - {'EXEMPT_URLS': []}, + "MANDATORY_PASSWORD_CHANGE", + {"EXEMPT_URLS": []}, ) @@ -327,38 +331,38 @@ class DecoratorTest(TestCase): def require_ajax_test(self): @require_ajax def ajax_only_view(request): - self.assertTrue(request.is_ajax()) + self.assertTrue(request.headers.get("x-requested-with") == "XMLHttpRequest") request = HttpRequest() response = ajax_only_view(request) self.assertTrue(isinstance(response, HttpResponseForbidden)) - request.META['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" response = ajax_only_view(request) self.assertFalse(isinstance(response, HttpResponseForbidden)) -@override_settings(MIDDLEWARE=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'security.middleware.SessionExpiryPolicyMiddleware', - 'security.middleware.LoginRequiredMiddleware', -)) +@override_settings( + MIDDLEWARE=( + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "security.middleware.SessionExpiryPolicyMiddleware", + "security.middleware.LoginRequiredMiddleware", + ) +) class SessionExpiryTests(TestCase): def test_session_variables_are_set(self): """ Verify the session cookie stores the start time and last active time. """ - self.client.get('/home/') + self.client.get("/home/") now = timezone.now() start_time = SessionExpiryPolicyMiddleware._get_datetime_in_session( - SessionExpiryPolicyMiddleware.START_TIME_KEY, - self.client.session + SessionExpiryPolicyMiddleware.START_TIME_KEY, self.client.session ) last_activity = SessionExpiryPolicyMiddleware._get_datetime_in_session( - SessionExpiryPolicyMiddleware.LAST_ACTIVITY_KEY, - self.client.session + SessionExpiryPolicyMiddleware.LAST_ACTIVITY_KEY, self.client.session ) self.assertTrue(now - start_time < datetime.timedelta(seconds=10)) @@ -369,17 +373,12 @@ def session_expiry_test(self, key, expired): Verify that expired sessions are cleared from the system. (And that we redirect to the login page.) """ - self.assertTrue(self.client.get('/home/').status_code, 200) + self.assertTrue(self.client.get("/home/").status_code, 200) session = self.client.session - SessionExpiryPolicyMiddleware._set_datetime_in_session( - key, - expired, - session - ) + SessionExpiryPolicyMiddleware._set_datetime_in_session(key, expired, session) session.save() - response = self.client.get('/home/') - self.assertRedirects(response, - reverse("login") + '?next=/home/') + response = self.client.get("/home/") + self.assertRedirects(response, reverse("login") + "?next=/home/") @login_user def test_session_too_old(self): @@ -389,8 +388,7 @@ def test_session_too_old(self): """ delta = SessionExpiryPolicyMiddleware().SESSION_COOKIE_AGE + 1 expired = timezone.now() - datetime.timedelta(seconds=delta) - self.session_expiry_test(SessionExpiryPolicyMiddleware.START_TIME_KEY, - expired) + self.session_expiry_test(SessionExpiryPolicyMiddleware.START_TIME_KEY, expired) @login_user def test_session_inactive_too_long(self): @@ -410,22 +408,19 @@ def test_exempted_session_expiry_urls(self): delta = SessionExpiryPolicyMiddleware().SESSION_INACTIVITY_TIMEOUT + 1 expired = timezone.now() - datetime.timedelta(seconds=delta) - self.assertTrue(self.client.get('/home/').status_code, 200) + self.assertTrue(self.client.get("/home/").status_code, 200) session = self.client.session SessionExpiryPolicyMiddleware._set_datetime_in_session( - SessionExpiryPolicyMiddleware.LAST_ACTIVITY_KEY, - expired, - session + SessionExpiryPolicyMiddleware.LAST_ACTIVITY_KEY, expired, session ) session.save() - exempted_response = self.client.get('/accounts/login/') - not_exempted_response = self.client.get('/home/') + exempted_response = self.client.get("/accounts/login/") + not_exempted_response = self.client.get("/home/") self.assertTrue(exempted_response.status_code, 200) - self.assertRedirects(not_exempted_response, - reverse("login") + '?next=/home/') + self.assertRedirects(not_exempted_response, reverse("login") + "?next=/home/") @login_user def test_custom_logout(self): @@ -438,26 +433,26 @@ def test_custom_logout(self): assert mocked_custom_logout.called -@override_settings(MIDDLEWARE=( - 'security.middleware.NoConfidentialCachingMiddleware', -)) +@override_settings(MIDDLEWARE=("security.middleware.NoConfidentialCachingMiddleware",)) class ConfidentialCachingTests(TestCase): def setUp(self): self.header_values = { - "Cache-Control": 'no-cache, no-store, max-age=0, must-revalidate', + "Cache-Control": "no-cache, no-store, max-age=0, must-revalidate", "Pragma": "no-cache", - "Expires": '-1' + "Expires": "-1", } - @override_settings(NO_CONFIDENTIAL_CACHING={ - "WHITELIST_ON": True, - "BLACKLIST_ON": False, - "WHITELIST_REGEXES": ["accounts/login/$"], - "BLACKLIST_REGEXES": ["accounts/logout/$"] - }) + @override_settings( + NO_CONFIDENTIAL_CACHING={ + "WHITELIST_ON": True, + "BLACKLIST_ON": False, + "WHITELIST_REGEXES": ["accounts/login/$"], + "BLACKLIST_REGEXES": ["accounts/logout/$"], + } + ) def test_whitelisting(self): # Get Non Confidential Page - response = self.client.get('/accounts/login/') + response = self.client.get("/accounts/login/") for header, value in self.header_values.items(): self.assertNotEqual(response.get(header, None), value) # Get Confidential Page @@ -465,15 +460,17 @@ def test_whitelisting(self): for header, value in self.header_values.items(): self.assertEqual(response.get(header, None), value) - @override_settings(NO_CONFIDENTIAL_CACHING={ - "WHITELIST_ON": False, - "BLACKLIST_ON": True, - "WHITELIST_REGEXES": ["accounts/login/$"], - "BLACKLIST_REGEXES": ["accounts/logout/$"] - }) + @override_settings( + NO_CONFIDENTIAL_CACHING={ + "WHITELIST_ON": False, + "BLACKLIST_ON": True, + "WHITELIST_REGEXES": ["accounts/login/$"], + "BLACKLIST_REGEXES": ["accounts/logout/$"], + } + ) def test_blacklisting(self): # Get Non Confidential Page - response = self.client.get('/accounts/login/') + response = self.client.get("/accounts/login/") for header, value in self.header_values.items(): self.assertNotEqual(response.get(header, None), value) # Get Confidential Page @@ -482,124 +479,69 @@ def test_blacklisting(self): self.assertEqual(response.get(header, None), value) -@override_settings(MIDDLEWARE=('security.middleware.XFrameOptionsMiddleware',)) +@override_settings(MIDDLEWARE=("security.middleware.XFrameOptionsMiddleware",)) class XFrameOptionsDenyTests(TestCase): def test_option_set(self): """ Verify the HTTP Response Header is set. """ - response = self.client.get('/accounts/login/') - self.assertEqual(response['X-Frame-Options'], settings.X_FRAME_OPTIONS) + response = self.client.get("/accounts/login/") + self.assertEqual(response["X-Frame-Options"], settings.X_FRAME_OPTIONS) def test_exclude_urls(self): """ Verify that pages can be excluded from the X-Frame-Options header. """ - response = self.client.get('/home/') - self.assertEqual(response['X-Frame-Options'], settings.X_FRAME_OPTIONS) - response = self.client.get('/test1/') - self.assertNotIn('X-Frame-Options', response) + response = self.client.get("/home/") + self.assertEqual(response["X-Frame-Options"], settings.X_FRAME_OPTIONS) + response = self.client.get("/test1/") + self.assertNotIn("X-Frame-Options", response) def test_improperly_configured(self): xframe = XFrameOptionsMiddleware() self.assertRaises( ImproperlyConfigured, xframe.load_setting, - 'X_FRAME_OPTIONS', - 'invalid', + "X_FRAME_OPTIONS", + "invalid", ) self.assertRaises( ImproperlyConfigured, xframe.load_setting, - 'X_FRAME_OPTIONS_EXCLUDE_URLS', + "X_FRAME_OPTIONS_EXCLUDE_URLS", 1, ) @override_settings(X_FRAME_OPTIONS_EXCLUDE_URLS=None) def test_default_exclude_urls(self): # This URL is excluded in other tests, see settings.py - response = self.client.get('/test1/') + response = self.client.get("/test1/") self.assertEqual( - response['X-Frame-Options'], + response["X-Frame-Options"], settings.X_FRAME_OPTIONS, ) @override_settings(X_FRAME_OPTIONS=None) def test_default_xframe_option(self): - response = self.client.get('/home/') + response = self.client.get("/home/") self.assertEqual( - response['X-Frame-Options'], - 'deny', + response["X-Frame-Options"], + "deny", ) -@override_settings(MIDDLEWARE=('security.middleware.XssProtectMiddleware',)) -class XXssProtectTests(TestCase): - - def test_option_set(self): - """ - Verify the HTTP Response Header is set. - """ - response = self.client.get('/accounts/login/') - self.assertNotEqual(response['X-XSS-Protection'], None) - - def test_default_setting(self): - with self.settings(XSS_PROTECT=None): - response = self.client.get('/accounts/login/') - self.assertEqual(response['X-XSS-Protection'], '1') # sanitize - - def test_option_off(self): - with self.settings(XSS_PROTECT='off'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['X-XSS-Protection'], '0') # off - - def test_improper_configuration_raises(self): - xss = XssProtectMiddleware() - self.assertRaises( - ImproperlyConfigured, - xss.load_setting, - 'XSS_PROTECT', - 'invalid', - ) - - -@override_settings(MIDDLEWARE=('security.middleware.ContentNoSniff',)) -class ContentNoSniffTests(TestCase): - - def test_option_set(self): - """ - Verify the HTTP Response Header is set. - """ - response = self.client.get('/accounts/login/') - self.assertEqual(response['X-Content-Options'], 'nosniff') - - -@override_settings(MIDDLEWARE=( - 'security.middleware.StrictTransportSecurityMiddleware', -)) -class StrictTransportSecurityTests(TestCase): - - def test_option_set(self): - """ - Verify the HTTP Response Header is set. - """ - response = self.client.get('/accounts/login/') - self.assertNotEqual(response['Strict-Transport-Security'], None) - - @override_settings( AUTHENTICATION_THROTTLING={ "DELAY_FUNCTION": lambda x, _: (2 ** (x - 1) if x else 0, 0), - "LOGIN_URLS_WITH_TEMPLATES": [ - ("accounts/login/", "registration/login.html") - ] + "LOGIN_URLS_WITH_TEMPLATES": [("accounts/login/", "registration/login.html")], }, MIDDLEWARE=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'security.auth_throttling.Middleware',) + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "security.auth_throttling.Middleware", + ), ) class AuthenticationThrottlingTests(TestCase): def setUp(self): @@ -607,17 +549,17 @@ def setUp(self): self.old_time = time.time self.time = 0 time.time = lambda: self.time - self.user = User.objects.create_user(username="foo", password="foo", - email="a@foo.org") + self.user = User.objects.create_user( + username="foo", password="foo", email="a@foo.org" + ) def tearDown(self): time.time = self.old_time def attempt(self, password): - return self.client.post("/accounts/login/", - {"username": "foo", - "password": password}, - follow=True) + return self.client.post( + "/accounts/login/", {"username": "foo", "password": password}, follow=True + ) def reset(self): self.client.logout() @@ -627,8 +569,7 @@ def typo(self): self.assertTemplateUsed(self.attempt("bar"), "registration/login.html") def _succeed(self): - self.assertTemplateNotUsed(self.attempt("foo"), - "registration/login.html") + self.assertTemplateNotUsed(self.attempt("foo"), "registration/login.html") self.reset() def _fail(self): @@ -712,12 +653,12 @@ def test_per_account_throttling(self): self.set_time(3) self._succeed() - @override_settings(AUTHENTICATION_THROTTLING={ - "DELAY_FUNCTION": lambda x, y: (x, y), - "LOGIN_URLS_WITH_TEMPLATES": [ - ("accounts/login/", None) - ] - }) + @override_settings( + AUTHENTICATION_THROTTLING={ + "DELAY_FUNCTION": lambda x, y: (x, y), + "LOGIN_URLS_WITH_TEMPLATES": [("accounts/login/", None)], + } + ) def test_too_many_requests_error_when_no_template_provided(self): """ Verify we simply return a 429 error when there is no login template @@ -745,8 +686,9 @@ def test_reset_button(self): """ self.set_time(0) self.typo() - admin = User.objects.create_user(username="bar", password="bar", - email="a@bar.org") + admin = User.objects.create_user( + username="bar", password="bar", email="a@bar.org" + ) admin.is_superuser = True admin.save() self.client.login(username="bar", password="bar") @@ -756,9 +698,11 @@ def test_reset_button(self): self.client.logout() self._succeed() - @override_settings(AUTHENTICATION_THROTTLING={ - "DELAY_FUNCTION": lambda x, y: (x, y), - }) + @override_settings( + AUTHENTICATION_THROTTLING={ + "DELAY_FUNCTION": lambda x, y: (x, y), + } + ) def test_improperly_configured_middleware(self): self.assertRaises(ImproperlyConfigured, AuthThrottlingMiddleware) @@ -785,32 +729,18 @@ def test_throttle_reset_404_on_not_found(self): self.assertEqual(resp.status_code, 404) -@override_settings(MIDDLEWARE=('security.middleware.P3PPolicyMiddleware',)) -class P3PPolicyTests(TestCase): - - def setUp(self): - self.policy = "NN AD BLAH" - settings.P3P_COMPACT_POLICY = self.policy - - def test_p3p_header(self): - expected_header = 'policyref="/w3c/p3p.xml" CP="%s"' % self.policy - response = self.client.get('/accounts/login/') - self.assertEqual(response["P3P"], expected_header) - - class AuthTests(TestCase): def test_min_length(self): self.assertRaises(ValidationError, min_length(6), "abcde") min_length(6)("abcdef") -@override_settings(MIDDLEWARE=( - 'security.middleware.ContentSecurityPolicyMiddleware', -)) +@override_settings(MIDDLEWARE=("security.middleware.ContentSecurityPolicyMiddleware",)) class ContentSecurityPolicyTests(TestCase): class FakeHttpRequest(object): - method = 'POST' - body = """{ + method = "POST" + body = ( + """{ "csp-report": { "document-uri": "http://example.org/page.html", "referrer": "http://evil.example.com/haxor.html", @@ -819,20 +749,22 @@ class FakeHttpRequest(object): "original-policy": "%s" } } - """ % settings.CSP_STRING + """ + % settings.CSP_STRING + ) META = { - 'CONTENT_TYPE': 'application/json', - 'REMOTE_ADDR': '127.0.0.1', - 'HTTP_USER_AGENT': 'FakeHTTPRequest' + "CONTENT_TYPE": "application/json", + "REMOTE_ADDR": "127.0.0.1", + "HTTP_USER_AGENT": "FakeHTTPRequest", } def test_option_set(self): """ Verify the HTTP Response Header is set. """ - response = self.client.get('/accounts/login/') + response = self.client.get("/accounts/login/") self.assertEqual( - response['Content-Security-Policy'], + response["Content-Security-Policy"], settings.CSP_STRING, ) @@ -857,19 +789,29 @@ def test_csp_view(self): def test_csp_gen_1(self): csp_dict = { - 'default-src': ['self', 'cdn.example.com'], - 'script-src': ['self', 'js.example.com'], - 'style-src': ['self', 'css.example.com'], - 'img-src': ['self', 'img.example.com'], - 'connect-src': ['self', ], - 'font-src': ['fonts.example.com', ], - 'object-src': ['self'], - 'media-src': ['media.example.com', ], - 'frame-src': ['*', ], - 'sandbox': ['', ], - 'reflected-xss': 'filter', - 'referrer': 'origin', - 'report-uri': 'http://example.com/csp-report', + "default-src": ["self", "cdn.example.com"], + "script-src": ["self", "js.example.com"], + "style-src": ["self", "css.example.com"], + "img-src": ["self", "img.example.com"], + "connect-src": [ + "self", + ], + "font-src": [ + "fonts.example.com", + ], + "object-src": ["self"], + "media-src": [ + "media.example.com", + ], + "frame-src": [ + "*", + ], + "sandbox": [ + "", + ], + "reflected-xss": "filter", + "referrer": "origin", + "report-uri": "http://example.com/csp-report", } expected = ( @@ -894,36 +836,33 @@ def test_csp_gen_1(self): # We can't assume the iteration order on the csp_dict, so we split the # output, sort, and ensure we got all the results back, regardless of # the order. - expected_list = sorted(x.strip() for x in expected.split(';')) - generated_list = sorted(x.strip() for x in generated.split(';')) + expected_list = sorted(x.strip() for x in expected.split(";")) + generated_list = sorted(x.strip() for x in generated.split(";")) self.assertEqual(generated_list, expected_list) def test_csp_gen_2(self): - csp_dict = {'default-src': ('none',), 'script-src': ['none']} + csp_dict = {"default-src": ("none",), "script-src": ["none"]} expected = "default-src 'none'; script-src 'none'" csp = ContentSecurityPolicyMiddleware() generated = csp._csp_builder(csp_dict) - expected_list = sorted(x.strip() for x in expected.split(';')) - generated_list = sorted(x.strip() for x in generated.split(';')) + expected_list = sorted(x.strip() for x in expected.split(";")) + generated_list = sorted(x.strip() for x in generated.split(";")) self.assertEqual(generated_list, expected_list) def test_csp_gen_3(self): csp_dict = { - 'script-src': [ - 'self', - 'www.google-analytics.com', - 'ajax.googleapis.com', + "script-src": [ + "self", + "www.google-analytics.com", + "ajax.googleapis.com", ], } - expected = ( - "script-src " - "'self' www.google-analytics.com ajax.googleapis.com" - ) + expected = "script-src " "'self' www.google-analytics.com ajax.googleapis.com" csp = ContentSecurityPolicyMiddleware() generated = csp._csp_builder(csp_dict) @@ -932,40 +871,40 @@ def test_csp_gen_3(self): def test_csp_gen_err(self): # argument not passed as array, expect failure - csp_dict = {'default-src': 'self'} + csp_dict = {"default-src": "self"} csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) def test_csp_gen_err2(self): - csp_dict = {'invalid': 'self'} # invalid directive + csp_dict = {"invalid": "self"} # invalid directive csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) def test_csp_gen_err3(self): - csp_dict = {'sandbox': 'none'} # not a list or tuple, expect failure + csp_dict = {"sandbox": "none"} # not a list or tuple, expect failure csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) def test_csp_gen_err4(self): # Not an allowed directive, expect failure - csp_dict = {'sandbox': ('invalid', )} + csp_dict = {"sandbox": ("invalid",)} csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) def test_csp_gen_err5(self): # Not an allowed directive, expect failure - csp_dict = {'referrer': 'invalid'} + csp_dict = {"referrer": "invalid"} csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) def test_csp_gen_err6(self): # Not an allowed directive, expect failure - csp_dict = {'reflected-xss': 'invalid'} + csp_dict = {"reflected-xss": "invalid"} csp = ContentSecurityPolicyMiddleware() self.assertRaises(MiddlewareNotUsed, csp._csp_builder, csp_dict) @@ -973,29 +912,29 @@ def test_csp_gen_err6(self): def test_enforced_by_default(self): with self.settings(CSP_MODE=None): response = self.client.get(settings.LOGIN_URL) - self.assertIn('Content-Security-Policy', response) - self.assertNotIn('Content-Security-Policy-Report-Only', response) + self.assertIn("Content-Security-Policy", response) + self.assertNotIn("Content-Security-Policy-Report-Only", response) def test_enforced_when_on(self): - with self.settings(CSP_MODE='enforce'): + with self.settings(CSP_MODE="enforce"): response = self.client.get(settings.LOGIN_URL) - self.assertIn('Content-Security-Policy', response) - self.assertNotIn('Content-Security-Policy-Report-Only', response) + self.assertIn("Content-Security-Policy", response) + self.assertNotIn("Content-Security-Policy-Report-Only", response) def test_report_only_set(self): - with self.settings(CSP_MODE='report-only'): + with self.settings(CSP_MODE="report-only"): response = self.client.get(settings.LOGIN_URL) - self.assertNotIn('Content-Security-Policy', response) - self.assertIn('Content-Security-Policy-Report-Only', response) + self.assertNotIn("Content-Security-Policy", response) + self.assertIn("Content-Security-Policy-Report-Only", response) def test_both_enforce_and_report_only(self): - with self.settings(CSP_MODE='enforce-and-report-only'): + with self.settings(CSP_MODE="enforce-and-report-only"): response = self.client.get(settings.LOGIN_URL) - self.assertIn('Content-Security-Policy', response) - self.assertIn('Content-Security-Policy-Report-Only', response) + self.assertIn("Content-Security-Policy", response) + self.assertIn("Content-Security-Policy-Report-Only", response) def test_invalid_csp_mode(self): - with self.settings(CSP_MODE='invalid'): + with self.settings(CSP_MODE="invalid"): self.assertRaises( MiddlewareNotUsed, ContentSecurityPolicyMiddleware, @@ -1009,7 +948,7 @@ def test_no_csp_options_set(self): ) def test_both_csp_options_set(self): - with self.settings(CSP_DICT={'x': 'y'}, CSP_STRING='x y;'): + with self.settings(CSP_DICT={"x": "y"}, CSP_STRING="x y;"): self.assertRaises( MiddlewareNotUsed, ContentSecurityPolicyMiddleware, @@ -1017,31 +956,31 @@ def test_both_csp_options_set(self): def test_sets_from_csp_dict(self): with self.settings( - CSP_DICT={'default-src': ('self',)}, + CSP_DICT={"default-src": ("self",)}, CSP_STRING=None, ): - response = self.client.get('/accounts/login/') + response = self.client.get("/accounts/login/") self.assertEqual( - response['Content-Security-Policy'], + response["Content-Security-Policy"], "default-src 'self'", ) -@override_settings(MIDDLEWARE=('security.middleware.DoNotTrackMiddleware',)) +@override_settings(MIDDLEWARE=("security.middleware.DoNotTrackMiddleware",)) class DoNotTrackTests(TestCase): def setUp(self): - self.dnt = DoNotTrackMiddleware() self.request = HttpRequest() self.response = HttpResponse() + self.dnt = DoNotTrackMiddleware(self.response) def test_set_DNT_on(self): - self.request.META['HTTP_DNT'] = '1' + self.request.META["HTTP_DNT"] = "1" self.dnt.process_request(self.request) self.assertTrue(self.request.dnt) def test_set_DNT_off(self): - self.request.META['HTTP_DNT'] = 'off' + self.request.META["HTTP_DNT"] = "off" self.dnt.process_request(self.request) self.assertFalse(self.request.dnt) @@ -1050,19 +989,20 @@ def test_default_DNT(self): self.assertFalse(self.request.dnt) def test_DNT_echo_on(self): - self.request.META['HTTP_DNT'] = '1' + self.request.META["HTTP_DNT"] = "1" self.dnt.process_response(self.request, self.response) - self.assertIn('DNT', self.response) - self.assertEqual(self.response['DNT'], '1') + self.assertIn("DNT", self.response) + self.assertEqual(self.response["DNT"], "1") def test_DNT_echo_off(self): - self.request.META['HTTP_DNT'] = 'off' + self.request.META["HTTP_DNT"] = "off" self.dnt.process_response(self.request, self.response) - self.assertEqual(self.response['DNT'], 'off') + self.assertEqual(self.response["DNT"], "off") def test_DNT_echo_default(self): self.dnt.process_response(self.request, self.response) - self.assertNotIn('DNT', self.response) + self.assertNotIn("DNT", self.response) + class ReferrerPolicyTests(TestCase): @@ -1070,64 +1010,66 @@ def test_option_set(self): """ Verify the HTTP Referrer-Policy Header is set. """ - response = self.client.get('/accounts/login/') - self.assertNotEqual(response['Referrer-Policy'], None) + response = self.client.get("/accounts/login/") + self.assertNotEqual(response["Referrer-Policy"], None) def test_default_setting(self): with self.settings(REFERRER_POLICY=None): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'same-origin') + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "same-origin") def test_no_referrer_setting(self): - with self.settings(REFERRER_POLICY='no-referrer'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'no-referrer') + with self.settings(REFERRER_POLICY="no-referrer"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "no-referrer") def test_no_referrer_when_downgrade_setting(self): - with self.settings(REFERRER_POLICY='no-referrer-when-downgrade'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'no-referrer-when-downgrade') + with self.settings(REFERRER_POLICY="no-referrer-when-downgrade"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "no-referrer-when-downgrade") def test_origin_setting(self): - with self.settings(REFERRER_POLICY='origin'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'origin') + with self.settings(REFERRER_POLICY="origin"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "origin") def test_origin_when_cross_origin_setting(self): - with self.settings(REFERRER_POLICY='origin-when-cross-origin'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'origin-when-cross-origin') + with self.settings(REFERRER_POLICY="origin-when-cross-origin"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "origin-when-cross-origin") def test_same_origin_setting(self): - with self.settings(REFERRER_POLICY='same-origin'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'same-origin') + with self.settings(REFERRER_POLICY="same-origin"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "same-origin") def test_strict_origin_setting(self): - with self.settings(REFERRER_POLICY='strict-origin'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'strict-origin') + with self.settings(REFERRER_POLICY="strict-origin"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "strict-origin") def test_strict_origin_when_cross_origin_setting(self): - with self.settings(REFERRER_POLICY='strict-origin-when-cross-origin'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin') + with self.settings(REFERRER_POLICY="strict-origin-when-cross-origin"): + response = self.client.get("/accounts/login/") + self.assertEqual( + response["Referrer-Policy"], "strict-origin-when-cross-origin" + ) def test_unsafe_url_setting(self): - with self.settings(REFERRER_POLICY='unsafe-url'): - response = self.client.get('/accounts/login/') - self.assertEqual(response['Referrer-Policy'], 'unsafe-url') + with self.settings(REFERRER_POLICY="unsafe-url"): + response = self.client.get("/accounts/login/") + self.assertEqual(response["Referrer-Policy"], "unsafe-url") def test_off_setting(self): - with self.settings(REFERRER_POLICY='off'): - response = self.client.get('/accounts/login/') - self.assertEqual('Referrer-Policy' in response, False) + with self.settings(REFERRER_POLICY="off"): + response = self.client.get("/accounts/login/") + self.assertEqual("Referrer-Policy" in response, False) def test_improper_configuration_raises(self): referer_policy_middleware = ReferrerPolicyMiddleware() self.assertRaises( ImproperlyConfigured, referer_policy_middleware.load_setting, - 'REFERRER_POLICY', - 'invalid', + "REFERRER_POLICY", + "invalid", ) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..5c302a2 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,31 @@ +# Copyright (c) 2011, SD Elements. See ../LICENSE.txt for details. + +from django.contrib.auth.views import LoginView, PasswordChangeView +from django.http import HttpResponse +from django.urls import path, re_path + +from security.auth_throttling.views import reset_username_throttle +from security.views import csp_report + +urlpatterns = [ + path("accounts/login/", LoginView.as_view(), {}, "login"), + path( + "change_password/", + PasswordChangeView.as_view(), + {"post_change_redirect": "/home/"}, + "change_password", + ), + re_path( + r"^admin/reset-account-throttling/(?P-?[0-9]+)/", + reset_username_throttle, + {"redirect_url": "/admin"}, + "reset_username_throttle", + ), + path("home/", lambda request: HttpResponse()), + path("custom-login/", lambda request: HttpResponse()), + path("test1/", lambda request: HttpResponse(), {}, "test1"), + path("test2/", lambda request: HttpResponse(), {}, "test2"), + path("test3/", lambda request: HttpResponse(), {}, "test3"), + path("test4/", lambda request: HttpResponse(), {}, "test4"), + path("csp-report/", csp_report), +] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index b2e8add..0000000 --- a/tox.ini +++ /dev/null @@ -1,43 +0,0 @@ -[tox] -envlist = {py36}-django{111,22,30}, docs, pep8 - -[testenv] -whitelist_externals = make -commands = coverage run --source {envsitepackagesdir}/security --omit="*migrations/*" testing/manage.py test tests -commands_post = coverage report -basepython = - py36: python3.6 -deps = - django-discover-runner - django111: django==1.11 - django22: django==2.2 - django30: django==3.0 - coverage - ua_parser==0.7.1 - mock==2.0.0 - -[testenv:docs] -basepython = python3.6 -deps = - Sphinx - django==3.0 - ua_parser==0.7.1 - coverage -commands = - make clean - make html -commands_post = - coverage report - -[testenv:pep8] -basepython = python3.6 -deps= - pep8-naming - hacking - flake8 - coverage -commands=flake8 security testing - -[flake8] -ignore=E131,H306,H301,H404,H405,H101,N802,N812,W503 -max-complexity=10 -exclude=*migrations*