From 996e3779c9f6db8e673991b5a78029b16485aad4 Mon Sep 17 00:00:00 2001
From: Philippe Parage <69145356+pparage@users.noreply.github.com>
Date: Mon, 14 Sep 2026 13:11:39 +0200
Subject: [PATCH] fix: handle web-test errors and normalize domain inputs
---
.github/workflows/django.yml | 2 +-
testing/forms.py | 42 +++++-
testing/helpers.py | 45 ++++--
testing/templates/check_webapp.html | 5 +-
testing/test_web_checks.py | 216 ++++++++++++++++++++++++++++
testing/views.py | 10 +-
6 files changed, 304 insertions(+), 16 deletions(-)
create mode 100644 testing/test_web_checks.py
diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml
index 7d4ee1ef..ddfe25f2 100644
--- a/.github/workflows/django.yml
+++ b/.github/workflows/django.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
- python-version: [3.9, 3.11.4]
+ python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v3
diff --git a/testing/forms.py b/testing/forms.py
index b373f400..13728f04 100644
--- a/testing/forms.py
+++ b/testing/forms.py
@@ -1,6 +1,46 @@
+from urllib.parse import urlsplit
+
+import idna
from django import forms
+from django.core.exceptions import ValidationError
+from django.core.validators import DomainNameValidator
+
+from .models import DMARCRecord, MailDomain, TestReport
+
+
+class WebTestForm(forms.Form):
+ invalid_target = (
+ "Enter a valid domain such as test-domain.lu or www.test-domain.lu, "
+ "or an HTTP(S) website URL without credentials or a port number."
+ )
+ target = forms.CharField(
+ max_length=2048,
+ error_messages={"required": invalid_target, "max_length": invalid_target},
+ )
-from .models import DMARCRecord, MailDomain
+ def clean_target(self):
+ value = self.cleaned_data["target"]
+ try:
+ if "\\" in value or any(character.isspace() for character in value):
+ raise ValueError("Invalid whitespace or backslash")
+ parsed = urlsplit(value if "://" in value else f"https://{value}")
+ if (
+ parsed.scheme not in ("http", "https")
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.port is not None
+ or not parsed.hostname
+ ):
+ raise ValueError("Expected a website hostname")
+ DomainNameValidator()(parsed.hostname)
+ domain = idna.encode(parsed.hostname.removesuffix("."), uts46=True).decode(
+ "ascii"
+ )
+ if len(domain) > TestReport._meta.get_field("tested_site").max_length:
+ raise ValueError("Domain is too long for a saved report")
+ return domain
+ except (ValidationError, ValueError, UnicodeError):
+ raise forms.ValidationError(self.invalid_target) from None
class DMARCRecordForm(forms.ModelForm):
diff --git a/testing/helpers.py b/testing/helpers.py
index 19d11a91..afcf982e 100644
--- a/testing/helpers.py
+++ b/testing/helpers.py
@@ -931,8 +931,12 @@ def analyze_csp(csp, result, header_name):
def parse_csp(csp):
- return dict(
- directive.split(None, 1) for directive in csp.split(';') if directive.strip())
+ directives = {}
+ for directive in csp.split(';'):
+ parts = directive.split(None, 1)
+ if parts:
+ directives.setdefault(parts[0].lower(), parts[1] if len(parts) > 1 else '')
+ return directives
def check_unsafe_directives(directives, result, header_name):
@@ -967,7 +971,9 @@ def check_overly_permissive_directives(directives, result, header_name):
def check_csp_syntax(csp, result, header_name):
- if not re.match(r'^[a-zA-Z0-9\-]+\s+[^;]+(?:;\s*[a-zA-Z0-9\-]+\s+[^;]+)*$', csp):
+ directives = (part.strip() for part in csp.split(';') if part.strip())
+ if any(not re.fullmatch(r'[a-zA-Z0-9-]+(?:[ \t]+[^\r\n;,]*)?', part)
+ for part in directives):
result['issues'].append(f"{header_name}: CSP syntax appears to be invalid.")
result['recommendations'].append("Review and correct the CSP syntax.")
@@ -1022,7 +1028,7 @@ def check_cookies(domain: str) -> Dict[str, Any]:
'cookies': cookie_details,
'message': message
}
- except RequestException as e:
+ except requests.RequestException as e:
return {
'status': False,
'cookies': [],
@@ -1322,7 +1328,18 @@ def check_hsts(domain: str) -> Dict[str, Union[bool, str, Dict[str, Union[str, b
response = requests.get(url, timeout=10)
response.raise_for_status()
hsts_header = response.headers.get('strict-transport-security')
- parsed_hsts = parse_hsts_header(hsts_header) if hsts_header else {}
+ try:
+ parsed_hsts = parse_hsts_header(hsts_header) if hsts_header else {}
+ except ValueError as e:
+ return {
+ 'status': False,
+ 'data': f'Invalid HSTS header: {e}',
+ 'parsed': {},
+ 'http_status': response.status_code,
+ 'preload_ready': False,
+ 'strength': 'Invalid',
+ 'recommendations': ['Provide one non-negative integer max-age value.']
+ }
preload_ready = parsed_hsts.get('preload', False)
strength, recommendations = evaluate_hsts_strength(parsed_hsts)
@@ -1380,13 +1397,21 @@ def parse_hsts_header(header: str) -> Dict[str, Union[str, bool, int]]:
components = header.split(';')
parsed = {}
for component in components:
- component = component.strip().lower()
- if component.startswith('max-age='):
- parsed['max-age'] = int(component.split('=')[1])
- elif component == 'includesubdomains':
+ name, separator, value = component.partition('=')
+ name = name.strip().lower()
+ if name == 'max-age':
+ value = value.strip()
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
+ value = value[1:-1]
+ if not separator or not re.fullmatch(r'[0-9]+', value) or 'max-age' in parsed:
+ raise ValueError('max-age must appear once and contain a non-negative integer.')
+ parsed['max-age'] = int(value)
+ elif name == 'includesubdomains' and not separator:
parsed['includeSubDomains'] = True
- elif component == 'preload':
+ elif name == 'preload' and not separator:
parsed['preload'] = True
+ if 'max-age' not in parsed:
+ raise ValueError('The required max-age directive is missing.')
return parsed
diff --git a/testing/templates/check_webapp.html b/testing/templates/check_webapp.html
index 0634ff79..699b85c0 100644
--- a/testing/templates/check_webapp.html
+++ b/testing/templates/check_webapp.html
@@ -31,9 +31,10 @@
Assesses web security, includin
{{ error }}
{% endif %}
-
+
+ value="{% firstof target domain %}" maxlength="2048" required>
+ For example, test-domain.lu or https://www.test-domain.lu/. The tests check the hostname's homepage.