NGINX Configuration Security Scanning in CI
Problem
NGINX configuration changes are a routine operational task — new virtual hosts, updated proxy rules, module additions, upstream changes. These changes frequently introduce security issues that are not caught before deployment:
SSRF via misconfigured proxy_pass. A proxy_pass http://$host/ directive creates an open proxy that forwards requests to any host specified in the Host header. This pattern appears in legitimate templated configurations but creates a server-side request forgery vulnerability that allows attackers to reach internal services.
Exposed internal locations. A location /internal/ block intended to be accessible only from localhost is deployed without an allow/deny block, making it publicly accessible. Static analysis catches this pattern; manual review misses it.
Module-specific CVE exposure. NGINX modules carry their own CVE history. The ngx_http_mp4_module (CVE-2024-7347), ngx_http_image_filter_module, and the QUIC module (CVE-2024-24989, CVE-2024-24990) are enabled via simple load_module or compilation flags. A CI check can flag when a potentially vulnerable module is enabled for the target NGINX version.
Annotation injection surface in ingress-nginx. The configuration-snippet annotation in Kubernetes Ingress objects is the vector for CVE-2023-5044 and the 2025 ingress-nginx CVE family. A CI gate on Ingress manifests that alerts on snippet annotations prevents this class from being introduced.
Missing security headers. NGINX configurations frequently omit security headers (Content-Security-Policy, Strict-Transport-Security, X-Frame-Options) on virtual hosts that serve HTML. Static analysis can flag missing headers that manual review overlooks.
Without CI enforcement, these issues reach production during the merge window. With CI enforcement, the same developer who introduces the issue is immediately informed, before the code review cycle begins.
Target systems: any team that manages NGINX configuration in version control (direct config files, Ansible templates, Helm charts); CI/CD pipelines for both bare metal/VM NGINX deployments and Kubernetes ingress-nginx.
Threat Model
Adversary 1 — Open proxy via $host variable. A developer adds a proxy_pass directive using $host or $http_host without understanding the open proxy implication. An attacker discovers the open proxy and uses NGINX as a relay to reach internal services (metadata APIs, internal databases). CI scanning blocks the merge before it reaches production.
Adversary 2 — Module CVE introduced via config change. A configuration change enables ngx_http_mp4_module for a new video streaming endpoint on an NGINX version with CVE-2024-7347. No one notices the module activation. CI scanning compares enabled modules against a CVE-to-module mapping for the target version and flags the risk.
Adversary 3 — Snippet annotation injection in Kubernetes manifests. A developer adds nginx.ingress.kubernetes.io/configuration-snippet to a Kubernetes Ingress object to customise header behaviour. The annotation enables the injection class of CVEs. A Conftest policy blocks the manifest from being applied.
Configuration / Implementation
Step 1 — Install and run gixy for NGINX static analysis
gixy is an open-source NGINX configuration static analyser that detects SSRF, alias traversal, add_header inheritance issues, and other common misconfigurations:
# Install gixy
pip install gixy
# Run against NGINX configuration
gixy /etc/nginx/nginx.conf
# Example output for SSRF detection:
# [ERROR] proxy_pass_ssrf: /etc/nginx/conf.d/api.conf:12
# Possible SSRF: proxy_pass http://$host/;
# Consider: use explicit upstream names instead of the $host variable
Create a gixy configuration that controls which checks are enabled:
# .gixy.cfg — in the repository root
[gixy]
# Enable all checks
enable = proxy_pass_ssrf,
alias_traversal,
add_header_redefinition,
add_header_multiline,
host_spoofing,
http_splitting
# Treat all findings as errors (non-zero exit code)
severity = error
Step 2 — Add nginx -t syntax validation
#!/bin/bash
# scripts/nginx-validate.sh
# Validates NGINX configuration syntax before deployment
CONFIG_DIR=${1:-/etc/nginx}
NGINX_BIN=${NGINX_BIN:-nginx}
echo "=== NGINX configuration syntax check ==="
# Use nginx -t with -c to test a specific config file
$NGINX_BIN -t -c "$CONFIG_DIR/nginx.conf" 2>&1
if [[ $? -ne 0 ]]; then
echo "FAIL: NGINX configuration syntax error"
exit 1
fi
echo "PASS: NGINX configuration syntax OK"
For CI environments where NGINX is not installed, use Docker:
#!/bin/bash
# scripts/nginx-validate-docker.sh
CONFIG_DIR=$(realpath "${1:-./nginx}")
docker run --rm \
-v "$CONFIG_DIR:/etc/nginx:ro" \
nginx:1.27 \
nginx -t -c /etc/nginx/nginx.conf 2>&1
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
echo "FAIL: NGINX configuration syntax error"
exit 1
fi
echo "PASS: NGINX configuration valid"
Step 3 — Write OPA/Conftest policies for NGINX Ingress objects
Install Conftest and write policies for Kubernetes Ingress manifests:
# Install conftest
go install github.com/open-policy-agent/conftest@latest
# or
curl -L https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz | tar xz
# policies/ingress-nginx-security.rego
package main
import future.keywords.contains
import future.keywords.if
import future.keywords.in
# Deny: configuration-snippet annotation (CVE-2023-5044 class)
deny contains msg if {
input.kind == "Ingress"
input.metadata.annotations["nginx.ingress.kubernetes.io/configuration-snippet"]
msg := sprintf(
"Ingress %s/%s uses configuration-snippet annotation which is a CVE injection vector. Use configmap-based configuration instead.",
[input.metadata.namespace, input.metadata.name]
)
}
# Deny: server-snippet annotation
deny contains msg if {
input.kind == "Ingress"
input.metadata.annotations["nginx.ingress.kubernetes.io/server-snippet"]
msg := sprintf(
"Ingress %s/%s uses server-snippet annotation. This annotation can be used for privilege escalation.",
[input.metadata.namespace, input.metadata.name]
)
}
# Deny: stream-snippet annotation
deny contains msg if {
input.kind == "Ingress"
input.metadata.annotations["nginx.ingress.kubernetes.io/stream-snippet"]
msg := sprintf(
"Ingress %s/%s uses stream-snippet annotation. Snippet annotations are disabled by policy.",
[input.metadata.namespace, input.metadata.name]
)
}
# Warn: Ingress without TLS configured
warn contains msg if {
input.kind == "Ingress"
count(input.spec.tls) == 0
msg := sprintf(
"Ingress %s/%s has no TLS configuration. All public ingress should use TLS.",
[input.metadata.namespace, input.metadata.name]
)
}
# Deny: auth-url annotation pointing to external hosts (SSRF vector)
deny contains msg if {
input.kind == "Ingress"
auth_url := input.metadata.annotations["nginx.ingress.kubernetes.io/auth-url"]
not startswith(auth_url, "http://authservice.")
not startswith(auth_url, "http://auth.")
not startswith(auth_url, "https://auth.")
msg := sprintf(
"Ingress %s/%s auth-url annotation points to unexpected host: %s. Only approved auth services are allowed.",
[input.metadata.namespace, input.metadata.name, auth_url]
)
}
# Run Conftest against all Ingress manifests
conftest test \
--policy policies/ \
kubernetes/ingress/*.yaml
# Expected output for a clean manifest:
# PASS - kubernetes/ingress/api.yaml
#
# Expected output for a failing manifest:
# FAIL - kubernetes/ingress/broken.yaml
# [FAIL] Ingress production/broken uses configuration-snippet annotation...
Step 4 — Add module CVE exposure check
#!/usr/bin/env python3
# scripts/check-nginx-module-cves.py
# Checks whether any NGINX modules in the configuration are associated with CVEs
# for the target NGINX version
import sys
import re
import subprocess
from typing import NamedTuple
class ModuleCVE(NamedTuple):
module: str
cve: str
affected_versions: str
severity: str
description: str
# Map of modules to CVEs affecting specific version ranges
MODULE_CVES = [
ModuleCVE(
module="ngx_http_mp4_module",
cve="CVE-2024-7347",
affected_versions="< 1.27.1, < 1.26.2",
severity="MEDIUM",
description="Heap buffer overflow in mp4_read_mdat_atom — memory corruption on malformed MP4"
),
ModuleCVE(
module="ngx_http_v3_module",
cve="CVE-2024-24989",
affected_versions="< 1.25.4",
severity="HIGH",
description="NULL pointer dereference in QUIC module — crash/DoS"
),
ModuleCVE(
module="ngx_http_v3_module",
cve="CVE-2024-24990",
affected_versions="< 1.25.4",
severity="HIGH",
description="Use-after-free in QUIC module — potential RCE"
),
]
def get_nginx_version() -> str:
try:
result = subprocess.run(
["nginx", "-v"],
capture_output=True, text=True
)
match = re.search(r"nginx/(\d+\.\d+\.\d+)", result.stderr)
return match.group(1) if match else "unknown"
except FileNotFoundError:
return "unknown"
def find_loaded_modules(config_dir: str) -> list[str]:
"""Find modules loaded in nginx configuration."""
modules = []
try:
result = subprocess.run(
["grep", "-r", "load_module\|http_mp4\|http_image_filter\|http_v3\|quic", config_dir],
capture_output=True, text=True
)
for line in result.stdout.splitlines():
if "load_module" in line:
match = re.search(r'load_module\s+["\']?([^"\';\s]+)', line)
if match:
modules.append(match.group(1))
except Exception:
pass
return modules
def check_compiled_modules() -> list[str]:
"""Check which modules were compiled into NGINX."""
try:
result = subprocess.run(
["nginx", "-V"],
capture_output=True, text=True
)
modules = []
if "with-http_mp4_module" in result.stderr:
modules.append("ngx_http_mp4_module")
if "with-http_v3_module" in result.stderr or "--with-compat" in result.stderr:
modules.append("ngx_http_v3_module")
if "with-http_image_filter_module" in result.stderr:
modules.append("ngx_http_image_filter_module")
return modules
except FileNotFoundError:
return []
if __name__ == "__main__":
config_dir = sys.argv[1] if len(sys.argv) > 1 else "/etc/nginx"
nginx_version = get_nginx_version()
print(f"NGINX version: {nginx_version}")
print(f"Scanning: {config_dir}")
print("")
active_modules = set(find_loaded_modules(config_dir) + check_compiled_modules())
findings = []
for module_cve in MODULE_CVES:
if any(module_cve.module in m or module_cve.module.replace("ngx_", "") in m
for m in active_modules):
findings.append(module_cve)
if not findings:
print("OK: No module CVE exposure detected")
sys.exit(0)
print("FINDINGS: Active modules with associated CVEs:")
for finding in findings:
print(f" [{finding.severity}] {finding.cve}: {finding.module}")
print(f" Affected versions: {finding.affected_versions}")
print(f" Description: {finding.description}")
critical = [f for f in findings if f.severity in ("HIGH", "CRITICAL")]
if critical:
print("\nFAIL: Critical module CVEs detected — update NGINX or disable the affected modules")
sys.exit(1)
else:
print("\nWARN: Module CVEs detected — review and patch when possible")
sys.exit(0)
Step 5 — Integrate into CI pipeline
# .github/workflows/nginx-security.yml
name: NGINX Configuration Security
on:
pull_request:
paths:
- "nginx/**"
- "helm/ingress-nginx/**"
- "kubernetes/ingress/**"
- "ansible/roles/nginx/**"
jobs:
nginx-security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
pip install gixy
curl -L https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin/
- name: NGINX syntax validation
run: |
docker run --rm \
-v "$(pwd)/nginx:/etc/nginx:ro" \
nginx:1.27 \
nginx -t -c /etc/nginx/nginx.conf
- name: gixy static analysis
run: |
gixy nginx/nginx.conf
continue-on-error: false
- name: Conftest policy check — Kubernetes Ingress manifests
run: |
if ls kubernetes/ingress/*.yaml 2>/dev/null; then
conftest test --policy policies/ kubernetes/ingress/*.yaml
else
echo "No Ingress manifests to check"
fi
- name: Module CVE exposure check
run: |
python3 scripts/check-nginx-module-cves.py nginx/
- name: Security header check
run: |
# Verify all server blocks serving HTML include required security headers
grep -l "text/html\|default_type" nginx/conf.d/*.conf | while read conf; do
if ! grep -q "Strict-Transport-Security\|add_header.*HSTS" "$conf"; then
echo "WARN: $conf missing HSTS header"
fi
if ! grep -q "X-Frame-Options\|Content-Security-Policy" "$conf"; then
echo "WARN: $conf missing clickjacking protection"
fi
done
Expected Behaviour
| Check | Without CI gate | With CI gate |
|---|---|---|
proxy_pass http://$host/ in config |
Merges to production; open proxy exploited | gixy flags SSRF at PR time; blocked before merge |
configuration-snippet annotation |
Deployed to cluster; annotation injection possible | Conftest denies the manifest; PR blocked |
| mp4 module enabled on unpatched NGINX | Exposed to CVE-2024-7347 | Module CVE script warns; escalated to team |
| Missing HSTS header on new virtual host | Header absent in production | Security header check warns in CI output |
| Invalid NGINX config syntax | Deployed; NGINX fails to reload; outage | nginx -t catches syntax error in CI |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| gixy blocking on all findings | Prevents misconfigurations | False positives on legitimate $host uses |
Use .gixy.cfg to suppress known-safe patterns with inline exemptions; document each suppression |
| Conftest denying snippet annotations | Prevents CVE-2023-5044 class | Breaks workflows that rely on snippets | Provide alternative configmap-based approach before enforcing the policy; give teams a migration window |
| Docker nginx -t in CI | Tests syntax without local NGINX install | Adds container pull time to CI | Cache the nginx Docker image in CI runner |
| Module CVE script vs. version | Catches known CVE exposure | Requires maintaining the CVE database manually | Integrate with a vulnerability database API to keep the mapping current |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| gixy false positive blocks legitimate config | PR cannot merge despite valid configuration | Pipeline failure with gixy finding | Add # gixy:disable=proxy_pass_ssrf comment with justification; update .gixy.cfg suppression |
| Conftest policy not catching new annotation variant | New annotation injection vector introduced | Post-deploy security review finds new pattern | Update Rego policy; back-test against historical manifests |
| nginx -t passes in CI but fails in production | NGINX version mismatch between CI Docker image and production | Production NGINX fails to reload after deployment | Pin Docker image version to match production; pin production NGINX version to match CI |
| Module CVE script uses outdated CVE list | New CVE not caught by script | CVE published; module in use; script does not flag | Subscribe to NGINX security mailing list; trigger script update on new CVE announcements |
Related Articles
- NGINX Worker Privilege Hardening — OS-level containment that complements pre-deployment scanning
- ingress-nginx Version Pinning — managing ingress-nginx Helm chart versions alongside CI config scanning
- Helm Chart Security Scanning — scanning Helm charts for vulnerabilities; extends to ingress-nginx charts
- OPA Conftest Policy Pipeline — broader use of Conftest for Kubernetes manifest policy enforcement
- NGINX CVE Exploitation Detection — runtime detection of CVE exploitation when pre-deployment scanning doesn’t catch the issue