Coverage for agentos/tools/csp.py: 0%
131 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:19 +0800
1"""
2CSP — Content Security Policy builder and validator.
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"""
16from __future__ import annotations
18import secrets
20# ============================================================================
21# Constants
22# ============================================================================
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)
57FLAG_DIRECTIVES = frozenset({"upgrade-insecure-requests", "block-all-mixed-content"})
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)
73# ============================================================================
74# Nonce
75# ============================================================================
78def generate_nonce(length: int = 32) -> str:
79 """Generate a cryptographically random base64 nonce."""
80 return secrets.token_urlsafe(length)
83# ============================================================================
84# CSP
85# ============================================================================
88class CSP:
89 """Fluent Content Security Policy builder.
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 )
100 header = csp.to_header()
101 # default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-abc123'; ...
102 """
104 def __init__(self):
105 self._directives: dict = {}
107 # ---------- Directive setters ----------
109 def default_src(self, *sources: str) -> CSP:
110 return self._set("default-src", *sources)
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)
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)
126 def script_src_attr(self, *sources: str) -> CSP:
127 return self._set("script-src-attr", *sources)
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)
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)
139 def style_src_attr(self, *sources: str) -> CSP:
140 return self._set("style-src-attr", *sources)
142 def img_src(self, *sources: str) -> CSP:
143 return self._set("img-src", *sources)
145 def connect_src(self, *sources: str) -> CSP:
146 return self._set("connect-src", *sources)
148 def font_src(self, *sources: str) -> CSP:
149 return self._set("font-src", *sources)
151 def object_src(self, *sources: str) -> CSP:
152 return self._set("object-src", *sources)
154 def media_src(self, *sources: str) -> CSP:
155 return self._set("media-src", *sources)
157 def frame_src(self, *sources: str) -> CSP:
158 return self._set("frame-src", *sources)
160 def frame_ancestors(self, *sources: str) -> CSP:
161 return self._set("frame-ancestors", *sources)
163 def form_action(self, *sources: str) -> CSP:
164 return self._set("form-action", *sources)
166 def base_uri(self, *sources: str) -> CSP:
167 return self._set("base-uri", *sources)
169 def worker_src(self, *sources: str) -> CSP:
170 return self._set("worker-src", *sources)
172 def child_src(self, *sources: str) -> CSP:
173 return self._set("child-src", *sources)
175 def manifest_src(self, *sources: str) -> CSP:
176 return self._set("manifest-src", *sources)
178 def prefetch_src(self, *sources: str) -> CSP:
179 return self._set("prefetch-src", *sources)
181 def navigate_to(self, *sources: str) -> CSP:
182 return self._set("navigate-to", *sources)
184 def sandbox(self, *flags: str) -> CSP:
185 value = " ".join(flags) if flags else ""
186 self._directives["sandbox"] = value
187 return self
189 def report_uri(self, *uris: str) -> CSP:
190 return self._set("report-uri", *uris)
192 def report_to(self, group: str) -> CSP:
193 self._directives["report-to"] = group
194 return self
196 # ---------- Flags ----------
198 def upgrade_insecure_requests(self) -> CSP:
199 self._directives["upgrade-insecure-requests"] = ""
200 return self
202 def block_all_mixed_content(self) -> CSP:
203 self._directives["block-all-mixed-content"] = ""
204 return self
206 # ---------- Utility ----------
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
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)
229 def to_dict(self) -> dict:
230 return dict(self._directives)
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
252 @classmethod
253 def is_valid_directive(cls, name: str) -> bool:
254 return name in ALL_DIRECTIVES
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