Coverage for agentos/tools/csp.py: 0%
132 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-03 08:08 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-03 08:08 +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
19from typing import List, Optional, Set, Union
22# ============================================================================
23# Constants
24# ============================================================================
26ALL_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})
38FLAG_DIRECTIVES = frozenset({"upgrade-insecure-requests", "block-all-mixed-content"})
40KEYWORD_SOURCES = frozenset({
41 "'self'", "'none'", "'strict-dynamic'",
42 "'unsafe-inline'", "'unsafe-eval'",
43 "'unsafe-hashes'", "'unsafe-allow-redirects'",
44 "'wasm-unsafe-eval'",
45})
48# ============================================================================
49# Nonce
50# ============================================================================
52def generate_nonce(length: int = 32) -> str:
53 """Generate a cryptographically random base64 nonce."""
54 return secrets.token_urlsafe(length)
57# ============================================================================
58# CSP
59# ============================================================================
61class CSP:
62 """Fluent Content Security Policy builder.
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 )
73 header = csp.to_header()
74 # default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-abc123'; ...
75 """
77 def __init__(self):
78 self._directives: dict = {}
80 # ---------- Directive setters ----------
82 def default_src(self, *sources: str) -> CSP:
83 return self._set("default-src", *sources)
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)
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)
97 def script_src_attr(self, *sources: str) -> CSP:
98 return self._set("script-src-attr", *sources)
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)
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)
110 def style_src_attr(self, *sources: str) -> CSP:
111 return self._set("style-src-attr", *sources)
113 def img_src(self, *sources: str) -> CSP:
114 return self._set("img-src", *sources)
116 def connect_src(self, *sources: str) -> CSP:
117 return self._set("connect-src", *sources)
119 def font_src(self, *sources: str) -> CSP:
120 return self._set("font-src", *sources)
122 def object_src(self, *sources: str) -> CSP:
123 return self._set("object-src", *sources)
125 def media_src(self, *sources: str) -> CSP:
126 return self._set("media-src", *sources)
128 def frame_src(self, *sources: str) -> CSP:
129 return self._set("frame-src", *sources)
131 def frame_ancestors(self, *sources: str) -> CSP:
132 return self._set("frame-ancestors", *sources)
134 def form_action(self, *sources: str) -> CSP:
135 return self._set("form-action", *sources)
137 def base_uri(self, *sources: str) -> CSP:
138 return self._set("base-uri", *sources)
140 def worker_src(self, *sources: str) -> CSP:
141 return self._set("worker-src", *sources)
143 def child_src(self, *sources: str) -> CSP:
144 return self._set("child-src", *sources)
146 def manifest_src(self, *sources: str) -> CSP:
147 return self._set("manifest-src", *sources)
149 def prefetch_src(self, *sources: str) -> CSP:
150 return self._set("prefetch-src", *sources)
152 def navigate_to(self, *sources: str) -> CSP:
153 return self._set("navigate-to", *sources)
155 def sandbox(self, *flags: str) -> CSP:
156 value = " ".join(flags) if flags else ""
157 self._directives["sandbox"] = value
158 return self
160 def report_uri(self, *uris: str) -> CSP:
161 return self._set("report-uri", *uris)
163 def report_to(self, group: str) -> CSP:
164 self._directives["report-to"] = group
165 return self
167 # ---------- Flags ----------
169 def upgrade_insecure_requests(self) -> CSP:
170 self._directives["upgrade-insecure-requests"] = ""
171 return self
173 def block_all_mixed_content(self) -> CSP:
174 self._directives["block-all-mixed-content"] = ""
175 return self
177 # ---------- Utility ----------
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
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)
200 def to_dict(self) -> dict:
201 return dict(self._directives)
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
223 @classmethod
224 def is_valid_directive(cls, name: str) -> bool:
225 return name in ALL_DIRECTIVES
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