"""CloudFront response headers policy (CSP and related security headers).""" from __future__ import annotations from pathlib import Path from urllib.parse import urlparse from aws_cdk import Duration from aws_cdk import aws_cloudfront as cloudfront from constructs import Construct # Template exported from AWS; placeholders {APP-URL} and {COGNITO-APP-CLIENT-LOGIN-URL}. _CSP_TEMPLATE = ( "default-src 'self'; script-src 'self' cdnjs.cloudflare.com 'unsafe-inline'; " "style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; " "img-src 'self' data:; font-src 'self' https://fonts.gstatic.com data:; " "connect-src 'self' wss://{app_hostname} https://cdnjs.cloudflare.com; " "form-action 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; " "manifest-src 'self' {cognito_login_url}; upgrade-insecure-requests;" ) RESPONSE_HEADERS_POLICY_TEMPLATE_PATH = ( Path(__file__).resolve().parent / "config" / "response-headers-policy-config.json" ) def normalize_https_origin(url: str) -> str: """Return a canonical https origin (scheme + host, no path).""" value = (url or "").strip() if not value: return "" if "://" not in value: value = f"https://{value}" parsed = urlparse(value) if not parsed.hostname: return value.rstrip("/") scheme = parsed.scheme or "https" netloc = parsed.netloc or parsed.hostname return f"{scheme}://{netloc}".rstrip("/") def hostname_from_origin(origin: str) -> str: parsed = urlparse(origin if "://" in origin else f"https://{origin}") return parsed.hostname or origin.strip() def cognito_hosted_ui_base_url(domain_prefix: str, region: str) -> str: prefix = (domain_prefix or "").strip() if not prefix: return "" return f"https://{prefix}.auth.{region}.amazoncognito.com" def build_content_security_policy( *, app_origin: str, cognito_login_url: str, ) -> str: origin = normalize_https_origin(app_origin) hostname = hostname_from_origin(origin) cognito_url = normalize_https_origin(cognito_login_url) return _CSP_TEMPLATE.format(app_hostname=hostname, cognito_login_url=cognito_url) def create_secure_cloudfront_response_headers_policy( scope: Construct, construct_id: str, *, policy_name: str, app_origin: str, cognito_login_url: str, comment: str = "Secure response headers with CSP for doc_redaction", ) -> cloudfront.ResponseHeadersPolicy: """Response headers policy aligned with config/response-headers-policy-config.json.""" cors_origin = normalize_https_origin(app_origin) csp = build_content_security_policy( app_origin=app_origin, cognito_login_url=cognito_login_url, ) return cloudfront.ResponseHeadersPolicy( scope, construct_id, response_headers_policy_name=policy_name, comment=comment, cors_behavior=cloudfront.ResponseHeadersCorsBehavior( access_control_allow_credentials=False, access_control_allow_headers=["*"], access_control_allow_methods=["ALL"], access_control_allow_origins=[cors_origin], access_control_max_age=Duration.seconds(600), origin_override=True, ), security_headers_behavior=cloudfront.ResponseSecurityHeadersBehavior( content_security_policy=cloudfront.ResponseHeadersContentSecurityPolicy( content_security_policy=csp, override=True, ), content_type_options=cloudfront.ResponseHeadersContentTypeOptions( override=True ), frame_options=cloudfront.ResponseHeadersFrameOptions( frame_option=cloudfront.HeadersFrameOption.SAMEORIGIN, override=True, ), referrer_policy=cloudfront.ResponseHeadersReferrerPolicy( referrer_policy=cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN, override=True, ), strict_transport_security=cloudfront.ResponseHeadersStrictTransportSecurity( access_control_max_age=Duration.seconds(31536000), include_subdomains=True, preload=False, override=True, ), xss_protection=cloudfront.ResponseHeadersXSSProtection( protection=True, mode_block=True, override=True, ), ), custom_headers_behavior=cloudfront.ResponseCustomHeadersBehavior( custom_headers=[ cloudfront.ResponseCustomHeader( header="Permissions-Policy", value=( "accelerometer=(), autoplay=(), camera=(), " "cross-origin-isolated=(), display-capture=(), encrypted-media=(), " "fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), " "magnetometer=(), microphone=(), midi=(), payment=(), " "picture-in-picture=(), publickey-credentials-get=(), " "screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), " "xr-spatial-tracking=()" ), override=True, ) ] ), remove_headers=["Server"], ) def resolve_cloudfront_csp_urls( *, cognito_redirection_url: str, cloudfront_domain: str, cognito_user_pool_domain_prefix: str, aws_region: str, cognito_user_pool_login_url: str = "", ssl_certificate_domain: str = "", ) -> tuple[str, str]: """ Return (app_origin, cognito_login_url) for CSP/CORS substitution. App origin prefers COGNITO_REDIRECTION_URL (canonical browser URL), then https://SSL_CERTIFICATE_DOMAIN, then https://CLOUDFRONT_DOMAIN. Cognito login URL uses COGNITO_USER_POOL_LOGIN_URL when set, else the hosted UI base URL derived from COGNITO_USER_POOL_DOMAIN_PREFIX. """ app_origin = normalize_https_origin(cognito_redirection_url) if not app_origin or "placeholder" in app_origin.lower(): if ssl_certificate_domain.strip(): app_origin = normalize_https_origin(ssl_certificate_domain) elif cloudfront_domain.strip(): app_origin = normalize_https_origin(cloudfront_domain) login_url = (cognito_user_pool_login_url or "").strip() if not login_url: login_url = cognito_hosted_ui_base_url( cognito_user_pool_domain_prefix, aws_region ) return app_origin, normalize_https_origin(login_url)