NGINX Configuration Security Scanning in CI

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