1"""

2 CSP — Content Security Policy builder and validator.

3

4 Supports:

5 - Fluent API for building CSP headers

6 - Source lists (self, none, urls, hashes, nonces, strict-dynamic)

7 - Directives: default-src, script-src, style-src, img-src, connect-src, font-src,

8 object-src, media-src, frame-src, frame-ancestors, form-action, base-uri,

9 report-uri, report-to, upgrade-insecure-requests, block-all-mixed-content,

10 sandbox, worker-src, manifest-src, prefetch-src, navigate-to

11 - Nonce generation

12 - Validation of directives

13 - Serialize to header string

14"""

15

16 from __future__ import annotations

17

18 import secrets

19 from typing import List, Optional, Set, Union

20

21

22# ============================================================================

23# Constants

24# ============================================================================

25

26 ALL_DIRECTIVES = frozenset({

27 "default-src", "script-src", "script-src-elem", "script-src-attr",

28 "style-src", "style-src-elem", "style-src-attr",

29 "img-src", "connect-src", "font-src", "object-src", "media-src",

30 "frame-src", "frame-ancestors", "form-action", "base-uri",

31 "report-uri", "report-to", "sandbox",

32 "worker-src", "manifest-src", "prefetch-src", "navigate-to",

33 "child-src", "fenced-frame-src",

34 "upgrade-insecure-requests", "block-all-mixed-content",

35 "require-trusted-types-for",

36})

37

38 FLAG_DIRECTIVES = frozenset({"upgrade-insecure-requests", "block-all-mixed-content"})

39

40 KEYWORD_SOURCES = frozenset({

41 "'self'", "'none'", "'strict-dynamic'",

42 "'unsafe-inline'", "'unsafe-eval'",

43 "'unsafe-hashes'", "'unsafe-allow-redirects'",

44 "'wasm-unsafe-eval'",

45})

46

47

48# ============================================================================

49# Nonce

50# ============================================================================

51

52 def generate_nonce(length: int = 32) -> str:

53 """Generate a cryptographically random base64 nonce."""

54 return secrets.token_urlsafe(length)

55

56

57# ============================================================================

58# CSP

59# ============================================================================

60

61 class CSP:

62 """Fluent Content Security Policy builder.

63

64 Usage:

65 csp = (CSP()

66 .default_src("'self'")

67 .script_src("'self'", "'strict-dynamic'", nonce=generate_nonce())

68 .style_src("'self'", "https://fonts.googleapis.com")

69 .img_src("*")

70 .upgrade_insecure_requests()

71 )

72

73 header = csp.to_header()

74 # default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-abc123'; ...

75 """

76

77 def __init__(self):

78 self._directives: dict = {}

79

80 # ---------- Directive setters ----------

81

82 def default_src(self, *sources: str) -> CSP:

83 return self._set("default-src", *sources)

84

85 def script_src(self, *sources: str, nonce: Optional[str] = None, hashes: Optional[List[str]] = None) -> CSP:

86 if nonce:

87 sources = sources + (f"'nonce-{nonce}'",)

88 if hashes:

89 sources = sources + tuple(h for h in hashes)

90 return self._set("script-src", *sources)

91

92 def script_src_elem(self, *sources: str, nonce: Optional[str] = None) -> CSP:

93 if nonce:

94 sources = sources + (f"'nonce-{nonce}'",)

95 return self._set("script-src-elem", *sources)

96

97 def script_src_attr(self, *sources: str) -> CSP:

98 return self._set("script-src-attr", *sources)

99

100 def style_src(self, *sources: str, nonce: Optional[str] = None) -> CSP:

101 if nonce:

102 sources = sources + (f"'nonce-{nonce}'",)

103 return self._set("style-src", *sources)

104

105 def style_src_elem(self, *sources: str, nonce: Optional[str] = None) -> CSP:

106 if nonce:

107 sources = sources + (f"'nonce-{nonce}'",)

108 return self._set("style-src-elem", *sources)

109

110 def style_src_attr(self, *sources: str) -> CSP:

111 return self._set("style-src-attr", *sources)

112

113 def img_src(self, *sources: str) -> CSP:

114 return self._set("img-src", *sources)

115

116 def connect_src(self, *sources: str) -> CSP:

117 return self._set("connect-src", *sources)

118

119 def font_src(self, *sources: str) -> CSP:

120 return self._set("font-src", *sources)

121

122 def object_src(self, *sources: str) -> CSP:

123 return self._set("object-src", *sources)

124

125 def media_src(self, *sources: str) -> CSP:

126 return self._set("media-src", *sources)

127

128 def frame_src(self, *sources: str) -> CSP:

129 return self._set("frame-src", *sources)

130

131 def frame_ancestors(self, *sources: str) -> CSP:

132 return self._set("frame-ancestors", *sources)

133

134 def form_action(self, *sources: str) -> CSP:

135 return self._set("form-action", *sources)

136

137 def base_uri(self, *sources: str) -> CSP:

138 return self._set("base-uri", *sources)

139

140 def worker_src(self, *sources: str) -> CSP:

141 return self._set("worker-src", *sources)

142

143 def child_src(self, *sources: str) -> CSP:

144 return self._set("child-src", *sources)

145

146 def manifest_src(self, *sources: str) -> CSP:

147 return self._set("manifest-src", *sources)

148

149 def prefetch_src(self, *sources: str) -> CSP:

150 return self._set("prefetch-src", *sources)

151

152 def navigate_to(self, *sources: str) -> CSP:

153 return self._set("navigate-to", *sources)

154

155 def sandbox(self, *flags: str) -> CSP:

156 value = " ".join(flags) if flags else ""

157 self._directives["sandbox"] = value

158 return self

159

160 def report_uri(self, *uris: str) -> CSP:

161 return self._set("report-uri", *uris)

162

163 def report_to(self, group: str) -> CSP:

164 self._directives["report-to"] = group

165 return self

166

167 # ---------- Flags ----------

168

169 def upgrade_insecure_requests(self) -> CSP:

170 self._directives["upgrade-insecure-requests"] = ""

171 return self

172

173 def block_all_mixed_content(self) -> CSP:

174 self._directives["block-all-mixed-content"] = ""

175 return self

176

177 # ---------- Utility ----------

178

179 def _set(self, directive: str, *sources: str) -> CSP:

180 existing = self._directives.get(directive)

181 if isinstance(existing, list):

182 existing.extend(sources)

183 else:

184 self._directives[directive] = list(sources)

185 return self

186

187 def to_header(self) -> str:

188 """Serialize to CSP header string."""

189 parts = []

190 for directive, value in self._directives.items():

191 if value == "":

192 parts.append(directive)

193 elif isinstance(value, list):

194 sources_str = " ".join(value)

195 parts.append(f"{directive} {sources_str}")

196 else:

197 parts.append(f"{directive} {value}")

198 return "; ".join(parts)

199

200 def to_dict(self) -> dict:

201 return dict(self._directives)

202

203 @classmethod

204 def parse(cls, header: str) -> CSP:

205 """Parse a CSP header string into a CSP builder."""

206 csp = cls()

207 for part in header.split(";"):

208 part = part.strip()

209 if not part:

210 continue

211 tokens = part.split()

212 directive = tokens[0]

213 if directive in FLAG_DIRECTIVES:

214 csp._directives[directive] = ""

215 elif directive == "sandbox":

216 csp._directives[directive] = " ".join(tokens[1:]) if len(tokens) > 1 else ""

217 elif directive == "report-to":

218 csp._directives[directive] = tokens[1] if len(tokens) > 1 else ""

219 else:

220 csp._directives[directive] = tokens[1:]

221 return csp

222

223 @classmethod

224 def is_valid_directive(cls, name: str) -> bool:

225 return name in ALL_DIRECTIVES

226

227 @classmethod

228 def strict_policy(cls, nonce: Optional[str] = None) -> CSP:

229 """Pre-built strict CSP with nonce or hash-based approach."""

230 csp = cls()

231 csp.base_uri("'self'")

232 csp.default_src("'self'")

233 csp.object_src("'none'")

234 if nonce:

235 csp.script_src("'strict-dynamic'", nonce=nonce)

236 csp.style_src(nonce=nonce)

237 else:

238 csp.script_src("'self'")

239 csp.style_src("'self'")

240 csp.img_src("*", "data:")

241 csp.font_src("'self'", "data:")

242 csp.connect_src("'self'")

243 csp.frame_ancestors("'none'")

244 return csp