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

217 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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 collections.abc import Mapping 

13from typing import Any 

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

15 

16import boto3 

17import urllib3 

18from backend_tls import get_backend_http_pool 

19 

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

21# Generated at (UTC): 2026-08-30T12:00:00Z 

22# Generated from Git commit: 8cb8e766461f5b7f35205e12db980988598cf6d5 

23# Flowchart(s) generated from this file: 

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

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

26# * ``forward_request`` -> ``diagrams/code_diagrams/lambda/proxy-shared/proxy_utils.forward_request.html`` 

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

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

29# <pyflowchart-code-diagram> END 

30 

31 

32logger = logging.getLogger(__name__) 

33 

34_HOP_BY_HOP_HEADERS = frozenset( 

35 { 

36 "connection", 

37 "keep-alive", 

38 "proxy-authenticate", 

39 "proxy-authorization", 

40 "te", 

41 "trailer", 

42 "transfer-encoding", 

43 "upgrade", 

44 } 

45) 

46_ALLOWED_REQUEST_HEADERS = frozenset( 

47 { 

48 "accept", 

49 "accept-encoding", 

50 "cache-control", 

51 "content-encoding", 

52 "content-type", 

53 "idempotency-key", 

54 "if-match", 

55 "if-none-match", 

56 "prefer", 

57 "range", 

58 "user-agent", 

59 "x-request-id", 

60 } 

61) 

62#: The SigV4 principal API Gateway authenticated, forwarded so the cluster API 

63#: can attribute work to a caller (the Slurm routes record it in each job's 

64#: comment). Only this proxy sets it, from the request context: a client 

65#: sending its own ``x-gco-caller`` loses it in ``sanitize_request_headers`` 

66#: (not an allowed inbound header), and the service trusts the header only 

67#: inside a request whose HMAC envelope verifies. 

68CALLER_IDENTITY_HEADER = "x-gco-caller" 

69_CALLER_ARN_PATTERN = re.compile( 

70 r"^arn:aws[a-zA-Z-]*:(?:iam|sts)::\d{12}:[A-Za-z0-9._/@+=,:-]{1,256}$" 

71) 

72_INTERNAL_SIGNATURE_HEADERS = frozenset( 

73 { 

74 "x-gco-signature-version", 

75 "x-gco-signature", 

76 "x-gco-timestamp", 

77 "x-gco-nonce", 

78 "x-gco-content-sha256", 

79 CALLER_IDENTITY_HEADER, 

80 } 

81) 

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

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

84# The one upstream answer a mutating request may be replayed after: the 

85# Application Load Balancer's own 503. The load balancer serves it when it has 

86# no target to send the request to (no registered or healthy target, or the 

87# target optimizer refused the request), which is a decision it makes before 

88# selecting a target, so the request provably never reached the backend and a 

89# replay cannot duplicate a write. The page below is the fixed one the load 

90# balancer serves, captured verbatim from an ALB with an empty target group: 

91# ``HTTP/1.1 503 Service Temporarily Unavailable``, ``Server: awselb/2.0``, 

92# ``Content-Type: text/html``, ``Content-Length: 162``. A target's 503 (the 

93# services answer JSON with their own ``Server`` header) and nginx's page (an 

94# extra ``<hr><center>nginx</center>`` line) do not match. 502 and 504 are 

95# never in this category: the load balancer produces them after it has handed 

96# the request to a target. 

97_LOAD_BALANCER_503_PAGE = re.compile( 

98 rb"<html>\r?\n" 

99 rb"<head><title>503 Service Temporarily Unavailable</title></head>\r?\n" 

100 rb"<body>\r?\n" 

101 rb"<center><h1>503 Service Temporarily Unavailable</h1></center>\r?\n" 

102 rb"</body>\r?\n" 

103 rb"</html>(?:\r?\n)?" 

104) 

105_LOAD_BALANCER_503_MAX_BODY_BYTES = 512 

106_LOAD_BALANCER_SERVER_HEADER = "awselb/2.0" 

107 

108 

109def _response_header(response: Any, name: str) -> str | None: 

110 """Return one response header by case-insensitive name, or None.""" 

111 headers = getattr(response, "headers", None) 

112 if not isinstance(headers, Mapping): 

113 return None 

114 wanted = name.lower() 

115 for key, value in headers.items(): 

116 if str(key).lower() == wanted: 

117 return str(value) 

118 return None 

119 

120 

121def _is_load_balancer_503(response: Any) -> bool: 

122 """True only for the load balancer's own 503 page (see the constants above). 

123 

124 Fails closed: any 503 that does not carry the load balancer's fixed page, 

125 or that names a different server, is treated as a target's answer. The 

126 ``Server`` header is a gate rather than the signal because operators can 

127 disable it on the listener (``routing.http.response.server.enabled``). 

128 """ 

129 if response.status != 503: 

130 return False 

131 server = _response_header(response, "server") 

132 if server is not None and server.strip().lower() != _LOAD_BALANCER_SERVER_HEADER: 

133 return False 

134 data = getattr(response, "data", None) 

135 if not isinstance(data, bytes | bytearray) or len(data) > _LOAD_BALANCER_503_MAX_BODY_BYTES: 

136 return False 

137 return _LOAD_BALANCER_503_PAGE.fullmatch(bytes(data)) is not None 

138 

139 

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

141 try: 

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

143 except ValueError: 

144 return default 

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

146 

147 

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

149 try: 

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

151 except ValueError: 

152 return default 

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

154 

155 

156_secret_lock = threading.Lock() 

157_cached_secret: str | None = None 

158_cache_timestamp = 0.0 

159_last_successful_refresh = 0.0 

160_last_refresh_attempt = 0.0 

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

162_CACHE_MAX_STALE_SECONDS = max( 

163 _CACHE_TTL_SECONDS, 

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

165) 

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

167 

168 

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

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

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

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

173 return parts[3] or None 

174 return None 

175 

176 

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

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

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

180_secrets_client = boto3.client( 

181 "secretsmanager", 

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

183) 

184 

185 

186def get_secret_token() -> str: 

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

188 global _cached_secret, _cache_timestamp, _last_successful_refresh, _last_refresh_attempt 

189 

190 now = time.monotonic() 

191 age = now - _last_successful_refresh 

192 if _cached_secret is not None and age < _CACHE_TTL_SECONDS: 

193 return _cached_secret 

194 if ( 

195 _cached_secret is not None 

196 and age <= _CACHE_MAX_STALE_SECONDS 

197 and now - _last_refresh_attempt < _CACHE_RETRY_SECONDS 

198 ): 

199 return _cached_secret 

200 

201 with _secret_lock: 

202 now = time.monotonic() 

203 age = now - _last_successful_refresh 

204 if _cached_secret is not None and age < _CACHE_TTL_SECONDS: 

205 return _cached_secret 

206 if ( 

207 _cached_secret is not None 

208 and age <= _CACHE_MAX_STALE_SECONDS 

209 and now - _last_refresh_attempt < _CACHE_RETRY_SECONDS 

210 ): 

211 return _cached_secret 

212 

213 _last_refresh_attempt = now 

214 try: 

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

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

217 token = secret_data.get("token") 

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

219 raise ValueError("secret token is missing") 

220 except Exception as error: 

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

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

223 return _cached_secret 

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

225 

226 _cached_secret = token 

227 _last_successful_refresh = now 

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

229 return token 

230 

231 

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

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

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

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

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

237 if normalized not in _ALLOWED_REQUEST_HEADERS or value is None: 

238 continue 

239 sanitized[normalized] = str(value) 

240 return sanitized 

241 

242 

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

244 parsed = urlsplit(target_url) 

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

246 

247 

248def build_signed_headers( 

249 signing_key: str, 

250 http_method: str, 

251 target_url: str, 

252 body: str | None, 

253) -> dict[str, str]: 

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

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

256 nonce = secrets.token_hex(16) 

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

258 canonical = "\n".join( 

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

260 ) 

261 signature = hmac.new( 

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

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

264 hashlib.sha256, 

265 ).hexdigest() 

266 return { 

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

268 "x-gco-signature": signature, 

269 "x-gco-timestamp": timestamp, 

270 "x-gco-nonce": nonce, 

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

272 } 

273 

274 

275def caller_identity_headers(event: Mapping[str, Any]) -> dict[str, str]: 

276 """The ``x-gco-caller`` header for the principal API Gateway authenticated. 

277 

278 Reads ``requestContext.identity.userArn`` (the IAM user or assumed-role 

279 session ARN API Gateway resolved from the SigV4 signature). Anything that 

280 is not a well-formed IAM/STS ARN yields no header, so the service falls 

281 back to attributing the work to the API itself rather than to a guess. 

282 """ 

283 context = event.get("requestContext") 

284 identity = context.get("identity") if isinstance(context, Mapping) else None 

285 arn = identity.get("userArn") if isinstance(identity, Mapping) else None 

286 if isinstance(arn, str) and _CALLER_ARN_PATTERN.fullmatch(arn): 

287 return {CALLER_IDENTITY_HEADER: arn} 

288 return {} 

289 

290 

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

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

293 allowed = _ALLOWED_REQUEST_HEADERS | _INTERNAL_SIGNATURE_HEADERS 

294 return { 

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

296 for name, value in headers.items() 

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

298 } 

299 

300 

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

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

303_http: urllib3.PoolManager | None = None 

304 

305 

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

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

308 return { 

309 "statusCode": 502, 

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

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

312 } 

313 

314 

315def forward_request( 

316 target_url: str, 

317 http_method: str, 

318 headers: dict[str, str], 

319 body: str | None, 

320 timeout: float = 29.0, 

321) -> dict[str, Any]: 

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

323 

324 Read-only methods replay retryable statuses and transport failures. 

325 Mutating methods are replayed only after the load balancer's own 503, 

326 which it serves before selecting a target (see ``_is_load_balancer_503``); 

327 every other answer or failure may have reached a target and is relayed 

328 after exactly one attempt. Plaintext, non-443, and credential-bearing 

329 targets are rejected before any network request. 

330 """ 

331 parsed_target = urlsplit(target_url) 

332 try: 

333 target_port = parsed_target.port 

334 except ValueError as exc: 

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

336 if ( 

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

338 or not parsed_target.hostname 

339 or parsed_target.username is not None 

340 or parsed_target.password is not None 

341 or target_port not in {None, 443} 

342 ): 

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

344 

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

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

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

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

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

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

351 

352 try: 

353 transport = _http or get_backend_http_pool() 

354 except RuntimeError: 

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

356 return { 

357 "statusCode": 503, 

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

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

360 } 

361 

362 method = http_method.upper() 

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

364 # Every method shares one attempt budget; what differs is which upstream 

365 # answers may spend it. A read-only request replays any retryable status 

366 # or transport failure. A mutating request replays only the load 

367 # balancer's own 503, the one answer proven not to have reached a target; 

368 # anything else it relays, and after a transport failure it stops, because 

369 # the write may already have been applied. 

370 read_only = method in _RETRYABLE_METHODS 

371 max_attempts = _MAX_RETRIES 

372 last_exception: Exception | None = None 

373 last_response: urllib3.BaseHTTPResponse | None = None 

374 attempts_made = 0 

375 

376 for attempt in range(max_attempts): 

377 remaining = deadline - time.monotonic() 

378 if remaining <= 0: 

379 break 

380 attempts_made = attempt + 1 

381 try: 

382 response = transport.request( 

383 method, 

384 target_url, 

385 headers=_outbound_headers(headers), 

386 body=encoded_body, 

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

388 ) 

389 if response.status not in _RETRYABLE_STATUS_CODES: 

390 return _build_success_response(response) 

391 last_response = response 

392 if not read_only and not _is_load_balancer_503(response): 

393 logger.warning( 

394 "Upstream status %d for %s may have reached a target; relaying it unreplayed", 

395 response.status, 

396 method, 

397 ) 

398 return _build_success_response(response) 

399 logger.warning( 

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

401 response.status, 

402 attempt + 1, 

403 max_attempts, 

404 method, 

405 "" if read_only else " (load balancer answered before selecting a target)", 

406 ) 

407 if attempt == max_attempts - 1: 

408 return _build_success_response(response) 

409 response.release_conn() 

410 except urllib3.exceptions.SSLError: 

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

412 return _tls_failure_response() 

413 except (urllib3.exceptions.MaxRetryError, urllib3.exceptions.TimeoutError) as error: 

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

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

416 return _tls_failure_response() 

417 last_exception = error 

418 if not read_only: 

419 # The write may have been applied: report this failure 

420 # instead of replaying, and instead of relaying an earlier 

421 # load balancer 503 that would claim nothing reached the 

422 # backend. 

423 logger.warning( 

424 "Upstream %s failed on attempt %d; not replaying a mutating request", 

425 method, 

426 attempt + 1, 

427 ) 

428 last_response = None 

429 break 

430 logger.warning( 

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

432 method, 

433 attempt + 1, 

434 max_attempts, 

435 ) 

436 except Exception: 

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

438 return { 

439 "statusCode": 500, 

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

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

442 } 

443 

444 if attempt < max_attempts - 1: 

445 remaining = deadline - time.monotonic() 

446 backoff = _RETRY_BACKOFF_BASE * (2**attempt) 

447 if remaining <= backoff: 

448 break 

449 time.sleep(backoff) 

450 

451 if last_response is not None: 

452 return _build_success_response(last_response) 

453 if last_exception is not None: 

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

455 return { 

456 "statusCode": status_code, 

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

458 "body": json.dumps( 

459 { 

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

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

462 } 

463 ), 

464 } 

465 return { 

466 "statusCode": 504, 

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

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

469 } 

470 

471 

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

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

474 response_headers = { 

475 str(name): str(value) 

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

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

478 } 

479 return { 

480 "statusCode": response.status, 

481 "headers": response_headers, 

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

483 } 

484 

485 

486def build_target_url( 

487 endpoint: str, 

488 path: str, 

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

490) -> str: 

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

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

493 parsed_endpoint = urlsplit(base_url) 

494 try: 

495 endpoint_port = parsed_endpoint.port 

496 except ValueError as exc: 

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

498 if ( 

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

500 or not parsed_endpoint.hostname 

501 or parsed_endpoint.username is not None 

502 or parsed_endpoint.password is not None 

503 or endpoint_port not in {None, 443} 

504 or parsed_endpoint.query 

505 or parsed_endpoint.fragment 

506 ): 

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

508 

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

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

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

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

513 return urlunsplit( 

514 ( 

515 parsed_endpoint.scheme, 

516 parsed_endpoint.netloc, 

517 f"{endpoint_path}{encoded_path}", 

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

519 "", 

520 ) 

521 )