Coverage for src/lexigram/web/security/csp/builder.py: 65%
40 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Content Security Policy (CSP) builder with preset policies."""
3from __future__ import annotations
5from dataclasses import dataclass, field
8@dataclass
9class CSPPolicy:
10 """Content Security Policy configuration builder.
12 Builds the ``Content-Security-Policy`` header value from individual
13 directive lists. Each list may contain quoted keywords like ``'self'``,
14 ``'none'``, ``'unsafe-inline'``, as well as scheme and host sources.
16 Example::
18 policy = CSPPolicy.strict()
19 headers = {"Content-Security-Policy": policy.build()}
20 """
22 default_src: list[str] = field(default_factory=lambda: ["'self'"])
23 script_src: list[str] = field(default_factory=lambda: ["'self'"])
24 style_src: list[str] = field(default_factory=lambda: ["'self'"])
25 img_src: list[str] = field(default_factory=lambda: ["'self'", "data:"])
26 connect_src: list[str] = field(default_factory=lambda: ["'self'"])
27 font_src: list[str] = field(default_factory=lambda: ["'self'"])
28 object_src: list[str] = field(default_factory=lambda: ["'none'"])
29 frame_ancestors: list[str] = field(default_factory=lambda: ["'none'"])
30 base_uri: list[str] = field(default_factory=lambda: ["'self'"])
31 form_action: list[str] = field(default_factory=lambda: ["'self'"])
32 upgrade_insecure_requests: bool = False
33 report_uri: str | None = None
34 report_only: bool = False
36 def build(self, report_only: bool | None = None) -> str:
37 """Build and return the CSP header value string.
39 Args:
40 report_only: If True, returns the report-only header name.
41 If False, returns the enforce header name.
42 If None, uses self.report_only instance attribute.
43 """
44 use_report_only = report_only if report_only is not None else self.report_only # noqa: F841
45 parts: list[str] = []
46 for directive, sources in [
47 ("default-src", self.default_src),
48 ("script-src", self.script_src),
49 ("style-src", self.style_src),
50 ("img-src", self.img_src),
51 ("connect-src", self.connect_src),
52 ("font-src", self.font_src),
53 ("object-src", self.object_src),
54 ("frame-ancestors", self.frame_ancestors),
55 ("base-uri", self.base_uri),
56 ("form-action", self.form_action),
57 ]:
58 if sources:
59 parts.append(f"{directive} {' '.join(sources)}")
60 if self.upgrade_insecure_requests:
61 parts.append("upgrade-insecure-requests")
62 if self.report_uri:
63 parts.append(f"report-uri {self.report_uri}")
64 return "; ".join(parts)
66 def get_header_name(self) -> str:
67 """Return the appropriate CSP header name based on report_only setting.
69 Returns:
70 'Content-Security-Policy-Report-Only' if report_only is True,
71 otherwise 'Content-Security-Policy'.
72 """
73 return (
74 "Content-Security-Policy-Report-Only"
75 if self.report_only
76 else "Content-Security-Policy"
77 )
79 @classmethod
80 def strict(cls) -> CSPPolicy:
81 """Strict preset — blocks inline scripts, eval, and external fonts."""
82 return cls(
83 script_src=["'self'"],
84 style_src=["'self'"],
85 object_src=["'none'"],
86 frame_ancestors=["'none'"],
87 upgrade_insecure_requests=True,
88 )
90 @classmethod
91 def relaxed(cls) -> CSPPolicy:
92 """Relaxed preset — allows popular CDNs and Google Fonts."""
93 return cls(
94 script_src=["'self'", "https://cdn.jsdelivr.net"],
95 style_src=["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
96 font_src=["'self'", "https://fonts.gstatic.com"],
97 img_src=["'self'", "data:", "https:"],
98 )
100 @classmethod
101 def api_only(cls) -> CSPPolicy:
102 """API-only preset — blocks all document/resource loading."""
103 return cls(
104 default_src=["'none'"],
105 script_src=["'none'"],
106 style_src=["'none'"],
107 img_src=["'none'"],
108 connect_src=["'self'"],
109 font_src=["'none'"],
110 object_src=["'none'"],
111 frame_ancestors=["'none'"],
112 )
115__all__ = ["CSPPolicy"]