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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Content Security Policy configuration."""
3from __future__ import annotations
5from typing import Any, ClassVar
7from lexigram.config.base import BaseConfig
8from lexigram.validation import ConfigDict, Field, model_validator
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}
44class CSPConfig(BaseConfig):
45 """Content Security Policy configuration.
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`.
51 Attributes:
52 enabled: Emit the ``Content-Security-Policy`` header.
53 directives: Mapping of CSP directive name to source expression(s).
54 """
56 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
66 @model_validator(mode="after")
67 def _merge_default_directives(self) -> CSPConfig:
68 """Merge the framework default directives into the configured ones.
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).
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
86 def build_header(self) -> str:
87 """Build the ``Content-Security-Policy`` header value.
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)
106__all__ = [
107 "CSPConfig",
108]