aurweb.asgi: add security headers middleware

This commit introduces a middleware function which adds
the following security headers to each response:

- Content-Security-Policy
    - This includes a new `nonce`, which is tied to a user
      via authentication middleware. Both an anonymous user
      and an authenticated user recieve their own random nonces.
- X-Content-Type-Options
- Referrer-Policy
- X-Frame-Options

They are then tested for existence in test/test_routes.py.

Note: The overcomplicated-looking asyncio behavior in the
middleware function is used to avoid a warning about the old
coroutine awaits being deprecated. See
https://docs.python.org/3/library/asyncio-task.html#asyncio.wait
for more detail.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-06-12 03:54:41 -07:00
parent 13456fea1e
commit 865c414504
6 changed files with 106 additions and 3 deletions

View file

@ -1,6 +1,8 @@
import asyncio
import http import http
import typing
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
@ -55,3 +57,42 @@ async def http_exception_handler(request, exc):
phrase = http.HTTPStatus(exc.status_code).phrase phrase = http.HTTPStatus(exc.status_code).phrase
return HTMLResponse(f"<h1>{exc.status_code} {phrase}</h1><p>{exc.detail}</p>", return HTMLResponse(f"<h1>{exc.status_code} {phrase}</h1><p>{exc.detail}</p>",
status_code=exc.status_code) status_code=exc.status_code)
@app.middleware("http")
async def add_security_headers(request: Request, call_next: typing.Callable):
""" This middleware adds the CSP, XCTO, XFO and RP security
headers to the HTTP response associated with request.
CSP: Content-Security-Policy
XCTO: X-Content-Type-Options
RP: Referrer-Policy
XFO: X-Frame-Options
"""
response = asyncio.create_task(call_next(request))
await asyncio.wait({response}, return_when=asyncio.FIRST_COMPLETED)
response = response.result()
# Add CSP header.
nonce = request.user.nonce
csp = "default-src 'self'; "
script_hosts = [
"ajax.googleapis.com",
"cdn.jsdelivr.net"
]
csp += f"script-src 'self' 'nonce-{nonce}' " + ' '.join(script_hosts)
response.headers["Content-Security-Policy"] = csp
# Add XTCO header.
xcto = "nosniff"
response.headers["X-Content-Type-Options"] = xcto
# Add Referrer Policy header.
rp = "same-origin"
response.headers["Referrer-Policy"] = rp
# Add X-Frame-Options header.
xfo = "SAMEORIGIN"
response.headers["X-Frame-Options"] = xfo
return response

View file

@ -10,7 +10,7 @@ from starlette.requests import HTTPConnection
import aurweb.config import aurweb.config
from aurweb import l10n from aurweb import l10n, util
from aurweb.models.session import Session from aurweb.models.session import Session
from aurweb.models.user import User from aurweb.models.user import User
from aurweb.templates import make_variable_context, render_template from aurweb.templates import make_variable_context, render_template
@ -25,6 +25,12 @@ class AnonymousUser:
# A stub ssh_pub_key relationship. # A stub ssh_pub_key relationship.
ssh_pub_key = None ssh_pub_key = None
# A nonce attribute, needed for all browser sessions; set in __init__.
nonce = None
def __init__(self):
self.nonce = util.make_nonce()
@staticmethod @staticmethod
def is_authenticated(): def is_authenticated():
return False return False
@ -55,7 +61,9 @@ class BasicAuthBackend(AuthenticationBackend):
# exists, due to ForeignKey constraints in the schema upheld # exists, due to ForeignKey constraints in the schema upheld
# by mysqlclient. # by mysqlclient.
user = session.query(User).filter(User.ID == record.UsersID).first() user = session.query(User).filter(User.ID == record.UsersID).first()
user.nonce = util.make_nonce()
user.authenticated = True user.authenticated = True
return AuthCredentials(["authenticated"]), user return AuthCredentials(["authenticated"]), user

View file

@ -37,6 +37,7 @@ class User(Base):
# High-level variables used to track authentication (not in DB). # High-level variables used to track authentication (not in DB).
authenticated = False authenticated = False
nonce = None
def __init__(self, Passwd: str = str(), **kwargs): def __init__(self, Passwd: str = str(), **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)

View file

@ -1,6 +1,8 @@
import base64 import base64
import math
import random import random
import re import re
import secrets
import string import string
from collections import OrderedDict from collections import OrderedDict
@ -20,6 +22,15 @@ def make_random_string(length):
string.digits, k=length)) string.digits, k=length))
def make_nonce(length: int = 8):
""" Generate a single random nonce. Here, token_hex generates a hex
string of 2 hex characters per byte, where the length give is
nbytes. This means that to get our proper string length, we need to
cut it in half and truncate off any remaining (in the case that
length was uneven). """
return secrets.token_hex(math.ceil(length / 2))[:length]
def valid_username(username): def valid_username(username):
min_len = aurweb.config.getint("options", "username_min_len") min_len = aurweb.config.getint("options", "username_min_len")
max_len = aurweb.config.getint("options", "username_max_len") max_len = aurweb.config.getint("options", "username_max_len")

View file

@ -1,6 +1,6 @@
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
<script type="text/javascript" src="/static/js/bootstrap-typeahead.min.js"></script> <script type="text/javascript" src="/static/js/bootstrap-typeahead.min.js"></script>
<script type="text/javascript"> <script type="text/javascript" nonce="{{ request.user.nonce }}">
$(document).ready(function() { $(document).ready(function() {
$('#pkgsearch-field').typeahead({ $('#pkgsearch-field').typeahead({
source: function(query, callback) { source: function(query, callback) {

View file

@ -1,7 +1,9 @@
import re
import urllib.parse import urllib.parse
from http import HTTPStatus from http import HTTPStatus
import lxml.etree
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -39,6 +41,24 @@ def test_index():
assert response.status_code == int(HTTPStatus.OK) assert response.status_code == int(HTTPStatus.OK)
def test_index_security_headers():
""" Check for the existence of CSP, XCTO, XFO and RP security headers.
CSP: Content-Security-Policy
XCTO: X-Content-Type-Options
RP: Referrer-Policy
XFO: X-Frame-Options
"""
# Use `with` to trigger FastAPI app events.
with client as req:
response = req.get("/")
assert response.status_code == int(HTTPStatus.OK)
assert response.headers.get("Content-Security-Policy") is not None
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.headers.get("Referrer-Policy") == "same-origin"
assert response.headers.get("X-Frame-Options") == "SAMEORIGIN"
def test_favicon(): def test_favicon():
""" Test the favicon route at '/favicon.ico'. """ """ Test the favicon route at '/favicon.ico'. """
response1 = client.get("/static/images/favicon.ico") response1 = client.get("/static/images/favicon.ico")
@ -106,3 +126,25 @@ def test_error_messages():
response2 = client.get("/raisefivethree") response2 = client.get("/raisefivethree")
assert response1.status_code == int(HTTPStatus.NOT_FOUND) assert response1.status_code == int(HTTPStatus.NOT_FOUND)
assert response2.status_code == int(HTTPStatus.SERVICE_UNAVAILABLE) assert response2.status_code == int(HTTPStatus.SERVICE_UNAVAILABLE)
def test_nonce_csp():
with client as request:
response = request.get("/")
data = response.headers.get("Content-Security-Policy")
nonce = next(field for field in data.split("; ") if "nonce" in field)
match = re.match(r"^script-src .*'nonce-([a-fA-F0-9]{8})' .*$", nonce)
nonce = match.group(1)
assert nonce is not None and len(nonce) == 8
parser = lxml.etree.HTMLParser(recover=True)
root = lxml.etree.fromstring(response.text, parser=parser)
nonce_verified = False
scripts = root.xpath("//script")
for script in scripts:
if script.text is not None:
assert "nonce" in script.keys()
if not (nonce_verified := (script.get("nonce") == nonce)):
break
assert nonce_verified is True