Coverage for agentos/tools/csp.py: 0%

131 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 13:14 +0800

1""" 

2CSP — Content Security Policy builder and validator. 

3 

4Supports: 

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 

16from __future__ import annotations 

17 

18import secrets 

19 

20# ============================================================================ 

21# Constants 

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

23 

24ALL_DIRECTIVES = frozenset( 

25 { 

26 "default-src", 

27 "script-src", 

28 "script-src-elem", 

29 "script-src-attr", 

30 "style-src", 

31 "style-src-elem", 

32 "style-src-attr", 

33 "img-src", 

34 "connect-src", 

35 "font-src", 

36 "object-src", 

37 "media-src", 

38 "frame-src", 

39 "frame-ancestors", 

40 "form-action", 

41 "base-uri", 

42 "report-uri", 

43 "report-to", 

44 "sandbox", 

45 "worker-src", 

46 "manifest-src", 

47 "prefetch-src", 

48 "navigate-to", 

49 "child-src", 

50 "fenced-frame-src", 

51 "upgrade-insecure-requests", 

52 "block-all-mixed-content", 

53 "require-trusted-types-for", 

54 } 

55) 

56 

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

58 

59KEYWORD_SOURCES = frozenset( 

60 { 

61 "'self'", 

62 "'none'", 

63 "'strict-dynamic'", 

64 "'unsafe-inline'", 

65 "'unsafe-eval'", 

66 "'unsafe-hashes'", 

67 "'unsafe-allow-redirects'", 

68 "'wasm-unsafe-eval'", 

69 } 

70) 

71 

72 

73# ============================================================================ 

74# Nonce 

75# ============================================================================ 

76 

77 

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

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

80 return secrets.token_urlsafe(length) 

81 

82 

83# ============================================================================ 

84# CSP 

85# ============================================================================ 

86 

87 

88class CSP: 

89 """Fluent Content Security Policy builder. 

90 

91 Usage: 

92 csp = (CSP() 

93 .default_src("'self'") 

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

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

96 .img_src("*") 

97 .upgrade_insecure_requests() 

98 ) 

99 

100 header = csp.to_header() 

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

102 """ 

103 

104 def __init__(self): 

105 self._directives: dict = {} 

106 

107 # ---------- Directive setters ---------- 

108 

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

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

111 

112 def script_src( 

113 self, *sources: str, nonce: str | None = None, hashes: list[str] | None = None 

114 ) -> CSP: 

115 if nonce: 

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

117 if hashes: 

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

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

120 

121 def script_src_elem(self, *sources: str, nonce: str | None = None) -> CSP: 

122 if nonce: 

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

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

125 

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

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

128 

129 def style_src(self, *sources: str, nonce: str | None = None) -> CSP: 

130 if nonce: 

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

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

133 

134 def style_src_elem(self, *sources: str, nonce: str | None = None) -> CSP: 

135 if nonce: 

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

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

138 

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

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

141 

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

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

144 

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

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

147 

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

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

150 

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

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

153 

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

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

156 

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

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

159 

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

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

162 

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

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

165 

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

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

168 

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

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

171 

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

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

174 

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

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

177 

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

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

180 

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

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

183 

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

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

186 self._directives["sandbox"] = value 

187 return self 

188 

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

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

191 

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

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

194 return self 

195 

196 # ---------- Flags ---------- 

197 

198 def upgrade_insecure_requests(self) -> CSP: 

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

200 return self 

201 

202 def block_all_mixed_content(self) -> CSP: 

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

204 return self 

205 

206 # ---------- Utility ---------- 

207 

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

209 existing = self._directives.get(directive) 

210 if isinstance(existing, list): 

211 existing.extend(sources) 

212 else: 

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

214 return self 

215 

216 def to_header(self) -> str: 

217 """Serialize to CSP header string.""" 

218 parts = [] 

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

220 if value == "": 

221 parts.append(directive) 

222 elif isinstance(value, list): 

223 sources_str = " ".join(value) 

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

225 else: 

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

227 return "; ".join(parts) 

228 

229 def to_dict(self) -> dict: 

230 return dict(self._directives) 

231 

232 @classmethod 

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

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

235 csp = cls() 

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

237 part = part.strip() 

238 if not part: 

239 continue 

240 tokens = part.split() 

241 directive = tokens[0] 

242 if directive in FLAG_DIRECTIVES: 

243 csp._directives[directive] = "" 

244 elif directive == "sandbox": 

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

246 elif directive == "report-to": 

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

248 else: 

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

250 return csp 

251 

252 @classmethod 

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

254 return name in ALL_DIRECTIVES 

255 

256 @classmethod 

257 def strict_policy(cls, nonce: str | None = None) -> CSP: 

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

259 csp = cls() 

260 csp.base_uri("'self'") 

261 csp.default_src("'self'") 

262 csp.object_src("'none'") 

263 if nonce: 

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

265 csp.style_src(nonce=nonce) 

266 else: 

267 csp.script_src("'self'") 

268 csp.style_src("'self'") 

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

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

271 csp.connect_src("'self'") 

272 csp.frame_ancestors("'none'") 

273 return csp