Skip to content

Commit

Permalink
Merge pull request #135 from LandRegistry/nginx
Browse files Browse the repository at this point in the history
Use NGINX reverse proxy server
  • Loading branch information
matthew-shaw authored Sep 3, 2024
2 parents 1fa4820 + 8c2f0b0 commit 34e2df9
Show file tree
Hide file tree
Showing 24 changed files with 325 additions and 240 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 7 additions & 11 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
USER appuser
52 changes: 43 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.**

Expand Down Expand Up @@ -53,7 +53,7 @@ python -c 'import secrets; print(secrets.token_hex())'
docker compose up --build
```

You should now have the app running on <https://localhost:9876/>. Accept the browsers security warning due to the self-signed HTTPS certificate to continue.
You should now have the app running on <https://localhost/>. Accept the browsers security warning due to the self-signed HTTPS certificate to continue.

## Demos

Expand All @@ -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

Expand All @@ -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

Expand Down
81 changes: 16 additions & 65 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion app/demos/custom_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 39 additions & 13 deletions app/demos/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand All @@ -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",
)
Expand Down Expand Up @@ -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",
Expand All @@ -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",
)
Expand Down Expand Up @@ -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.",
)

Expand Down Expand Up @@ -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.",
)
Expand Down Expand Up @@ -237,15 +259,23 @@ 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.",
)

radio_field = RadioField(
"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.",
)

Expand All @@ -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.",
)
Expand Down
Loading

0 comments on commit 34e2df9

Please sign in to comment.