HAProxy Config Injection: How Unsanitized Form Fields Can Hijack Your Load Balancer
Introduction
Load balancers are the traffic cops of modern infrastructure. They decide which requests go where, enforce rate limits, terminate SSL, and gate access to your most sensitive services. HAProxy, in particular, is trusted in high-stakes environments — financial services, healthcare platforms, large-scale SaaS products — precisely because of its reliability and fine-grained control.
So what happens when the configuration file that governs all of that behavior can be silently rewritten by an attacker through a web form?
That's exactly the scenario this vulnerability enables. And it's more common than you'd think.
This post breaks down a high-severity configuration injection vulnerability found in an HAProxy dashboard application, explains how it could be exploited in the real world, and walks through the fix that closes the door on it.
The Vulnerability Explained
What Is Configuration Injection?
Configuration injection is a class of vulnerability where unsanitized user input is written into a configuration file that a privileged process later reads and executes. It's conceptually similar to SQL injection or command injection — the attacker's input crosses a trust boundary and is interpreted as instructions rather than data.
In this case, the HAProxy dashboard exposes a web form that allows administrators to add new frontends, backends, and routing rules. The form accepts fields like:
frontend_namefrontend_ipfrontend_portlb_methodprotocol
These values were read directly from HTTP form submissions and written into the HAProxy configuration file without any sanitization or validation.
The Root Cause
The vulnerable code path in main_routes.py passed user-supplied values straight into the configuration writer, which then did something like this:
# VULNERABLE: No sanitization before writing to config
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
haproxy_cfg.write(f"\nfrontend {frontend_name}\n"
f" bind {frontend_ip}:{frontend_port}\n"
f" mode {protocol}\n"
...)
The problem is the newline character (\n). HAProxy's configuration format is line-oriented — each line is a distinct directive. If an attacker can inject a newline into any of these fields, they can terminate the current line and begin writing a completely new, attacker-controlled directive.
How Could It Be Exploited?
Consider a malicious value submitted for frontend_name:
my-frontend\nacl internal_network src 10.0.0.0/8\nhttp-request allow if internal_network\nhttp-request deny
When this gets written to the config file, it doesn't produce a single frontend name. It produces:
frontend my-frontend
acl internal_network src 10.0.0.0/8
http-request allow if internal_network
http-request deny
bind ...
The attacker has just injected ACL rules into the running load balancer configuration. With HAProxy reloading periodically (or on demand), these rules become active.
Real-World Attack Scenarios
Scenario 1: Bypassing IP Allowlists
An attacker with access to the dashboard form submits a crafted frontend_name that injects ACL rules granting their IP address unrestricted access to an otherwise-protected backend. On the next HAProxy reload, the rules are live.
Scenario 2: Adding an Unauthorized Backend
By injecting newlines into lb_method or protocol, an attacker could close out the current stanza and open a new backend block that proxies traffic to an attacker-controlled server — effectively creating a backdoor at the load balancer level.
Scenario 3: Disabling Rate Limiting or WAF Rules
HAProxy configurations often include stick-table rate limiting and tcp-request rules. An injection attack could overwrite or neutralize these controls, opening the door to brute force attacks or DDoS amplification.
Scenario 4: Full Configuration Corruption
Even without a specific goal, injecting malformed directives can cause HAProxy to fail on reload, resulting in a denial of service for all traffic passing through the load balancer.
Why This Is Rated High Severity
- No authentication bypass required — the attacker only needs access to the dashboard form (which may be accessible to multiple internal users or, in misconfigured deployments, externally)
- Impact is infrastructure-wide — a single successful injection affects all traffic routed through the HAProxy instance
- Persistence — the injected configuration survives restarts until manually corrected
- Difficult to detect — injected lines blend into a large config file and may not trigger obvious alerts
The Fix
What Changed
The fix introduces a _sanitize() function in haproxy_config.py and applies it to every user-supplied parameter before any of them are written to the configuration file.
# ADDED: Sanitization helper
def _sanitize(value):
if isinstance(value, str):
return ''.join(c for c in value if c.isprintable())
return value
This function iterates over each character in the string and retains only those for which Python's str.isprintable() returns True. Critically, newline characters (\n), carriage returns (\r), and other control characters are not considered printable and are therefore stripped.
Before and After
Before (vulnerable):
# Values arrive directly from HTTP form submission
def update_haproxy_config(frontend_name, frontend_ip, frontend_port, lb_method, protocol, ...):
if is_frontend_exist(frontend_name, frontend_ip, frontend_port):
return f"Frontend already exists."
# No sanitization — values go straight to disk
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
haproxy_cfg.write(f"\nfrontend {frontend_name}\n" ...)
After (fixed):
def update_haproxy_config(frontend_name, frontend_ip, frontend_port, lb_method, protocol, ...):
if is_frontend_exist(frontend_name, frontend_ip, frontend_port):
return f"Frontend already exists."
# Sanitize ALL user-supplied inputs before touching the config file
frontend_name = _sanitize(frontend_name)
frontend_ip = _sanitize(frontend_ip)
frontend_port = _sanitize(frontend_port)
lb_method = _sanitize(lb_method)
protocol = _sanitize(protocol)
backend_name = _sanitize(backend_name)
health_check_link = _sanitize(health_check_link)
header_name = _sanitize(header_name)
header_value = _sanitize(header_value)
sticky_session_type = _sanitize(sticky_session_type)
acl_name = _sanitize(acl_name)
acl_action = _sanitize(acl_action)
acl_backend_name = _sanitize(acl_backend_name)
ssl_cert_path = _sanitize(ssl_cert_path)
ban_duration = _sanitize(ban_duration)
limit_requests = _sanitize(limit_requests)
forbidden_name = _sanitize(forbidden_name)
allowed_ip = _sanitize(allowed_ip)
forbidden_path = _sanitize(forbidden_path)
redirect_domain_name = _sanitize(redirect_domain_name)
root_redirect = _sanitize(root_redirect)
redirect_to = _sanitize(redirect_to)
with open(HAPROXY_CFG, 'a') as haproxy_cfg:
haproxy_cfg.write(f"\nfrontend {frontend_name}\n" ...)
Why This Fix Works
The injection attack relies entirely on the attacker's ability to embed newline characters in form fields. By stripping all non-printable characters — including \n, \r, null bytes, and other control characters — the sanitization function makes it structurally impossible to inject new lines into the configuration file through these fields.
Note that the fix is applied comprehensively: not just to the five originally identified fields, but to all 22 parameters that flow into the configuration writer. This is the right approach. A partial fix that only sanitizes the most obvious fields leaves the door open for injection through less-obvious parameters like ssl_cert_path or acl_action.
What the Fix Doesn't Do (And Why That's Okay Here)
The _sanitize() function doesn't validate that values conform to expected formats (e.g., that frontend_ip is actually a valid IP address, or that frontend_port is a number between 1 and 65535). That kind of semantic validation is a separate, complementary concern — and arguably belongs at the route/form-handling layer rather than the config-writing layer.
The sanitization function's job is specifically to prevent injection, and it does that job well.
Prevention & Best Practices
1. Never Trust User Input at Configuration Boundaries
Any time user-supplied data crosses into a configuration file, a shell command, a database query, or any other interpreted format, it must be sanitized or escaped for that specific context. The general principle: data and instructions must be kept separate.
2. Apply Defense in Depth — Validate at Multiple Layers
- At the form/route layer: Validate that inputs match expected types and formats (regex for IP addresses, integer range checks for ports, allowlist for protocol values like
http/tcp) - At the config-writing layer: Sanitize for the target format (as this fix does)
- At the file-write layer: Consider writing to a temp file and running
haproxy -c -f(HAProxy's built-in config check) before atomically replacing the live config
import subprocess
import tempfile
import shutil
def safe_write_config(new_config_content, config_path):
with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as tmp:
tmp.write(new_config_content)
tmp_path = tmp.name
result = subprocess.run(['haproxy', '-c', '-f', tmp_path], capture_output=True)
if result.returncode != 0:
os.unlink(tmp_path)
raise ValueError(f"Invalid HAProxy config: {result.stderr.decode()}")
shutil.move(tmp_path, config_path)
3. Use Allowlists, Not Blocklists
The _sanitize() function uses an allowlist approach (keep only printable characters) rather than a blocklist (try to remove known bad characters). This is almost always the right choice. Blocklists are fragile — attackers find creative encodings and edge cases. Allowlists are robust by design.
For even stronger validation, consider field-specific allowlists:
import re
def validate_frontend_name(name):
# HAProxy identifiers: alphanumeric, hyphens, underscores only
if not re.match(r'^[a-zA-Z0-9_-]{1,64}$', name):
raise ValueError(f"Invalid frontend name: {name}")
return name
def validate_ip(ip):
import ipaddress
ipaddress.ip_address(ip) # Raises ValueError if invalid
return ip
def validate_port(port):
port_int = int(port)
if not (1 <= port_int <= 65535):
raise ValueError(f"Port out of range: {port}")
return str(port_int)
4. Principle of Least Privilege for File Operations
The process writing the HAProxy configuration file should have the minimum permissions necessary. If possible:
- Run the dashboard process as a dedicated user with write access only to the HAProxy config directory
- Use file integrity monitoring (e.g., AIDE, Tripwire) to alert on unexpected config changes
- Log all configuration changes with the authenticated user's identity
5. Audit All Code Paths That Write to Files
Configuration injection vulnerabilities often lurk in code that writes to .conf, .cfg, .ini, .yaml, or .json files based on user input. When reviewing code, specifically look for:
- f-string or %-formatted strings that include user input being written to files
- Template engines rendering user data into config files
- Shell commands constructed from user input (command injection cousin)
Relevant Security Standards
- OWASP Top 10 A03:2021 – Injection: This vulnerability is a textbook injection attack. The OWASP guidance is clear: treat all user-supplied data as untrusted and validate/escape for the target interpreter.
- CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection'): The specific mechanism here — newline injection — is catalogued as CWE-93.
- CWE-20: Improper Input Validation: The broader category covering all failure-to-validate vulnerabilities.
- NIST SP 800-53 SI-10: Input validation as a system and information integrity control.
Conclusion
This vulnerability is a sharp reminder that infrastructure tooling deserves the same security scrutiny as user-facing applications — arguably more, given the blast radius of a compromised load balancer.
The attack surface here is deceptively simple: a web form, a Python f-string, and a missing sanitization step. But the potential impact — unauthorized traffic routing, disabled security controls, full configuration corruption — is severe and infrastructure-wide.
The fix is equally straightforward, which is part of the lesson: security doesn't always require complex solutions. Stripping non-printable characters from user input before writing it to a line-oriented config file is a small change with a large protective effect.
Key takeaways for developers:
🔑 Every boundary where user data becomes configuration is an injection risk. Identify these boundaries in your codebase and treat them with the same care you'd give SQL queries or shell commands.
🔑 Sanitize comprehensively. The fix here correctly sanitized all 22 parameters, not just the five originally flagged. Partial fixes create a false sense of security.
🔑 Allowlists beat blocklists. Define what's valid and reject everything else, rather than trying to enumerate what's dangerous.
🔑 Validate the output, not just the input. For critical config files, run the target application's own config validator before deploying changes.
Secure infrastructure starts with secure code. Every form field that touches a config file is a trust boundary — treat it like one.
This vulnerability was identified and fixed by automated security scanning. Automated tooling caught what manual review might have missed — a strong argument for integrating security scanners into your CI/CD pipeline.