From 61c718ea034e9bb0b6b9734fc92e0dcdaf6d726e Mon Sep 17 00:00:00 2001 From: Tiago Correia Date: Sun, 10 May 2026 09:34:14 +0100 Subject: [PATCH 1/2] fix: detect https via CloudFront-injected header instead of ProxyFix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous ProxyFix(x_proto=2) attempt was based on the assumption that ALB *appends* to X-Forwarded-Proto (so the value at the app would be "https,http"). It doesn't — ALB with an HTTP listener *replaces* X-Forwarded-Proto with "http", clobbering whatever CloudFront sent. The header at the app is just "http" (single value), which is why ProxyFix's _get_real_value returned None and the WSGI scheme stayed http even after the previous "fix" deployed. Approach: have CloudFront inject an immutable `X-Forwarded-Scheme: https` custom origin header. Custom name (not X-Forwarded-Proto) sidesteps ALB's rewrite — ALB has no special handling for that name and passes it through verbatim. Hard-coding "https" is safe because viewer_protocol_policy="redirect-to-https" guarantees every request reaching origin came from an HTTPS viewer. App side replaces ProxyFix with a small WSGI middleware that flips wsgi.url_scheme when the header is present, so url_for(_external=True) emits https URLs (the OIDC redirect_uri Keycloak validates). Deploy order: terraform apply first (or the app waits for the header that isn't there yet); both must land for login to succeed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/__init__.py | 31 ++++++++++++++++++----- terraform/main/modules/cloudfront/main.tf | 13 ++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 1203f8f..e6eb887 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,21 +1,38 @@ from flask import Flask -from werkzeug.middleware.proxy_fix import ProxyFix from config import Config from app.extensions import db, migrate, oauth from app.storage import public_url +class _CloudFrontHTTPSFix: + """Flip wsgi.url_scheme to https when the request came in via CloudFront. + + The stack is CloudFront(HTTPS) → ALB(HTTP) → ECS, and ALB *replaces* + `X-Forwarded-Proto` with its listener's value (http) rather than + appending — so the standard ProxyFix pattern doesn't help here. + CloudFront is configured to inject `X-Forwarded-Scheme: https` as an + immutable origin custom header (see modules/cloudfront/main.tf); ALB + passes that name through verbatim because it has no special handling + for it. Presence = the request entered through prod's CloudFront, + so url_for(_external=True) should generate https URLs (e.g. the OIDC + redirect_uri that Keycloak validates). + """ + + def __init__(self, wsgi_app): + self.wsgi_app = wsgi_app + + def __call__(self, environ, start_response): + if environ.get("HTTP_X_FORWARDED_SCHEME") == "https": + environ["wsgi.url_scheme"] = "https" + return self.wsgi_app(environ, start_response) + + def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) - # CloudFront → ALB (HTTP) → ECS. CloudFront stamps `X-Forwarded-Proto: - # https`; ALB appends its own `http`. x_proto=2 picks the CloudFront - # value so `url_for(..., _external=True)` produces https URLs (e.g. - # the OIDC redirect_uri Keycloak validates). x_for=2 mirrors the same - # for client IPs in access logs. - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2, x_proto=2, x_host=1) + app.wsgi_app = _CloudFrontHTTPSFix(app.wsgi_app) db.init_app(app) migrate.init_app(app, db) diff --git a/terraform/main/modules/cloudfront/main.tf b/terraform/main/modules/cloudfront/main.tf index 0bc67e7..9c82112 100644 --- a/terraform/main/modules/cloudfront/main.tf +++ b/terraform/main/modules/cloudfront/main.tf @@ -76,6 +76,19 @@ resource "aws_cloudfront_distribution" "this" { origin_protocol_policy = "http-only" origin_ssl_protocols = ["TLSv1.2"] } + + # `viewer_protocol_policy = "redirect-to-https"` below means every + # request reaching origin came from an HTTPS viewer, so this static + # value is always correct. The ALB's HTTP listener replaces + # `X-Forwarded-Proto` with `http` before forwarding to ECS, which + # breaks Flask's url_for(_external=True) for OIDC redirect_uris. A + # custom header name (vs reusing `X-Forwarded-Proto`) sidesteps the + # ALB rewrite entirely; the app reads this header to flip + # wsgi.url_scheme back to https. + custom_header { + name = "X-Forwarded-Scheme" + value = "https" + } } # Default behavior: forward everything to the ALB with no caching. Auth From 7eef2139b5d01a52d867d382d4aa5bd40631e36a Mon Sep 17 00:00:00 2001 From: Tiago Correia Date: Sun, 10 May 2026 11:37:07 +0100 Subject: [PATCH 2/2] docs: fix CloudFront module path in _CloudFrontHTTPSFix docstring The docstring pointed at modules/cloudfront/main.tf, but the file lives at terraform/main/modules/cloudfront/main.tf. Updated so the cross-reference resolves from the repo root. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index e6eb887..01893e4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,7 +12,7 @@ class _CloudFrontHTTPSFix: `X-Forwarded-Proto` with its listener's value (http) rather than appending — so the standard ProxyFix pattern doesn't help here. CloudFront is configured to inject `X-Forwarded-Scheme: https` as an - immutable origin custom header (see modules/cloudfront/main.tf); ALB + immutable origin custom header (see terraform/main/modules/cloudfront/main.tf); ALB passes that name through verbatim because it has no special handling for it. Presence = the request entered through prod's CloudFront, so url_for(_external=True) should generate https URLs (e.g. the OIDC