Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3from typing import TYPE_CHECKING
  4
  5from ..results import AuditResult, CheckResult
  6from .base import Audit
  7
  8if TYPE_CHECKING:
  9    from ..scanner import Scanner
 10
 11
 12class HSTSAudit(Audit):
 13    """HTTP Strict Transport Security checks."""
 14
 15    name = "HTTP Strict Transport Security (HSTS)"
 16    slug = "hsts"
 17    description = "Ensures HSTS is configured to force HTTPS connections and protect against downgrade attacks. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security"
 18
 19    def check(self, scanner: Scanner) -> AuditResult:
 20        """Check if HSTS is present and configured properly."""
 21        response = scanner.fetch()
 22
 23        # Check if HSTS header is present
 24        hsts_header = response.headers.get("Strict-Transport-Security")
 25
 26        if not hsts_header:
 27            # HSTS header not detected
 28            return AuditResult(
 29                name=self.name,
 30                detected=False,
 31                required=self.required,
 32                checks=[],
 33                description=self.description,
 34            )
 35
 36        directives = self._parse_directives(hsts_header)
 37
 38        # HSTS header detected - run nested checks
 39        checks = [
 40            self._check_max_age(directives),
 41            self._check_include_subdomains(directives),
 42            self._check_preload(directives),
 43        ]
 44
 45        return AuditResult(
 46            name=self.name,
 47            detected=True,
 48            required=self.required,
 49            checks=checks,
 50            description=self.description,
 51        )
 52
 53    def _check_max_age(self, directives: dict[str, str | None]) -> CheckResult:
 54        """Check if HSTS max-age is set to a reasonable value."""
 55        # Parse max-age from header
 56        max_age = None
 57        value = directives.get("max-age")
 58        if value is not None:
 59            try:
 60                max_age = int(value)
 61            except ValueError:
 62                max_age = None
 63
 64        if max_age is None:
 65            return CheckResult(
 66                name="max-age",
 67                passed=False,
 68                message="HSTS header missing max-age directive",
 69            )
 70
 71        # Recommended minimum is 1 year (31536000 seconds)
 72        min_recommended = 31536000
 73        if max_age < min_recommended:
 74            return CheckResult(
 75                name="max-age",
 76                passed=False,
 77                message=f"HSTS max-age is {max_age} seconds (recommended minimum: {min_recommended})",
 78            )
 79
 80        return CheckResult(
 81            name="max-age",
 82            passed=True,
 83            message=f"HSTS max-age is {max_age} seconds",
 84        )
 85
 86    def _check_include_subdomains(
 87        self, directives: dict[str, str | None]
 88    ) -> CheckResult:
 89        """Ensure includeSubDomains directive is present."""
 90        if "includesubdomains" in directives:
 91            return CheckResult(
 92                name="include-subdomains",
 93                passed=True,
 94                message="HSTS applies to all subdomains",
 95            )
 96
 97        return CheckResult(
 98            name="include-subdomains",
 99            passed=False,
100            message="HSTS missing includeSubDomains directive",
101        )
102
103    def _check_preload(self, directives: dict[str, str | None]) -> CheckResult:
104        """Check for preload directive to enable HSTS preload list eligibility."""
105        if "preload" in directives:
106            return CheckResult(
107                name="preload",
108                passed=True,
109                message="HSTS preload directive present",
110            )
111
112        return CheckResult(
113            name="preload",
114            passed=False,
115            message="HSTS missing preload directive (required for browser preload list)",
116        )
117
118    def _parse_directives(self, header: str) -> dict[str, str | None]:
119        """Parse HSTS header directives into a dictionary."""
120        directives: dict[str, str | None] = {}
121        for directive in header.split(";"):
122            directive = directive.strip()
123            if not directive:
124                continue
125
126            if "=" in directive:
127                key, value = directive.split("=", 1)
128                directives[key.strip().lower()] = value.strip()
129            else:
130                directives[directive.strip().lower()] = None
131
132        return directives