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 CookiesAudit(Audit):
 13    """Cookie security checks."""
 14
 15    name = "Cookies"
 16    slug = "cookies"
 17    required = False  # Only relevant when cookies are actually issued
 18    description = "Validates cookie security attributes including Secure, HttpOnly, and SameSite to protect against XSS and CSRF attacks. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies"
 19
 20    def check(self, scanner: Scanner) -> AuditResult:
 21        """Check if cookies are configured securely."""
 22        response = scanner.fetch()
 23
 24        # Get all Set-Cookie headers
 25        # Note: requests library stores these in response.headers but only returns the last one
 26        # We need to use response.raw to get all of them
 27        cookies = []
 28
 29        # Try to get cookies from response.cookies (CookieJar)
 30        if response.cookies:
 31            for cookie in response.cookies:
 32                # SameSite can be in _rest as either "SameSite" or "samesite" (case-insensitive)
 33                samesite = None
 34                if hasattr(cookie, "_rest") and cookie._rest:
 35                    for key in cookie._rest:
 36                        if key.lower() == "samesite":
 37                            samesite = cookie._rest[key]
 38                            break
 39
 40                cookies.append(
 41                    {
 42                        "name": cookie.name,
 43                        "secure": cookie.secure,
 44                        "httponly": hasattr(cookie, "_rest")
 45                        and "HttpOnly" in cookie._rest,
 46                        "samesite": samesite,
 47                    }
 48                )
 49
 50        if not cookies:
 51            # No cookies detected
 52            return AuditResult(
 53                name=self.name,
 54                detected=False,
 55                required=self.required,
 56                checks=[],
 57                description=self.description,
 58            )
 59
 60        # Cookies detected - run security checks
 61        checks = [
 62            self._check_secure_flag(cookies),
 63            self._check_httponly_flag(cookies),
 64            self._check_samesite_attribute(cookies),
 65        ]
 66
 67        return AuditResult(
 68            name=self.name,
 69            detected=True,
 70            required=self.required,
 71            checks=checks,
 72            description=self.description,
 73        )
 74
 75    def _check_secure_flag(self, cookies: list[dict]) -> CheckResult:
 76        """Check if cookies have Secure flag."""
 77        insecure_cookies = [c["name"] for c in cookies if not c["secure"]]
 78
 79        if insecure_cookies:
 80            return CheckResult(
 81                name="secure",
 82                passed=False,
 83                message=f"Cookies missing Secure flag: {', '.join(insecure_cookies)}",
 84            )
 85
 86        return CheckResult(
 87            name="secure",
 88            passed=True,
 89            message=f"All {len(cookies)} cookie(s) have Secure flag",
 90        )
 91
 92    def _is_session_cookie(self, cookie_name: str) -> bool:
 93        """
 94        Determine if a cookie is a session cookie based on name patterns.
 95        Uses Mozilla Observatory's heuristic: cookies with 'login' or 'sess' in the name.
 96        """
 97        name_lower = cookie_name.lower()
 98        return any(pattern in name_lower for pattern in ("login", "sess"))
 99
100    def _is_anticsrf_cookie(self, cookie_name: str) -> bool:
101        """
102        Determine if a cookie is an anti-CSRF token.
103        Anti-CSRF tokens need SameSite but should NOT have HttpOnly (JavaScript needs to read them).
104        """
105        return "csrf" in cookie_name.lower()
106
107    def _check_httponly_flag(self, cookies: list[dict]) -> CheckResult:
108        """
109        Check if session cookies have HttpOnly flag.
110        Only session cookies (auth-related) strictly require HttpOnly.
111        Other cookies are checked but don't fail the test.
112        """
113        session_cookies = [c for c in cookies if self._is_session_cookie(c["name"])]
114        other_cookies = [c for c in cookies if not self._is_session_cookie(c["name"])]
115
116        # Session cookies missing HttpOnly is a failure
117        session_missing_httponly = [
118            c["name"] for c in session_cookies if not c["httponly"]
119        ]
120
121        # Other cookies missing HttpOnly is noted but not a failure
122        other_missing_httponly = [c["name"] for c in other_cookies if not c["httponly"]]
123
124        if session_missing_httponly:
125            return CheckResult(
126                name="httponly",
127                passed=False,
128                message=f"Session cookies missing HttpOnly flag: {', '.join(session_missing_httponly)}",
129            )
130
131        # All session cookies have HttpOnly - that's a pass
132        if session_cookies and other_missing_httponly:
133            # Note other cookies without HttpOnly but don't fail
134            return CheckResult(
135                name="httponly",
136                passed=True,
137                message=f"All {len(session_cookies)} session cookie(s) have HttpOnly flag ({len(other_missing_httponly)} non-session cookie(s) missing HttpOnly: {', '.join(other_missing_httponly)})",
138            )
139        elif session_cookies:
140            return CheckResult(
141                name="httponly",
142                passed=True,
143                message=f"All {len(session_cookies)} session cookie(s) have HttpOnly flag",
144            )
145        elif other_missing_httponly:
146            # No session cookies, but other cookies missing HttpOnly
147            return CheckResult(
148                name="httponly",
149                passed=True,
150                message=f"No session cookies detected ({len(other_missing_httponly)} non-session cookie(s) missing HttpOnly: {', '.join(other_missing_httponly)})",
151            )
152        else:
153            # All cookies have HttpOnly
154            return CheckResult(
155                name="httponly",
156                passed=True,
157                message=f"All {len(cookies)} cookie(s) have HttpOnly flag",
158            )
159
160    def _check_samesite_attribute(self, cookies: list[dict]) -> CheckResult:
161        """
162        Check if cookies have SameSite attribute.
163        Anti-CSRF tokens REQUIRE SameSite (failure if missing).
164        Other cookies are recommended to have SameSite but it's not required (passes with note).
165        """
166        anticsrf_cookies = [c for c in cookies if self._is_anticsrf_cookie(c["name"])]
167        other_cookies = [c for c in cookies if not self._is_anticsrf_cookie(c["name"])]
168
169        # Anti-CSRF tokens missing SameSite is a failure
170        anticsrf_missing_samesite = [
171            c["name"] for c in anticsrf_cookies if not c["samesite"]
172        ]
173
174        if anticsrf_missing_samesite:
175            return CheckResult(
176                name="samesite",
177                passed=False,
178                message=f"Anti-CSRF cookies missing SameSite attribute: {', '.join(anticsrf_missing_samesite)}",
179            )
180
181        # Check for problematic SameSite=None without Secure
182        problematic = [
183            c["name"]
184            for c in cookies
185            if c["samesite"] and c["samesite"].lower() == "none" and not c["secure"]
186        ]
187
188        if problematic:
189            return CheckResult(
190                name="samesite",
191                passed=False,
192                message=f"Cookies with SameSite=None must have Secure flag: {', '.join(problematic)}",
193            )
194
195        # Check if we have SameSite on all cookies (bonus points, not required)
196        other_missing_samesite = [c["name"] for c in other_cookies if not c["samesite"]]
197
198        if not other_missing_samesite:
199            # All cookies have SameSite - excellent!
200            return CheckResult(
201                name="samesite",
202                passed=True,
203                message=f"All {len(cookies)} cookie(s) have SameSite attribute",
204            )
205        else:
206            # Some cookies missing SameSite - still passes but note it
207            return CheckResult(
208                name="samesite",
209                passed=True,
210                message=f"SameSite set on {len(cookies) - len(other_missing_samesite)}/{len(cookies)} cookie(s) (recommended: {', '.join(other_missing_samesite)})",
211            )