From 5eee2fcf19886579f086ae2a96cd41cf715514b1 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 25 Jun 2026 22:34:51 -0400 Subject: [PATCH] Add pass_headers support to FastCGI plugin --- docs/reference/plugins/fastcgi.md | 58 +++++++++++++++++++++++-------- src/plugins/builtin/fastcgi.py | 44 ++++++++++++++++++++++- tests/test_plugins.py | 48 +++++++++++++++++++++++++ tests_e2e/test_docker_compose.py | 2 +- 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/docs/reference/plugins/fastcgi.md b/docs/reference/plugins/fastcgi.md index f606a3f..87a08ef 100644 --- a/docs/reference/plugins/fastcgi.md +++ b/docs/reference/plugins/fastcgi.md @@ -18,14 +18,39 @@ Automatically generates HAProxy `fcgi-app` configuration that defines required C ## Configuration Options -| Option | Description | Default | -|-------------------|-----------------------------------------|------------------------------------| -| `enabled` | Enable/disable plugin | `true` | -| `document_root` | Document root path | `/var/www/html` | -| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | -| `index_file` | Default index file | `index.php` | -| `path_info` | Enable PATH_INFO support | `true` | -| `custom_params` | Dictionary of custom FastCGI parameters | (optional) | +| Option | Description | Default | +|-------------------|--------------------------------------------|--------------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `document_root` | Document root path | `/var/www/html` | +| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | +| `index_file` | Default index file | `index.php` | +| `path_info` | Enable PATH_INFO support | `true` | +| `custom_params` | Dictionary of custom FastCGI parameters | (optional) | +| `pass_headers` | HTTP headers to forward to the FastCGI app | (optional) | + +### `pass_headers` — Forwarding HTTP Headers + +:::important +HAProxy strips `Authorization`, `Proxy-Authorization`, and all hop-by-hop headers before forwarding requests to the FastCGI backend. Applications that rely on these headers (e.g. APIs using Bearer tokens, Basic auth, or JWT) will fail silently without this option. +::: + +`pass_headers` accepts a comma-separated string or a list of strings/dicts: + +```yaml +# Simple — single header +pass_headers: "Authorization" + +# Multiple — comma-separated +pass_headers: "Authorization, Proxy-Authorization" + +# With ACL condition — list of dicts +pass_headers: + - Authorization + - name: X-Custom-Header + condition: "if { ssl_fc }" +``` + +> **Note:** `Content-Type` and `Content-Length` cannot be passed here — they are automatically converted to the CGI parameters `CONTENT_TYPE` and `CONTENT_LENGTH`. ## Configuration Examples @@ -78,6 +103,7 @@ metadata: easyhaproxy.plugin.fastcgi.document_root: "/var/www/html" easyhaproxy.plugin.fastcgi.index_file: "index.php" easyhaproxy.plugin.fastcgi.script_filename: "/var/www/html/index.php" + easyhaproxy.plugin.fastcgi.pass_headers: "Authorization, Proxy-Authorization" spec: ingressClassName: easyhaproxy rules: @@ -113,13 +139,13 @@ easymapping: ### Environment Variables -| Environment Variable | Config Key | Type | Default | Description | -|----------------------------------------------|-------------------|----------|------------------------|---------------------------------------| -| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | -| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | -| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | -| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | -| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | +| Environment Variable | Config Key | Type | Default | Description | +|----------------------------------------------|-------------------|----------|-------------------------|---------------------------------------| +| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | +| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | +| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | +| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | ## Generated HAProxy Configuration @@ -129,6 +155,8 @@ fcgi-app fcgi_phpapp_local docroot /var/www/html index index.php path-info ^(/.+\.php)(/.*)?$ + pass-header Authorization + pass-header Proxy-Authorization # Backend configuration (added to the backend section) backend srv_phpapp_local_80 diff --git a/src/plugins/builtin/fastcgi.py b/src/plugins/builtin/fastcgi.py index 7078900..e6e7f27 100644 --- a/src/plugins/builtin/fastcgi.py +++ b/src/plugins/builtin/fastcgi.py @@ -15,6 +15,8 @@ Configuration: - index_file: Default index file (default: index.php) - path_info: Enable PATH_INFO support (default: true) - custom_params: Dictionary of custom FastCGI parameters (optional) + - pass_headers: Headers to forward to the FastCGI app (optional). + Comma-separated string or list of strings/dicts with optional condition. Example YAML config: plugins: @@ -34,6 +36,7 @@ Example Kubernetes Annotation: easyhaproxy.plugins: "fastcgi" easyhaproxy.plugin.fastcgi.document_root: /var/www/myapp easyhaproxy.plugin.fastcgi.index_file: index.php + easyhaproxy.plugin.fastcgi.pass_headers: "Authorization, Proxy-Authorization" """ import os @@ -55,6 +58,7 @@ class FastcgiPlugin(PluginInterface): self.index_file = "index.php" self.path_info = True self.custom_params = {} + self.pass_headers = [] # list of {"name": str, "condition": str|None} @property def name(self) -> str: @@ -76,6 +80,19 @@ class FastcgiPlugin(PluginInterface): - index_file: Default index file - path_info: Enable PATH_INFO support - custom_params: Dictionary of custom FastCGI parameters + - pass_headers: Headers to forward to the FastCGI application. + HAProxy omits Authorization, Proxy-Authorization, and hop-by-hop headers + by default — this directive is required to pass them through. + Note: Content-Type and Content-Length are never passable here; they are + already converted to CGI parameters (CONTENT_TYPE, CONTENT_LENGTH). + + Accepts a comma-separated string or a list of strings/dicts: + Simple: "Authorization" + Multiple: "Authorization, Proxy-Authorization" + With ACL: [{"name": "Authorization", "condition": "if { ssl_fc }"}] + + HAProxy syntax: pass-header [ { if | unless } ] + Ref: https://docs.haproxy.org/dev/configuration.html """ if "enabled" in config: self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] @@ -95,6 +112,23 @@ class FastcgiPlugin(PluginInterface): if "custom_params" in config: self.custom_params = config["custom_params"] + if "pass_headers" in config: + val = config["pass_headers"] + if isinstance(val, str): + self.pass_headers = [{"name": h.strip(), "condition": None} + for h in val.split(",") if h.strip()] + elif isinstance(val, list): + result = [] + for item in val: + if isinstance(item, str): + result.append({"name": item.strip(), "condition": None}) + elif isinstance(item, dict): + result.append({ + "name": item["name"], + "condition": item.get("condition") + }) + self.pass_headers = result + def process(self, context: PluginContext) -> PluginResult: """ Process the plugin and generate FastCGI configuration @@ -134,6 +168,13 @@ class FastcgiPlugin(PluginInterface): for param_name, param_value in self.custom_params.items(): fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + # Pass headers + for header in self.pass_headers: + line = f" pass-header {header['name']}" + if header.get("condition"): + line += f" {header['condition']}" + fcgi_app_lines.append(line) + fcgi_app_definition = "\n".join(fcgi_app_lines) # Build metadata @@ -143,7 +184,8 @@ class FastcgiPlugin(PluginInterface): "document_root": self.document_root, "index_file": self.index_file, "path_info": self.path_info, - "custom_params_count": len(self.custom_params) + "custom_params_count": len(self.custom_params), + "pass_headers_count": len(self.pass_headers) } return PluginResult( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 495bf91..47bcf16 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -952,6 +952,7 @@ class TestFastcgiPlugin: assert plugin.index_file == "index.php" assert plugin.path_info is True assert plugin.custom_params == {} + assert plugin.pass_headers == [] def test_fastcgi_plugin_configuration(self): """Test plugin configuration""" @@ -1046,6 +1047,53 @@ class TestFastcgiPlugin: assert result.haproxy_config is None or result.haproxy_config == "" + def test_fastcgi_plugin_pass_headers_string(self): + """Test pass_headers parsed from comma-separated string""" + plugin = FastcgiPlugin() + plugin.configure({"pass_headers": "Authorization, Proxy-Authorization"}) + + assert plugin.pass_headers == [ + {"name": "Authorization", "condition": None}, + {"name": "Proxy-Authorization", "condition": None}, + ] + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + fcgi_app_def = result.global_configs[0] + + assert "pass-header Authorization" in fcgi_app_def + assert "pass-header Proxy-Authorization" in fcgi_app_def + assert result.metadata["pass_headers_count"] == 2 + + def test_fastcgi_plugin_pass_headers_list(self): + """Test pass_headers parsed from list of strings and dicts with condition""" + plugin = FastcgiPlugin() + plugin.configure({ + "pass_headers": [ + "Authorization", + {"name": "X-Custom-Header", "condition": "if { ssl_fc }"}, + ] + }) + + assert plugin.pass_headers == [ + {"name": "Authorization", "condition": None}, + {"name": "X-Custom-Header", "condition": "if { ssl_fc }"}, + ] + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, + domain="phpapp.local", port="80", host_config={} + ) + result = plugin.process(context) + fcgi_app_def = result.global_configs[0] + + assert "pass-header Authorization" in fcgi_app_def + assert "pass-header X-Custom-Header if { ssl_fc }" in fcgi_app_def + assert result.metadata["pass_headers_count"] == 2 + class TestPluginManager: """Test cases for PluginManager""" diff --git a/tests_e2e/test_docker_compose.py b/tests_e2e/test_docker_compose.py index 5e9b419..87fb884 100644 --- a/tests_e2e/test_docker_compose.py +++ b/tests_e2e/test_docker_compose.py @@ -920,7 +920,7 @@ class TestACME: """Test that Pebble successfully issues a certificate""" # Wait for certificate issuance (Certbot runs in background loop) # Typical time: 10-15 seconds from container start - max_retries = 30 + max_retries = 60 check_interval = 1 has_success = False