Coverage for src/lexigram/web/security/config/csp.py: 48%

27 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Content Security Policy configuration.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any, ClassVar 

6 

7from lexigram.config.base import BaseConfig 

8from lexigram.validation import ConfigDict, Field, model_validator 

9 

10_DEFAULT_DIRECTIVES: dict[str, str] = { 

11 "default-src": "'self'", 

12 # Safe CDN hosts used by the framework's own templates (Swagger UI, 

13 # ReDoc, lexigram-admin, lexigram-ui): see UI_CSP_REQUIREMENTS and 

14 # APIDocsConfig.SWAGGER_DOMAINS / REDOC_DOMAINS. 

15 "script-src": ( 

16 "'self' 'unsafe-inline' 'unsafe-eval' " 

17 "https://unpkg.com https://cdn.jsdelivr.net " 

18 "https://cdn.redoc.ly https://cdn.plot.ly" 

19 ), 

20 "script-src-elem": ( 

21 "'self' 'unsafe-inline' 'unsafe-eval' " 

22 "https://unpkg.com https://cdn.jsdelivr.net " 

23 "https://cdn.redoc.ly https://cdn.plot.ly" 

24 ), 

25 "style-src": ( 

26 "'self' 'unsafe-inline' " 

27 "https://unpkg.com https://cdn.jsdelivr.net " 

28 "https://fonts.googleapis.com" 

29 ), 

30 "style-src-elem": ( 

31 "'self' 'unsafe-inline' " 

32 "https://unpkg.com https://cdn.jsdelivr.net " 

33 "https://fonts.googleapis.com" 

34 ), 

35 "img-src": "'self' data: https: blob:", 

36 "font-src": "'self' data: https://fonts.googleapis.com https://fonts.gstatic.com", 

37 "connect-src": "'self' https: wss: ws: https://unpkg.com", 

38 "frame-ancestors": "'none'", 

39 "base-uri": "'self'", 

40 "form-action": "'self'", 

41} 

42 

43 

44class CSPConfig(BaseConfig): 

45 """Content Security Policy configuration. 

46 

47 Manages CSP directives as a ``dict[str, str | set[str]]`` and 

48 serialises them to the ``Content-Security-Policy`` header value 

49 via :meth:`build_header`. 

50 

51 Attributes: 

52 enabled: Emit the ``Content-Security-Policy`` header. 

53 directives: Mapping of CSP directive name to source expression(s). 

54 """ 

55 

56 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

57 

58 enabled: bool = Field( 

59 default=True, description="Emit the Content-Security-Policy header" 

60 ) 

61 directives: dict[str, Any] = Field( 

62 default_factory=lambda: dict(_DEFAULT_DIRECTIVES), 

63 description="CSP directives mapping directive name to source expression(s)", 

64 ) 

65 

66 @model_validator(mode="after") 

67 def _merge_default_directives(self) -> CSPConfig: 

68 """Merge the framework default directives into the configured ones. 

69 

70 User-supplied directives always win per-key; any directive the user 

71 omitted falls back to the safe framework default. This prevents a 

72 partial ``directives`` dict from silently dropping defaults — e.g. a 

73 user configuring only ``style-src`` previously lost the default 

74 ``style-src-elem`` with ``'unsafe-inline'``, which then blocked all 

75 inline styles (the CSP fallback chain does not apply when the 

76 ``-elem`` variant is present). 

77 

78 Returns: 

79 Self with merged directives. 

80 """ 

81 merged = dict(_DEFAULT_DIRECTIVES) 

82 merged.update(self.directives) 

83 self.directives = merged 

84 return self 

85 

86 def build_header(self) -> str: 

87 """Build the ``Content-Security-Policy`` header value. 

88 

89 Returns: 

90 Semicolon-delimited CSP policy string ready for the response header. 

91 """ 

92 parts: list[str] = [] 

93 for directive, value in self.directives.items(): 

94 if isinstance(value, set): 

95 csp_value = " ".join(str(v) for v in value) if value else "'none'" 

96 parts.append(f"{directive} {csp_value}") 

97 else: 

98 str_value = str(value) 

99 if str_value: 

100 parts.append(f"{directive} {str_value}") 

101 else: 

102 parts.append(directive) 

103 return "; ".join(parts) 

104 

105 

106__all__ = [ 

107 "CSPConfig", 

108]