Coverage for agentos/tools/cors.py: 0%
100 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 12:22 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 12:22 +0800
1"""
2CORS — Cross-Origin Resource Sharing middleware helper.
4Supports:
5 - Fluent builder for CORS configuration
6 - Origin validation (allowlist/regex)
7 - Method (GET/POST/PUT/DELETE/etc.) and header control
8 - Preflight (OPTIONS) response builder
9 - Max-Age, Credentials, Expose-Headers
10 - Serialize to dict of response headers
11"""
13from __future__ import annotations
15import re
16from re import Pattern
18# ============================================================================
19# CORS
20# ============================================================================
22SAFELISTED_METHODS = frozenset({"GET", "HEAD", "POST"})
23SAFELISTED_HEADERS = frozenset(
24 {
25 "accept",
26 "accept-language",
27 "content-language",
28 "content-type",
29 }
30)
33class CORSConfig:
34 """CORS configuration builder.
36 Usage:
37 cors = (CORSConfig()
38 .allow_origins("https://example.com", "https://app.example.com")
39 .allow_methods("GET", "POST", "PUT")
40 .allow_headers("Content-Type", "Authorization")
41 .allow_credentials()
42 .max_age(3600)
43 )
45 # Check if origin is allowed
46 ok = cors.is_origin_allowed("https://example.com")
48 # Build preflight response headers
49 headers = cors.preflight_headers("https://example.com")
50 """
52 def __init__(self):
53 self._origins: list[str] = []
54 self._origin_patterns: list[Pattern] = []
55 self._allow_any_origin: bool = False
56 self._methods: set[str] = set()
57 self._headers: set[str] = set()
58 self._expose_headers: set[str] = set()
59 self._allow_credentials: bool = False
60 self._max_age: int | None = None
62 # ---------- Fluent setters ----------
64 def allow_origins(self, *origins: str) -> CORSConfig:
65 for origin in origins:
66 if origin == "*":
67 self._allow_any_origin = True
68 elif "*" in origin and origin != "*":
69 # Convert glob to regex
70 pattern = re.escape(origin).replace(r"\*", ".*")
71 self._origin_patterns.append(re.compile(f"^{pattern}$"))
72 else:
73 self._origins.append(origin.rstrip("/"))
74 return self
76 def allow_methods(self, *methods: str) -> CORSConfig:
77 self._methods.update(m.upper() for m in methods)
78 return self
80 def allow_headers(self, *headers: str) -> CORSConfig:
81 self._headers.update(h.lower() for h in headers)
82 return self
84 def expose_headers(self, *headers: str) -> CORSConfig:
85 self._expose_headers.update(h.lower() for h in headers)
86 return self
88 def allow_credentials(self) -> CORSConfig:
89 self._allow_credentials = True
90 return self
92 def max_age(self, seconds: int) -> CORSConfig:
93 self._max_age = seconds
94 return self
96 # ---------- Convenience ----------
98 def allow_all_origins(self) -> CORSConfig:
99 self._allow_any_origin = True
100 return self
102 def allow_all_methods(self) -> CORSConfig:
103 self._methods = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
104 return self
106 def allow_all_headers(self) -> CORSConfig:
107 self._headers = {"*"}
108 return self
110 # ---------- Logic ----------
112 def is_origin_allowed(self, origin: str) -> bool:
113 if self._allow_any_origin:
114 return True
115 origin = origin.rstrip("/")
116 if origin in self._origins:
117 return True
118 for pattern in self._origin_patterns:
119 if pattern.match(origin):
120 return True
121 return False
123 def preflight_headers(
124 self,
125 origin: str,
126 request_method: str | None = None,
127 request_headers: list[str] | None = None,
128 ) -> dict[str, str]:
129 """Build response headers for a preflight OPTIONS request."""
130 headers: dict = {}
132 if not self.is_origin_allowed(origin):
133 return headers
135 if self._allow_any_origin and not self._allow_credentials:
136 headers["Access-Control-Allow-Origin"] = "*"
137 else:
138 headers["Access-Control-Allow-Origin"] = origin
140 if self._allow_credentials:
141 headers["Access-Control-Allow-Credentials"] = "true"
143 # Methods — return all allowed methods (preflight spec)
144 if self._methods:
145 headers["Access-Control-Allow-Methods"] = ", ".join(sorted(self._methods))
146 elif request_method:
147 headers["Access-Control-Allow-Methods"] = request_method.upper()
149 # Headers
150 if request_headers:
151 allowed = set(h.lower() for h in request_headers)
152 if self._headers and "*" not in self._headers:
153 allowed &= self._headers
154 if allowed:
155 headers["Access-Control-Allow-Headers"] = ", ".join(sorted(allowed))
156 elif self._headers:
157 headers["Access-Control-Allow-Headers"] = ", ".join(sorted(self._headers))
159 # Expose
160 if self._expose_headers:
161 headers["Access-Control-Expose-Headers"] = ", ".join(sorted(self._expose_headers))
163 # Max-Age
164 if self._max_age is not None:
165 headers["Access-Control-Max-Age"] = str(self._max_age)
167 return headers
169 def actual_headers(self, origin: str) -> dict[str, str]:
170 """Build response headers for the actual (non-preflight) request."""
171 headers: dict = {}
173 if not self.is_origin_allowed(origin):
174 return headers
176 if self._allow_any_origin and not self._allow_credentials:
177 headers["Access-Control-Allow-Origin"] = "*"
178 else:
179 headers["Access-Control-Allow-Origin"] = origin
181 if self._allow_credentials:
182 headers["Access-Control-Allow-Credentials"] = "true"
184 if self._expose_headers:
185 headers["Access-Control-Expose-Headers"] = ", ".join(sorted(self._expose_headers))
187 return headers
189 def is_preflight(self, method: str, headers: list[str] | None = None) -> bool:
190 """Check if a request is a CORS preflight request."""
191 if method.upper() != "OPTIONS":
192 return False
193 # Preflight requires Origin header + either non-safelisted method or custom header
194 # (Origin header presence is assumed by the caller)
195 return True