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

1"""Content Security Policy (CSP) builder with preset policies.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6 

7 

8@dataclass 

9class CSPPolicy: 

10 """Content Security Policy configuration builder. 

11 

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. 

15 

16 Example:: 

17 

18 policy = CSPPolicy.strict() 

19 headers = {"Content-Security-Policy": policy.build()} 

20 """ 

21 

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 

35 

36 def build(self, report_only: bool | None = None) -> str: 

37 """Build and return the CSP header value string. 

38 

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) 

65 

66 def get_header_name(self) -> str: 

67 """Return the appropriate CSP header name based on report_only setting. 

68 

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 ) 

78 

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 ) 

89 

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 ) 

99 

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 ) 

113 

114 

115__all__ = ["CSPPolicy"]