document_redaction / cdk /cdk_cloudfront_headers.py
seanpedrickcase's picture
Sync: Merge pull request #202 from seanpedrick-case/cdk_cloudfront
e73a24a
Raw
History Blame Contribute Delete
6.68 kB
"""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)