Coverage for lambda / proxy-shared / proxy_utils.py: 100.00%

180 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Shared, fail-closed utilities for API Gateway backend proxy Lambdas.""" 

2 

3import hashlib 

4import hmac 

5import json 

6import logging 

7import os 

8import re 

9import secrets 

10import threading 

11import time 

12from typing import Any 

13from urllib.parse import quote, urlencode, urlsplit, urlunsplit 

14 

15import boto3 

16import urllib3 

17from backend_tls import get_backend_http_pool 

18 

19# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

20# Generated at (UTC): 2026-09-01T14:42:56Z 

21# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

22# Flowchart(s) generated from this file: 

23# * ``build_signed_headers`` -> ``diagrams/code_diagrams/lambda/proxy-shared/proxy_utils.build_signed_headers.html`` 

24# (PNG: ``diagrams/code_diagrams/lambda/proxy-shared/proxy_utils.build_signed_headers.png``) 

25# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

26# <pyflowchart-code-diagram> END 

27 

28 

29logger = logging.getLogger(__name__) 

30 

31_HOP_BY_HOP_HEADERS = frozenset( 

32 { 

33 "connection", 

34 "keep-alive", 

35 "proxy-authenticate", 

36 "proxy-authorization", 

37 "te", 

38 "trailer", 

39 "transfer-encoding", 

40 "upgrade", 

41 } 

42) 

43_ALLOWED_REQUEST_HEADERS = frozenset( 

44 { 

45 "accept", 

46 "accept-encoding", 

47 "cache-control", 

48 "content-encoding", 

49 "content-type", 

50 "idempotency-key", 

51 "if-match", 

52 "if-none-match", 

53 "prefer", 

54 "range", 

55 "user-agent", 

56 "x-request-id", 

57 } 

58) 

59_INTERNAL_SIGNATURE_HEADERS = frozenset( 

60 { 

61 "x-gco-signature-version", 

62 "x-gco-signature", 

63 "x-gco-timestamp", 

64 "x-gco-nonce", 

65 "x-gco-content-sha256", 

66 } 

67) 

68_RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504}) 

69_RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) 

70 

71 

72def _bounded_env_float(name: str, default: float, minimum: float, maximum: float) -> float: 

73 try: 

74 value = float(os.getenv(name, str(default))) 

75 except ValueError: 

76 return default 

77 return value if minimum <= value <= maximum else default 

78 

79 

80def _bounded_env_int(name: str, default: int, minimum: int, maximum: int) -> int: 

81 try: 

82 value = int(os.getenv(name, str(default))) 

83 except ValueError: 

84 return default 

85 return value if minimum <= value <= maximum else default 

86 

87 

88_secret_lock = threading.Lock() 

89_cached_secret: str | None = None 

90_cache_timestamp = 0.0 

91_last_successful_refresh = 0.0 

92_last_refresh_attempt = 0.0 

93_CACHE_TTL_SECONDS = _bounded_env_float("SECRET_CACHE_TTL_SECONDS", 300.0, 1.0, 3600.0) 

94_CACHE_MAX_STALE_SECONDS = max( 

95 _CACHE_TTL_SECONDS, 

96 _bounded_env_float("SECRET_CACHE_MAX_STALE_SECONDS", 900.0, 1.0, 7200.0), 

97) 

98_CACHE_RETRY_SECONDS = _bounded_env_float("SECRET_CACHE_RETRY_SECONDS", 5.0, 0.1, 60.0) 

99 

100 

101def _secret_region(secret_arn: str) -> str | None: 

102 """Return the owning region for a Secrets Manager ARN, if present.""" 

103 parts = secret_arn.split(":", 5) 

104 if len(parts) == 6 and parts[0] == "arn" and parts[2] == "secretsmanager": 

105 return parts[3] or None 

106 return None 

107 

108 

109# The regional VPC proxy runs outside the API Gateway region where the shared 

110# HMAC key lives. Secrets Manager clients do not route cross-region ARNs to the 

111# owning endpoint automatically, so bind the client to the ARN's region. 

112_secrets_client = boto3.client( 

113 "secretsmanager", 

114 region_name=_secret_region(os.getenv("SECRET_ARN", "")), 

115) 

116 

117 

118def get_secret_token() -> str: 

119 """Return a cached signing key, with a strictly bounded stale grace period.""" 

120 global _cached_secret, _cache_timestamp, _last_successful_refresh, _last_refresh_attempt 

121 

122 now = time.monotonic() 

123 age = now - _last_successful_refresh 

124 if _cached_secret is not None and age < _CACHE_TTL_SECONDS: 

125 return _cached_secret 

126 if ( 

127 _cached_secret is not None 

128 and age <= _CACHE_MAX_STALE_SECONDS 

129 and now - _last_refresh_attempt < _CACHE_RETRY_SECONDS 

130 ): 

131 return _cached_secret 

132 

133 with _secret_lock: 

134 now = time.monotonic() 

135 age = now - _last_successful_refresh 

136 if _cached_secret is not None and age < _CACHE_TTL_SECONDS: 

137 return _cached_secret 

138 if ( 

139 _cached_secret is not None 

140 and age <= _CACHE_MAX_STALE_SECONDS 

141 and now - _last_refresh_attempt < _CACHE_RETRY_SECONDS 

142 ): 

143 return _cached_secret 

144 

145 _last_refresh_attempt = now 

146 try: 

147 response = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_ARN"]) 

148 secret_data = json.loads(response["SecretString"]) 

149 token = secret_data.get("token") 

150 if not isinstance(token, str) or not token: 

151 raise ValueError("secret token is missing") 

152 except Exception as error: 

153 if _cached_secret is not None and age <= _CACHE_MAX_STALE_SECONDS: 

154 logger.warning("Secrets Manager refresh failed; using bounded stale signing key") 

155 return _cached_secret 

156 raise RuntimeError("Authentication signing key is unavailable") from error 

157 

158 _cached_secret = token 

159 _last_successful_refresh = now 

160 _cache_timestamp = now # Backward-compatible observability/test alias. 

161 return token 

162 

163 

164def sanitize_request_headers(headers: dict[str, Any]) -> dict[str, str]: 

165 """Apply a case-insensitive end-to-end allowlist at the IAM trust boundary.""" 

166 sanitized: dict[str, str] = {} 

167 for name, value in headers.items(): 

168 normalized = str(name).strip().lower() 

169 if normalized not in _ALLOWED_REQUEST_HEADERS or value is None: 

170 continue 

171 sanitized[normalized] = str(value) 

172 return sanitized 

173 

174 

175def _request_target(target_url: str) -> str: 

176 parsed = urlsplit(target_url) 

177 return parsed.path + (f"?{parsed.query}" if parsed.query else "") 

178 

179 

180def build_signed_headers( 

181 signing_key: str, 

182 http_method: str, 

183 target_url: str, 

184 body: str | None, 

185) -> dict[str, str]: 

186 """Build short-lived request authentication without transmitting the signing key.""" 

187 timestamp = str(int(time.time())) 

188 nonce = secrets.token_hex(16) 

189 content_hash = hashlib.sha256((body or "").encode("utf-8")).hexdigest() 

190 canonical = "\n".join( 

191 ["v1", timestamp, nonce, http_method.upper(), _request_target(target_url), content_hash] 

192 ) 

193 signature = hmac.new( 

194 signing_key.encode("utf-8"), 

195 canonical.encode("utf-8"), 

196 hashlib.sha256, 

197 ).hexdigest() 

198 return { 

199 "x-gco-signature-version": "v1", 

200 "x-gco-signature": signature, 

201 "x-gco-timestamp": timestamp, 

202 "x-gco-nonce": nonce, 

203 "x-gco-content-sha256": content_hash, 

204 } 

205 

206 

207def _outbound_headers(headers: dict[str, Any]) -> dict[str, str]: 

208 """Re-validate caller headers while retaining only generated auth fields.""" 

209 allowed = _ALLOWED_REQUEST_HEADERS | _INTERNAL_SIGNATURE_HEADERS 

210 return { 

211 str(name).lower(): str(value) 

212 for name, value in headers.items() 

213 if str(name).lower() in allowed and value is not None 

214 } 

215 

216 

217_MAX_RETRIES = _bounded_env_int("PROXY_MAX_RETRIES", 3, 1, 5) 

218_RETRY_BACKOFF_BASE = _bounded_env_float("PROXY_RETRY_BACKOFF_BASE", 0.3, 0.0, 5.0) 

219_http: urllib3.PoolManager | None = None 

220 

221 

222def _tls_failure_response() -> dict[str, Any]: 

223 """Return a bounded error without exposing certificate or trust details.""" 

224 return { 

225 "statusCode": 502, 

226 "headers": {"Content-Type": "application/json"}, 

227 "body": json.dumps({"error": "Backend TLS verification failed"}), 

228 } 

229 

230 

231def forward_request( 

232 target_url: str, 

233 http_method: str, 

234 headers: dict[str, str], 

235 body: str | None, 

236 timeout: float = 29.0, 

237) -> dict[str, Any]: 

238 """Forward over authenticated TLS within one deadline. 

239 

240 Retries are limited to safe, read-only methods. Plaintext, non-443, and 

241 credential-bearing targets are rejected before any network request. 

242 """ 

243 parsed_target = urlsplit(target_url) 

244 try: 

245 target_port = parsed_target.port 

246 except ValueError as exc: 

247 raise ValueError("Backend proxy target has an invalid port") from exc 

248 if ( 

249 parsed_target.scheme.lower() != "https" 

250 or not parsed_target.hostname 

251 or parsed_target.username is not None 

252 or parsed_target.password is not None 

253 or target_port not in {None, 443} 

254 ): 

255 raise ValueError("Backend proxy targets must use HTTPS on port 443") 

256 

257 # Anchor the deadline before acquiring transport: the caller computed the 

258 # budget from the Lambda's remaining time, so a cold-start trust-bundle 

259 # refresh must consume this budget. Anchoring after it extended the wall 

260 # clock past the Lambda timeout, killing the function mid-flight instead 

261 # of returning its bounded 504 when the backend black-holed. 

262 deadline = time.monotonic() + max(timeout, 0.0) 

263 

264 try: 

265 transport = _http or get_backend_http_pool() 

266 except RuntimeError: 

267 logger.exception("Backend TLS trust is unavailable") 

268 return { 

269 "statusCode": 503, 

270 "headers": {"Content-Type": "application/json"}, 

271 "body": json.dumps({"error": "Backend trust is temporarily unavailable"}), 

272 } 

273 

274 method = http_method.upper() 

275 encoded_body = body.encode("utf-8") if body else None 

276 max_attempts = _MAX_RETRIES if method in _RETRYABLE_METHODS else 1 

277 last_exception: Exception | None = None 

278 last_response: urllib3.BaseHTTPResponse | None = None 

279 attempts_made = 0 

280 

281 for attempt in range(max_attempts): 

282 remaining = deadline - time.monotonic() 

283 if remaining <= 0: 

284 break 

285 attempts_made = attempt + 1 

286 try: 

287 response = transport.request( 

288 method, 

289 target_url, 

290 headers=_outbound_headers(headers), 

291 body=encoded_body, 

292 timeout=urllib3.Timeout(total=remaining), 

293 ) 

294 if response.status not in _RETRYABLE_STATUS_CODES: 

295 return _build_success_response(response) 

296 last_response = response 

297 logger.warning( 

298 "Retryable upstream status %d on attempt %d/%d for %s", 

299 response.status, 

300 attempt + 1, 

301 max_attempts, 

302 method, 

303 ) 

304 if attempt == max_attempts - 1: 

305 return _build_success_response(response) 

306 response.release_conn() 

307 except urllib3.exceptions.SSLError: 

308 logger.exception("Backend TLS verification failed") 

309 return _tls_failure_response() 

310 except urllib3.exceptions.MaxRetryError as error: 

311 if isinstance(getattr(error, "reason", None), urllib3.exceptions.SSLError): 

312 logger.exception("Backend TLS verification failed") 

313 return _tls_failure_response() 

314 last_exception = error 

315 logger.warning( 

316 "Upstream %s failed on attempt %d/%d", 

317 method, 

318 attempt + 1, 

319 max_attempts, 

320 ) 

321 except urllib3.exceptions.TimeoutError as error: 

322 last_exception = error 

323 logger.warning( 

324 "Upstream %s failed on attempt %d/%d", 

325 method, 

326 attempt + 1, 

327 max_attempts, 

328 ) 

329 except Exception: 

330 logger.exception("Unexpected proxy forwarding failure") 

331 return { 

332 "statusCode": 500, 

333 "headers": {"Content-Type": "application/json"}, 

334 "body": json.dumps({"error": "Internal server error"}), 

335 } 

336 

337 if attempt < max_attempts - 1: 

338 remaining = deadline - time.monotonic() 

339 backoff = _RETRY_BACKOFF_BASE * (2**attempt) 

340 if remaining <= backoff: 

341 break 

342 time.sleep(backoff) 

343 

344 if last_response is not None: 

345 return _build_success_response(last_response) 

346 if last_exception is not None: 

347 status_code = 503 if isinstance(last_exception, urllib3.exceptions.MaxRetryError) else 504 

348 return { 

349 "statusCode": status_code, 

350 "headers": {"Content-Type": "application/json"}, 

351 "body": json.dumps( 

352 { 

353 "error": "Service unavailable" if status_code == 503 else "Gateway timeout", 

354 "message": f"Upstream failed after {attempts_made} attempt(s)", 

355 } 

356 ), 

357 } 

358 return { 

359 "statusCode": 504, 

360 "headers": {"Content-Type": "application/json"}, 

361 "body": json.dumps({"error": "Gateway timeout"}), 

362 } 

363 

364 

365def _build_success_response(response: urllib3.BaseHTTPResponse) -> dict[str, Any]: 

366 """Build an API Gateway response without hop-by-hop framing headers.""" 

367 response_headers = { 

368 str(name): str(value) 

369 for name, value in response.headers.items() 

370 if str(name).lower() not in _HOP_BY_HOP_HEADERS 

371 } 

372 return { 

373 "statusCode": response.status, 

374 "headers": response_headers, 

375 "body": response.data.decode("utf-8"), 

376 } 

377 

378 

379def build_target_url( 

380 endpoint: str, 

381 path: str, 

382 query_params: dict[str, str | list[str]] | None, 

383) -> str: 

384 """Build one HTTPS/443 upstream URL without losing repeated query keys.""" 

385 base_url = endpoint if "://" in endpoint else f"https://{endpoint}" 

386 parsed_endpoint = urlsplit(base_url) 

387 try: 

388 endpoint_port = parsed_endpoint.port 

389 except ValueError as exc: 

390 raise ValueError(f"Invalid proxy endpoint: {endpoint!r}") from exc 

391 if ( 

392 parsed_endpoint.scheme.lower() != "https" 

393 or not parsed_endpoint.hostname 

394 or parsed_endpoint.username is not None 

395 or parsed_endpoint.password is not None 

396 or endpoint_port not in {None, 443} 

397 or parsed_endpoint.query 

398 or parsed_endpoint.fragment 

399 ): 

400 raise ValueError(f"Proxy endpoint must use HTTPS on port 443: {endpoint!r}") 

401 

402 request_path = path if path.startswith("/") else f"/{path}" 

403 request_path = re.sub(r"%(?![0-9A-Fa-f]{2})", "%25", request_path) 

404 encoded_path = quote(request_path, safe="/:@-._~!$&'()*+,;=%") 

405 endpoint_path = parsed_endpoint.path.rstrip("/") 

406 return urlunsplit( 

407 ( 

408 parsed_endpoint.scheme, 

409 parsed_endpoint.netloc, 

410 f"{endpoint_path}{encoded_path}", 

411 urlencode(query_params or {}, doseq=True), 

412 "", 

413 ) 

414 )