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 FrameOptionsAudit(Audit):
 13    """Frame options header checks."""
 14
 15    name = "Frame Options"
 16    slug = "frame-options"
 17    description = "Protects against clickjacking attacks by controlling whether the site can be framed. CSP frame-ancestors is the modern alternative. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options"
 18
 19    def check(self, scanner: Scanner) -> AuditResult:
 20        """Check if X-Frame-Options or CSP frame-ancestors is configured."""
 21        response = scanner.fetch()
 22
 23        # Check for X-Frame-Options header
 24        xfo_header = response.headers.get("X-Frame-Options")
 25
 26        # Check for CSP frame-ancestors as modern alternative
 27        csp_header = response.headers.get("Content-Security-Policy")
 28        frame_ancestors = []
 29        if csp_header:
 30            frame_ancestors = self._extract_frame_ancestors(csp_header)
 31        has_frame_ancestors = bool(frame_ancestors)
 32
 33        if not xfo_header and not has_frame_ancestors:
 34            # Neither protection detected
 35            return AuditResult(
 36                name=self.name,
 37                detected=False,
 38                required=self.required,
 39                checks=[],
 40                description=self.description,
 41            )
 42
 43        checks = []
 44
 45        # Check X-Frame-Options if present
 46        if xfo_header:
 47            checks.append(self._check_xfo_value(xfo_header))
 48
 49        if has_frame_ancestors:
 50            checks.append(self._check_frame_ancestors(frame_ancestors))
 51
 52        return AuditResult(
 53            name=self.name,
 54            detected=True,
 55            required=self.required,
 56            checks=checks,
 57            description=self.description,
 58        )
 59
 60    def _check_xfo_value(self, header: str) -> CheckResult:
 61        """Check if X-Frame-Options has a valid value."""
 62        header_value = header.strip().upper()
 63
 64        valid_values = ["DENY", "SAMEORIGIN"]
 65
 66        # ALLOW-FROM is deprecated but we'll acknowledge it
 67        if header_value in valid_values:
 68            return CheckResult(
 69                name="value",
 70                passed=True,
 71                message=f"X-Frame-Options is set to {header_value}",
 72            )
 73
 74        if header_value.startswith("ALLOW-FROM"):
 75            return CheckResult(
 76                name="value",
 77                passed=False,
 78                message="X-Frame-Options uses deprecated ALLOW-FROM syntax (use CSP frame-ancestors instead)",
 79            )
 80
 81        return CheckResult(
 82            name="value",
 83            passed=False,
 84            message=f"X-Frame-Options has invalid value: '{header}' (expected: DENY or SAMEORIGIN)",
 85        )
 86
 87    def _extract_frame_ancestors(self, csp_header: str) -> list[str]:
 88        """Extract frame-ancestors directive values from CSP."""
 89        for directive in csp_header.split(";"):
 90            directive = directive.strip()
 91            if not directive:
 92                continue
 93
 94            parts = directive.split()
 95            if not parts:
 96                continue
 97
 98            if parts[0].lower() == "frame-ancestors":
 99                return parts[1:]
100
101        return []
102
103    def _check_frame_ancestors(self, values: list[str]) -> CheckResult:
104        """Validate frame-ancestors directive restricts embedding safely."""
105        normalized = [value.strip() for value in values if value.strip()]
106
107        if not normalized:
108            return CheckResult(
109                name="csp-frame-ancestors",
110                passed=False,
111                message="frame-ancestors directive is present but empty",
112            )
113
114        violations: list[str] = []
115        saw_none = False
116        for value in normalized:
117            original = value.strip()
118            original_lower = original.lower()
119            stripped = original.strip('"').strip("'")
120            lower = stripped.lower()
121
122            if stripped == "*":
123                violations.append("allows any origin (*)")
124                continue
125
126            if lower == "none":
127                saw_none = True
128                continue
129
130            if lower == "self":
131                continue
132
133            if lower.startswith("http:"):
134                violations.append(f"allows insecure origin {stripped}")
135                continue
136
137            if lower.startswith("https://"):
138                continue
139
140            if lower.startswith("https:"):
141                # scheme-only https: still allows all HTTPS origins
142                violations.append(
143                    "uses scheme-only https: fallback (overly permissive)"
144                )
145                continue
146
147            if original_lower.startswith("'nonce-") or original_lower.startswith(
148                "'sha"
149            ):
150                violations.append(
151                    f"contains unsupported token for frame-ancestors {stripped}"
152                )
153                continue
154
155            if original_lower.startswith("'"):
156                violations.append(f"contains unsupported keyword {stripped}")
157                continue
158
159            violations.append(f"contains unrecognized token {stripped}")
160
161        if saw_none and len(normalized) > 1:
162            violations.append("'none' must not be combined with other sources")
163
164        if violations:
165            return CheckResult(
166                name="csp-frame-ancestors",
167                passed=False,
168                message="frame-ancestors is too permissive: " + "; ".join(violations),
169            )
170
171        return CheckResult(
172            name="csp-frame-ancestors",
173            passed=True,
174            message="frame-ancestors restricts embedding to trusted origins",
175        )