diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index de80497..9ef8f51 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,7 +27,7 @@ jobs: pip install -r requirements_dev.txt pip install -r requirements.txt - name: Check dependencies for known security vulnerabilities - run: safety check -r requirements.txt + run: pip-audit -r requirements.txt - name: Check code for potential security vulnerabilities run: bandit -r . -x /tests - name: Check code formatting diff --git a/Dockerfile b/Dockerfile index ca27f4f..189b2fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,17 @@ FROM python:3.12-slim -RUN useradd containeruser +RUN useradd appuser -WORKDIR /home/containeruser - -COPY app app -COPY govuk-frontend-flask.py config.py docker-entrypoint.sh requirements.txt ./ -RUN pip install -r requirements.txt \ - && chmod +x docker-entrypoint.sh \ - && chown -R containeruser:containeruser ./ +WORKDIR /home/appuser # Set environment variables ENV FLASK_APP=govuk-frontend-flask.py \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -USER containeruser +COPY app app +COPY govuk-frontend-flask.py config.py requirements.txt ./ +RUN pip install -r requirements.txt \ + && chown -R appuser:appuser ./ -EXPOSE 9876 -ENTRYPOINT ["./docker-entrypoint.sh"] \ No newline at end of file +USER appuser \ No newline at end of file diff --git a/README.md b/README.md index 0c71321..b3f9a57 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GOV.UK Frontend Flask -![govuk-frontend 5.4.0](https://img.shields.io/badge/govuk--frontend%20version-5.4.0-005EA5?logo=gov.uk&style=flat) +![govuk-frontend 5.6.0](https://img.shields.io/badge/govuk--frontend%20version-5.6.0-005EA5?logo=gov.uk&style=flat) **GOV.UK Frontend Flask is a [community tool](https://design-system.service.gov.uk/community/resources-and-tools/) of the [GOV.UK Design System](https://design-system.service.gov.uk/). The Design System team is not responsible for it and cannot support you with using it. Contact the [maintainers](#contributors) directly if you need [help](#support) or you want to request a feature.** @@ -53,7 +53,7 @@ python -c 'import secrets; print(secrets.token_hex())' docker compose up --build ``` -You should now have the app running on . Accept the browsers security warning due to the self-signed HTTPS certificate to continue. +You should now have the app running on . Accept the browsers security warning due to the self-signed HTTPS certificate to continue. ## Demos @@ -67,19 +67,52 @@ To run the tests: python -m pytest --cov=app --cov-report=term-missing --cov-branch ``` +## Environment + +```mermaid +flowchart TB + cache1(Redis):::CACHE + Client + prox1(NGINX):::PROXY + web1(Flask app):::WEB + web2[/Static files/]:::WEB + + Client <-- https:443 --> prox1 <-- http:5000 --> web1 + prox1 -- Read only --> web2 + web1 -- Write --> web2 + web1 <-- redis:6379 --> cache1 + + subgraph Proxy container + prox1 + end + + subgraph Web container + web1 + web2 + end + + subgraph Cache container + cache1 + end + + classDef CACHE fill:#F8CECC,stroke:#B85450,stroke-width:2px + classDef PROXY fill:#D5E8D4,stroke:#82B366,stroke-width:2px + classDef WEB fill:#FFF2CC,stroke:#D6B656,stroke-width:2px +``` + ## Features Please refer to the specific packages documentation for more details. ### Asset management -Custom CSS and JavaScript files are merged and compressed using [Flask Assets](https://flask-assets.readthedocs.io/en/latest/) and [Webassets](https://webassets.readthedocs.io/en/latest/). This takes all `*.css` files in `app/static/src/css` and all `*.js` files in `app/static/src/js` and outputs a single compressed file to both `app/static/dist/css` and `app/static/dist/js` respectively. +Custom CSS and JavaScript files are merged and minified using [Flask Assets](https://flask-assets.readthedocs.io/en/latest/) and [Webassets](https://webassets.readthedocs.io/en/latest/). This takes all `*.css` files in `app/static/src/css` and all `*.js` files in `app/static/src/js` and outputs a single minified file to both `app/static/dist/css` and `app/static/dist/js` respectively. CSS is [minified](https://en.wikipedia.org/wiki/Minification_(programming)) using [CSSMin](https://github.com/zacharyvoase/cssmin) and JavaScript is minified using [JSMin](https://github.com/tikitu/jsmin/). This removes all whitespace characters, comments and line breaks to reduce the size of the source code, making its transmission over a network more efficient. ### Cache busting -Merged and compressed assets are browser cache busted on update by modifying their URL with their MD5 hash using [Flask Assets](https://flask-assets.readthedocs.io/en/latest/) and [Webassets](https://webassets.readthedocs.io/en/latest/). The MD5 hash is appended to the file name, for example `custom-d41d8cd9.css` instead of a query string, to support certain older browsers and proxies that ignore the querystring in their caching behaviour. +Merged and minified assets are browser cache busted on update by modifying the filename with their MD5 hash using [Flask Assets](https://flask-assets.readthedocs.io/en/latest/) and [Webassets](https://webassets.readthedocs.io/en/latest/). The MD5 hash is appended to the file name, for example `custom-d41d8cd9.css` instead of a query string, to support certain older browsers and proxies that ignore the querystring in their caching behaviour. ### Forms generation and validation @@ -101,20 +134,21 @@ CSRF errors are handled by creating a [flash message](#flash-messages) notificat ### HTTP security headers -Uses [Flask Talisman](https://github.com/GoogleCloudPlatform/flask-talisman) to set HTTP headers that can help protect against a few common web application security issues. - -- Forces all connections to `https`, unless running with debug enabled. +- Forces all connections to `https`. - Enables [HTTP Strict Transport Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security). - Sets Flask's session cookie to `secure`, so it will never be set if your application is somehow accessed via a non-secure connection. - Sets Flask's session cookie to `httponly`, preventing JavaScript from being able to access its content. - Sets [X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) to `SAMEORIGIN` to avoid [clickjacking](https://en.wikipedia.org/wiki/Clickjacking). -- Sets [X-XSS-Protection](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection) to enable a cross site scripting filter for IE and Safari (note Chrome has removed this and Firefox never supported it). - Sets [X-Content-Type-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options) to prevent content type sniffing. - Sets a strict [Referrer-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy) of `strict-origin-when-cross-origin` that governs which referrer information should be included with requests made. ### Content Security Policy -A strict default [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (CSP) is set using [Flask Talisman](https://github.com/GoogleCloudPlatform/flask-talisman) to mitigate [Cross Site Scripting](https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_xss) (XSS) and packet sniffing attacks. This prevents loading any resources that are not in the same domain as the application. +A strict [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) (CSP) is set to mitigate [Cross Site Scripting](https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_xss) (XSS) and packet sniffing attacks. This prevents loading any resources that are not in the same domain as the application by default. + +### Permissions Policy + +A strict [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy) is set to deny the use of browser features by default. ### Response compression diff --git a/app/__init__.py b/app/__init__.py index a67c326..7d75258 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,20 +1,20 @@ from flask import Flask from flask_assets import Bundle, Environment -from flask_compress import Compress from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from flask_talisman import Talisman from flask_wtf.csrf import CSRFProtect from govuk_frontend_wtf.main import WTFormsHelpers from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader +from werkzeug.middleware.proxy_fix import ProxyFix from config import Config assets = Environment() -compress = Compress() csrf = CSRFProtect() -limiter = Limiter(get_remote_address, default_limits=["2 per second", "60 per minute"]) -talisman = Talisman() +limiter = Limiter( + get_remote_address, + default_limits=["2 per second", "60 per minute"], +) def create_app(config_class=Config): @@ -33,74 +33,25 @@ def create_app(config_class=Config): ), ] ) - - # Set content security policy - csp = { - "default-src": "'self'", - "script-src": [ - "'self'", - "'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='", - "'sha256-xvC5hOpINthj2xzP7qkRGmqR3SpU8ZVw1sEMKbsOS/4='", - ], - } - - # Set permissions policy - permissions_policy = { - "accelerometer": "()", - "ambient-light-sensor": "()", - "autoplay": "()", - "battery": "()", - "camera": "()", - "cross-origin-isolated": "()", - "display-capture": "()", - "document-domain": "()", - "encrypted-media": "()", - "execution-while-not-rendered": "()", - "execution-while-out-of-viewport": "()", - "fullscreen": "()", - "geolocation": "()", - "gyroscope": "()", - "keyboard-map": "()", - "magnetometer": "()", - "microphone": "()", - "midi": "()", - "navigation-override": "()", - "payment": "()", - "picture-in-picture": "()", - "publickey-credentials-get": "()", - "screen-wake-lock": "()", - "sync-xhr": "()", - "usb": "()", - "web-share": "()", - "xr-spatial-tracking": "()", - "clipboard-read": "()", - "clipboard-write": "()", - "gamepad": "()", - "speaker-selection": "()", - "conversion-measurement": "()", - "focus-without-user-activation": "()", - "hid": "()", - "idle-detection": "()", - "interest-cohort": "()", - "serial": "()", - "sync-script": "()", - "trust-token-redemption": "()", - "unload": "()", - "window-management": "()", - "vertical-scroll": "()", - } + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # Initialise app extensions assets.init_app(app) - compress.init_app(app) csrf.init_app(app) limiter.init_app(app) - talisman.init_app(app, content_security_policy=csp, permissions_policy=permissions_policy) WTFormsHelpers(app) # Create static asset bundles - css = Bundle("src/css/*.css", filters="cssmin", output="dist/css/custom-%(version)s.min.css") - js = Bundle("src/js/*.js", filters="jsmin", output="dist/js/custom-%(version)s.min.js") + css = Bundle( + "src/css/*.css", + filters="cssmin", + output="dist/css/custom-%(version)s.min.css", + ) + js = Bundle( + "src/js/*.js", + filters="jsmin", + output="dist/js/custom-%(version)s.min.js", + ) if "css" not in assets: assets.register("css", css) if "js" not in assets: diff --git a/app/demos/custom_validators.py b/app/demos/custom_validators.py index c05667e..08390b1 100644 --- a/app/demos/custom_validators.py +++ b/app/demos/custom_validators.py @@ -2,7 +2,13 @@ class RequiredIf(InputRequired): - def __init__(self, other_field_name, other_field_value, *args, **kwargs): + def __init__( + self, + other_field_name, + other_field_value, + *args, + **kwargs, + ): self.other_field_name = other_field_name self.other_field_value = other_field_value diff --git a/app/demos/forms.py b/app/demos/forms.py index 6059857..9448ff5 100644 --- a/app/demos/forms.py +++ b/app/demos/forms.py @@ -45,7 +45,10 @@ class BankDetailsForm(FlaskForm): widget=GovTextInput(), validators=[ InputRequired(message="Enter a sort code"), - Regexp(regex=r"\d{6}", message="Enter a valid sort code like 309430"), + Regexp( + regex=r"\d{6}", + message="Enter a valid sort code like 309430", + ), ], description="Must be 6 digits long", ) @@ -54,8 +57,15 @@ class BankDetailsForm(FlaskForm): widget=GovTextInput(), validators=[ InputRequired(message="Enter an account number"), - Regexp(regex=r"\d{6,8}", message="Enter a valid account number like 00733445"), - Length(min=6, max=8, message="Account number must be between 6 and 8 digits"), + Regexp( + regex=r"\d{6,8}", + message="Enter a valid account number like 00733445", + ), + Length( + min=6, + max=8, + message="Account number must be between 6 and 8 digits", + ), ], description="Must be between 6 and 8 digits long", ) @@ -118,7 +128,10 @@ class CreateAccountForm(FlaskForm): widget=GovTextInput(), validators=[ InputRequired(message="Enter an email address"), - Length(max=256, message="Email address must be 256 characters or fewer"), + Length( + max=256, + message="Email address must be 256 characters or fewer", + ), Email(message="Enter an email address in the correct format, like name@example.com"), ], description="You'll need this email address to sign in to your account", @@ -139,7 +152,10 @@ class CreateAccountForm(FlaskForm): widget=GovPasswordInput(), validators=[ InputRequired(message="Enter a password"), - Length(min=8, message="Password must be at least 8 characters"), + Length( + min=8, + message="Password must be at least 8 characters", + ), ], description="Must be at least 8 characters", ) @@ -170,7 +186,10 @@ class KitchenSinkForm(FlaskForm): email_field = StringField( "EmailField", widget=GovTextInput(), - validators=[InputRequired(message="EmailField is required"), Email()], + validators=[ + InputRequired(message="EmailField is required"), + Email(), + ], description="StringField rendered using a GovTextInput widget.", ) @@ -207,7 +226,10 @@ class KitchenSinkForm(FlaskForm): widget=GovCharacterCount(), validators=[ InputRequired(message="CharacterCountField is required"), - Length(max=200, message="CharacterCountField must be 200 characters or fewer "), + Length( + max=200, + message="CharacterCountField must be 200 characters or fewer ", + ), ], description="TextAreaField rendered using a GovCharacterCount widget.", ) @@ -237,7 +259,11 @@ class KitchenSinkForm(FlaskForm): "SelectMultipleField", widget=GovCheckboxesInput(), validators=[InputRequired(message="Please select an option")], - choices=[("one", "One"), ("two", "Two"), ("three", "Three")], + choices=[ + ("one", "One"), + ("two", "Two"), + ("three", "Three"), + ], description="SelectMultipleField rendered using a GovCheckboxesInput widget.", ) @@ -245,7 +271,11 @@ class KitchenSinkForm(FlaskForm): "RadioField", widget=GovRadioInput(), validators=[InputRequired(message="Please select an option")], - choices=[("one", "One"), ("two", "Two"), ("three", "Three")], + choices=[ + ("one", "One"), + ("two", "Two"), + ("three", "Three"), + ], description="RadioField rendered using a GovRadioInput widget.", ) @@ -268,10 +298,6 @@ class KitchenSinkForm(FlaskForm): widget=GovPasswordInput(), validators=[ InputRequired("Password is required"), - EqualTo( - "password_retype_field", - message="Please ensure both password fields match", - ), ], description="PasswordField rendered using a GovPasswordInput widget.", ) diff --git a/app/demos/routes.py b/app/demos/routes.py index cc4a67a..8ff30a6 100644 --- a/app/demos/routes.py +++ b/app/demos/routes.py @@ -10,7 +10,7 @@ @bp.route("/components", methods=["GET"]) def components(): - components = os.listdir("govuk_components") + components = os.listdir("app/demos/govuk_components") components.sort() return render_template("components.html", components=components) @@ -19,12 +19,16 @@ def components(): @bp.route("/components/", methods=["GET"]) def component(component): try: - with open(f"govuk_components/{component}/{component}.yaml") as yaml_file: + with open(f"app/demos/govuk_components/{component}/{component}.yaml") as yaml_file: fixtures = yaml.safe_load(yaml_file) except FileNotFoundError: raise NotFound - return render_template("component.html", component=component, fixtures=fixtures) + return render_template( + "component.html", + component=component, + fixtures=fixtures, + ) @bp.route("/forms", methods=["GET"]) diff --git a/app/main/routes.py b/app/main/routes.py index 70bee6d..5aa173a 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -34,7 +34,12 @@ def cookies(): response = make_response(render_template("cookies.html", form=form)) # Set cookies policy for one year - response.set_cookie("cookies_policy", json.dumps(cookies_policy), max_age=31557600) + response.set_cookie( + "cookies_policy", + json.dumps(cookies_policy), + max_age=31557600, + secure=True, + ) return response elif request.method == "GET": if request.cookies.get("cookies_policy"): diff --git a/app/templates/base.html b/app/templates/base.html index 2a94022..071e1d3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,7 +13,7 @@ - + {% assets "css" %}{% endassets %} {% endblock %} @@ -33,7 +33,7 @@ {% endset %} {{ govukCookieBanner({ - 'ariaLabel': "Cookies on " + config['SERVICE_NAME'], + 'ariaLabel': "Cookies on " ~ config['SERVICE_NAME'], 'attributes': { 'id': "cookie-banner" }, @@ -42,7 +42,7 @@ 'attributes': { 'id': "default-message" }, - 'headingText': "Cookies on " + config['SERVICE_NAME'], + 'headingText': "Cookies on " ~ config['SERVICE_NAME'], 'html': html, 'actions': [ { @@ -161,9 +161,9 @@ {% endblock %} {% block bodyEnd %} - + {% assets "js" %}{% endassets %} diff --git a/app/templates/demos/component.html b/app/templates/demos/component.html index 61ca9b7..2e982b9 100644 --- a/app/templates/demos/component.html +++ b/app/templates/demos/component.html @@ -27,6 +27,7 @@ {%- from 'govuk_frontend_jinja/components/phase-banner/macro.html' import govukPhaseBanner -%} {%- from 'govuk_frontend_jinja/components/radios/macro.html' import govukRadios -%} {%- from 'govuk_frontend_jinja/components/select/macro.html' import govukSelect -%} +{%- from 'govuk_frontend_jinja/components/service-navigation/macro.html' import govukServiceNavigation -%} {%- from 'govuk_frontend_jinja/components/skip-link/macro.html' import govukSkipLink -%} {%- from 'govuk_frontend_jinja/components/summary-list/macro.html' import govukSummaryList -%} {%- from 'govuk_frontend_jinja/components/table/macro.html' import govukTable -%} @@ -65,7 +66,7 @@

{{component | replace("-", " ") | capitalize}}

Examples:

-
    +
      {% for fixture in fixtures.examples if not fixture.hidden %}
    • {{fixture.name | capitalize}} @@ -133,6 +134,8 @@

      {{fixture. {{ govukRadios(fixture.options)}} {% elif component == 'select' %} {{ govukSelect(fixture.options)}} + {% elif component == 'service-navigation' %} + {{ govukServiceNavigation(fixture.options)}} {% elif component == 'skip-link' %} {{ govukSkipLink(fixture.options)}} {% elif component == 'summary-list' %} diff --git a/app/templates/demos/components.html b/app/templates/demos/components.html index 9841936..8b8a8e0 100644 --- a/app/templates/demos/components.html +++ b/app/templates/demos/components.html @@ -23,7 +23,7 @@
      Demo

      Components

      -
        +
          {% for component in components %}
        • {{component | replace("-", " ") | capitalize}} diff --git a/app/templates/demos/forms.html b/app/templates/demos/forms.html index 29b1b98..62d4ec9 100644 --- a/app/templates/demos/forms.html +++ b/app/templates/demos/forms.html @@ -25,7 +25,7 @@ {{ super() }} Demo

          Forms

          -
            +
            • Autocomplete
            • Bank details
            • Conditional reveal
            • diff --git a/app/templates/main/index.html b/app/templates/main/index.html index 2fab0bf..dc6483c 100644 --- a/app/templates/main/index.html +++ b/app/templates/main/index.html @@ -14,7 +14,7 @@

              Hello, World!

              get a new project started quicker.

              It is also the reference implementation of two core packages:

              -
                +
                • GOV.UK Frontend Jinja which provides Jinja macros of GOV.UK components
                • GOV.UK Frontend WTForms @@ -26,7 +26,7 @@

                  Hello, World!

                  Features

                  A number of other packages are used to provide the features listed below with sensible and best-practice defaults:

                  -
                    +
                    • Asset management
                    • Cache busting
                    • Form generation and validation
                    • @@ -40,7 +40,7 @@

                      Features

                    Demos

                    -
                      + diff --git a/build.sh b/build.sh index a644e43..447e178 100755 --- a/build.sh +++ b/build.sh @@ -4,7 +4,7 @@ rm -rf app/static/images rm -rf app/static/govuk-frontend* # Get new release distribution assets and move to static directory -curl -L https://github.com/alphagov/govuk-frontend/releases/download/v5.4.0/release-v5.4.0.zip > govuk_frontend.zip +curl -L https://github.com/alphagov/govuk-frontend/releases/download/v5.6.0/release-v5.6.0.zip > govuk_frontend.zip unzip -o govuk_frontend.zip -d app/static mv app/static/assets/* app/static @@ -18,16 +18,16 @@ rm -rf govuk_frontend.zip ##################################################################### # Remove existing GOV.UK Frontend test fixtures -rm -rf govuk_components +rm -rf app/demos/govuk_components # Get new release source code and move to a directory -curl -L https://github.com/alphagov/govuk-frontend/archive/refs/tags/v5.4.0.zip > govuk_frontend_source.zip +curl -L https://github.com/alphagov/govuk-frontend/archive/refs/tags/v5.6.0.zip > govuk_frontend_source.zip unzip -o govuk_frontend_source.zip -d govuk_frontend_source -mkdir govuk_components -mv govuk_frontend_source/govuk-frontend-5.4.0/packages/govuk-frontend/src/govuk/components/** govuk_components +mkdir app/demos/govuk_components +mv govuk_frontend_source/govuk-frontend-5.6.0/packages/govuk-frontend/src/govuk/components/** app/demos/govuk_components # Remove all files apart from test fixtures -find govuk_components -type f ! -name '*.yaml' -delete +find app/demos/govuk_components -type f ! -name '*.yaml' -delete # Tidy up rm -rf govuk_frontend_source diff --git a/compose.yml b/compose.yml index 8e44d6c..1a7323f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,28 +1,36 @@ services: web: - container_name: govuk-frontend-flask build: . + command: gunicorn --bind 0.0.0.0:5000 -w 4 --access-logfile - --error-logfile - govuk-frontend-flask:app restart: always environment: - CONTACT_EMAIL=[contact email] - CONTACT_PHONE=[contact phone] - DEPARTMENT_NAME=[name of department] - DEPARTMENT_URL=[url of department] - - REDIS_URL=redis://cache:6379 + - REDIS_URL=redis://redis:6379 - SECRET_KEY=4f378500459bb58fecf903ea3c113069f11f150b33388f56fc89f7edce0e6a84 - SERVICE_NAME=[name of service] - SERVICE_PHASE=[phase] - SERVICE_URL=[url of service] - ports: - - "9876:9876" volumes: - - .:/home/containeruser + - static_volume:/home/appuser/app/static:rw + expose: + - 5000 depends_on: - - cache - cache: - container_name: redis - image: redis:7.0-alpine + - redis + redis: + image: redis:7-alpine restart: always + expose: + - 6379 + nginx: + build: ./nginx + volumes: + - static_volume:/home/appuser/app/static:ro ports: - - 6379:6379 - \ No newline at end of file + - 443:443 + depends_on: + - web +volumes: + static_volume: diff --git a/config.py b/config.py index 8d24d82..f2f5388 100644 --- a/config.py +++ b/config.py @@ -13,4 +13,5 @@ class Config(object): SERVICE_PHASE = os.environ.get("SERVICE_PHASE") SERVICE_URL = os.environ.get("SERVICE_URL") SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SECURE = True diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100755 index 93d14e4..0000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -openssl req -new -x509 -newkey rsa:4096 -nodes -out cert.pem -keyout key.pem -days 365 -subj "/C=GB/ST=Devon/L=Plymouth/O=HM Land Registry/OU=DDaT/CN=localhost" -exec gunicorn --reload --certfile cert.pem --keyfile key.pem -b :9876 --access-logfile - --error-logfile - govuk-frontend-flask:app \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..b7c491a --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:stable + +RUN rm /etc/nginx/conf.d/default.conf && \ + mkdir /etc/nginx/ssl && \ + openssl req -x509 -noenc -newkey rsa:2048 -keyout /etc/nginx/ssl/key.pem -out /etc/nginx/ssl/req.pem -days 90 -subj "/C=GB/ST=Devon/L=Plymouth/O=HM Land Registry/OU=Digital/CN=localhost" + +COPY nginx.conf /etc/nginx/conf.d \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..cb07a61 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,65 @@ +# generated 2024-09-03, Mozilla Guideline v5.7, nginx 1.26.2, OpenSSL 3.0.13, modern configuration, no OCSP +# https://ssl-config.mozilla.org/#server=nginx&version=1.26.2&config=modern&openssl=3.0.13&ocsp=false&guideline=5.7 +server { + listen 80 default_server; + listen [::]:80 default_server; + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + ssl_certificate /etc/nginx/ssl/req.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; + + # modern configuration + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; + + # add security headers + add_header Content-Security-Policy "script-src 'self' 'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw=' 'sha256-3t81BEe/IfrPieOkVojxAPxOujfIBkzGt+HP2GeblR4='; object-src 'none'; base-uri 'none';" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), speaker-selection=(), conversion-measurement=(), focus-without-user-activation=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), sync-script=(), trust-token-redemption=(), unload=(), window-placement=(), vertical-scroll=()" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-Xss-Protection "1; mode=block" always; + + # enable gzip compression + gzip on; + gzip_comp_level 6; + gzip_proxied any; + gzip_types application/javascript application/json application/xml font/otf font/ttf font/woff font/woff2 image/gif image/jpeg image/png image/svg+xml image/webp text/css text/csv text/javascript text/xml; + + location / { + # forward application requests to the gunicorn server + proxy_pass http://web:5000; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Real-IP $remote_addr; + } + + location /assets/ { + # serve static files directly, without forwarding to the application + alias /home/appuser/app/static/; + + sendfile on; + tcp_nopush on; + + # set far future expires header + expires 10y; + } +} \ No newline at end of file diff --git a/requirements.in b/requirements.in index 1a97612..52cdcd7 100644 --- a/requirements.in +++ b/requirements.in @@ -2,9 +2,7 @@ cssmin email_validator flask flask-assets -flask-compress flask-limiter[redis] -flask-talisman govuk-frontend-jinja govuk-frontend-wtf gunicorn diff --git a/requirements.txt b/requirements.txt index 84f6be1..5d1cfee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,49 +6,42 @@ # blinker==1.8.2 # via flask -brotli==1.1.0 - # via flask-compress click==8.1.7 # via flask cssmin==0.2.0 # via -r requirements.in -deepmerge==1.1.1 +deepmerge==2.0 # via govuk-frontend-wtf deprecated==1.2.14 # via limits dnspython==2.6.1 # via email-validator -email-validator==2.1.1 +email-validator==2.2.0 # via -r requirements.in flask==3.0.3 # via # -r requirements.in # flask-assets - # flask-compress # flask-limiter # flask-wtf # govuk-frontend-wtf flask-assets==2.1.0 # via -r requirements.in -flask-compress==1.15 - # via -r requirements.in -flask-limiter[redis]==3.7.0 - # via -r requirements.in -flask-talisman==1.1.0 +flask-limiter[redis]==3.8.0 # via -r requirements.in flask-wtf==1.2.1 # via govuk-frontend-wtf -govuk-frontend-jinja==3.1.0 +govuk-frontend-jinja==3.3.0 # via # -r requirements.in # govuk-frontend-wtf govuk-frontend-wtf==3.1.0 # via -r requirements.in -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements.in -idna==3.7 +idna==3.8 # via email-validator -importlib-resources==6.4.0 +importlib-resources==6.4.4 # via limits itsdangerous==2.2.0 # via @@ -61,7 +54,7 @@ jinja2==3.1.4 # govuk-frontend-wtf jsmin==3.0.1 # via -r requirements.in -limits[redis]==3.12.0 +limits[redis]==3.13.0 # via flask-limiter markdown-it-py==3.0.0 # via rich @@ -74,25 +67,25 @@ mdurl==0.1.2 # via markdown-it-py ordered-set==4.1.0 # via flask-limiter -packaging==24.0 +packaging==24.1 # via # gunicorn # limits pygments==2.18.0 # via rich -pyyaml==6.0.1 +pyyaml==6.0.2 # via -r requirements.in -redis==5.0.4 +redis==5.0.8 # via limits -rich==13.7.1 +rich==13.8.0 # via flask-limiter -typing-extensions==4.12.0 +typing-extensions==4.12.2 # via # flask-limiter # limits webassets==2.0 # via flask-assets -werkzeug==3.0.3 +werkzeug==3.0.4 # via flask wrapt==1.16.0 # via deprecated @@ -100,5 +93,3 @@ wtforms==3.1.2 # via # flask-wtf # govuk-frontend-wtf -zstandard==0.22.0 - # via flask-compress diff --git a/requirements_dev.in b/requirements_dev.in index a9fc1b4..70d88cb 100644 --- a/requirements_dev.in +++ b/requirements_dev.in @@ -2,8 +2,9 @@ bandit black flake8-bugbear isort +mypy pep8-naming +pip-audit pip-tools pur pytest-cov -safety diff --git a/requirements_dev.txt b/requirements_dev.txt index d91d780..362865a 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,22 +4,22 @@ # # pip-compile requirements_dev.in # -annotated-types==0.7.0 - # via pydantic -attrs==23.2.0 +attrs==24.2.0 # via flake8-bugbear -authlib==1.3.0 - # via safety -bandit==1.7.8 +bandit==1.7.9 # via -r requirements_dev.in -black==24.4.2 +black==24.8.0 # via -r requirements_dev.in +boolean-py==4.0 + # via license-expression build==1.2.1 # via pip-tools -certifi==2024.2.2 +cachecontrol[filecache]==0.14.0 + # via + # cachecontrol + # pip-audit +certifi==2024.8.30 # via requests -cffi==1.16.0 - # via cryptography charset-normalizer==3.3.2 # via requests click==8.1.7 @@ -27,124 +27,116 @@ click==8.1.7 # black # pip-tools # pur - # safety - # typer -coverage[toml]==7.5.3 +coverage[toml]==7.6.1 # via pytest-cov -cryptography==42.0.7 - # via authlib -dparse==0.6.4b0 - # via - # safety - # safety-schemas -flake8==7.0.0 +cyclonedx-python-lib==7.6.0 + # via pip-audit +defusedxml==0.7.1 + # via py-serializable +filelock==3.15.4 + # via cachecontrol +flake8==7.1.1 # via # flake8-bugbear # pep8-naming -flake8-bugbear==24.4.26 +flake8-bugbear==24.8.19 # via -r requirements_dev.in -idna==3.7 +html5lib==1.1 + # via pip-audit +idna==3.8 # via requests iniconfig==2.0.0 # via pytest isort==5.13.2 # via -r requirements_dev.in -jinja2==3.1.4 - # via safety +license-expression==30.3.1 + # via cyclonedx-python-lib markdown-it-py==3.0.0 # via rich -markupsafe==2.1.5 - # via jinja2 -marshmallow==3.21.2 - # via safety mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py +msgpack==1.0.8 + # via cachecontrol +mypy==1.11.2 + # via -r requirements_dev.in mypy-extensions==1.0.0 - # via black -packaging==24.0 + # via + # black + # mypy +packageurl-python==0.15.6 + # via cyclonedx-python-lib +packaging==24.1 # via # black # build - # dparse - # marshmallow + # pip-audit + # pip-requirements-parser # pytest - # safety - # safety-schemas pathspec==0.12.1 # via black -pbr==6.0.0 +pbr==6.1.0 # via stevedore pep8-naming==0.14.1 # via -r requirements_dev.in +pip-api==0.0.34 + # via pip-audit +pip-audit==2.7.3 + # via -r requirements_dev.in +pip-requirements-parser==32.0.1 + # via pip-audit pip-tools==7.4.1 # via -r requirements_dev.in platformdirs==4.2.2 # via black pluggy==1.5.0 # via pytest -pur==7.3.1 +pur==7.3.2 # via -r requirements_dev.in -pycodestyle==2.11.1 +py-serializable==1.1.0 + # via cyclonedx-python-lib +pycodestyle==2.12.1 # via flake8 -pycparser==2.22 - # via cffi -pydantic==2.7.2 - # via - # safety - # safety-schemas -pydantic-core==2.18.3 - # via pydantic pyflakes==3.2.0 # via flake8 pygments==2.18.0 # via rich +pyparsing==3.1.4 + # via pip-requirements-parser pyproject-hooks==1.1.0 # via # build # pip-tools -pytest==8.2.1 +pytest==8.3.2 # via pytest-cov pytest-cov==5.0.0 # via -r requirements_dev.in -pyyaml==6.0.1 +pyyaml==6.0.2 # via bandit requests==2.32.3 - # via safety -rich==13.7.1 # via - # bandit - # safety - # typer -ruamel-yaml==0.18.6 + # cachecontrol + # pip-audit +rich==13.8.0 # via - # safety - # safety-schemas -ruamel-yaml-clib==0.2.8 - # via ruamel-yaml -safety==3.2.0 - # via -r requirements_dev.in -safety-schemas==0.0.2 - # via safety -shellingham==1.5.4 - # via typer -stevedore==5.2.0 + # bandit + # pip-audit +six==1.16.0 + # via html5lib +sortedcontainers==2.4.0 + # via cyclonedx-python-lib +stevedore==5.3.0 # via bandit -typer==0.12.3 - # via safety -typing-extensions==4.12.0 - # via - # pydantic - # pydantic-core - # safety - # safety-schemas - # typer -urllib3==2.2.1 - # via - # requests - # safety -wheel==0.43.0 +toml==0.10.2 + # via pip-audit +typing-extensions==4.12.2 + # via mypy +urllib3==2.2.2 + # via requests +webencodings==0.5.1 + # via html5lib +wheel==0.44.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/runtime.txt b/runtime.txt index b884b0f..14a2d25 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.12.2 \ No newline at end of file +python-3.12.5 \ No newline at end of file