diff --git a/integration_tests/docker-compose.yml b/integration_tests/docker-compose.yml index ce65e042..654a8a85 100644 --- a/integration_tests/docker-compose.yml +++ b/integration_tests/docker-compose.yml @@ -113,6 +113,12 @@ services: container_name: "zgrab_http_h2c" hostname: "http.h2c.target" + http-fail-to-https: + build: + context: ./http/http_fail_to_https_test/container + container_name: "zgrab_http_fail_to_https" + hostname: "target" + ipp-cups: build: context: ./ipp/container-cups diff --git a/integration_tests/http/http_fail_to_https_test/container/Dockerfile b/integration_tests/http/http_fail_to_https_test/container/Dockerfile new file mode 100644 index 00000000..19757208 --- /dev/null +++ b/integration_tests/http/http_fail_to_https_test/container/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:alpine +RUN apk add --no-cache openssl +RUN mkdir -p /etc/nginx/ssl && \ + openssl req -x509 -newkey rsa:2048 -keyout /etc/nginx/ssl/server.key \ + -out /etc/nginx/ssl/server.crt -days 3650 -nodes -subj "/CN=target" +COPY nginx.conf /etc/nginx/nginx.conf diff --git a/integration_tests/http/http_fail_to_https_test/container/nginx.conf b/integration_tests/http/http_fail_to_https_test/container/nginx.conf new file mode 100644 index 00000000..ef691004 --- /dev/null +++ b/integration_tests/http/http_fail_to_https_test/container/nginx.conf @@ -0,0 +1,34 @@ +worker_processes 1; +error_log /dev/stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + access_log /dev/stdout; + + server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/ssl/server.crt; + ssl_certificate_key /etc/nginx/ssl/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # nginx triggers error 497 when a plain HTTP request arrives on an HTTPS + # port. We return the Apache-style message that zgrab2's + # --fail-http-to-https detection logic matches against. + error_page 497 @http_to_https_mismatch; + location @http_to_https_mismatch { + add_header Content-Type text/html always; + return 400 "Client sent an HTTP request to an HTTPS server."; + } + + location / { + add_header Content-Type text/html; + return 200 "HTTPS OK"; + } + } +} diff --git a/integration_tests/http/http_fail_to_https_test/test.py b/integration_tests/http/http_fail_to_https_test/test.py new file mode 100644 index 00000000..70ff947e --- /dev/null +++ b/integration_tests/http/http_fail_to_https_test/test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Integration tests for the --fail-http-to-https flag (PR #735). + +The target container (zgrab_http_fail_to_https) runs nginx configured for +HTTPS-only on port 443. When a plain-text HTTP request arrives, nginx returns +HTTP 400 with "Client sent an HTTP request to an HTTPS server." in the body -- +the same response Apache produces. zgrab2 detects this string and, when +--fail-http-to-https is set, retries over TLS and succeeds. + +Note: --retry-https is intentionally not tested here. That flag retries on +any Grab() failure (connection-level errors), but Grab() treats the nginx 400 +response as a successfully read HTTP reply and returns nil. The body-check +that raises ErrHTTPSProtocolMismatch is only active when --fail-http-to-https +is set, so --retry-https has no effect against this server. +""" + +import os +import json +import subprocess +import sys + + +def run_command(command, output_file=None): + try: + with subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + shell=True, + ) as process: + stdout, stderr = process.communicate() + if output_file: + with open(output_file, "w") as f: + f.write(stdout) + if stderr: + print(stderr, file=sys.stderr) + return stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Command failed: {e}", file=sys.stderr) + sys.exit(1) + + +zgrab_root = run_command("git rev-parse --show-toplevel") +zgrab_output = os.path.join(zgrab_root, "zgrab-output") +output_root = os.path.join(zgrab_output, "http") + +os.makedirs(output_root, exist_ok=True) + +container_name = "zgrab_http_fail_to_https" + + +def _scan(extra_flags, output_file=None): + cmd = ( + f"CONTAINER_NAME={container_name} " + f"{zgrab_root}/docker-runner/docker-run.sh " + f"http --port 443 {extra_flags}" + ) + return run_command(cmd, output_file=output_file) + + +def test_fail_http_to_https_flag(): + """--fail-http-to-https: plain HTTP to HTTPS port triggers retry, scan succeeds.""" + print("http/fail_to_https_test: --fail-http-to-https should retry over TLS") + output_file = os.path.join(output_root, "http_fail_to_https.json") + raw = _scan("--fail-http-to-https", output_file=output_file) + + result = json.loads(raw) + scan = result.get("data", {}).get("http", {}) + + status = scan.get("status") + assert status == "success", f"Expected scan status 'success', got '{status}'" + + status_code = scan.get("result", {}).get("response", {}).get("status_code") + assert status_code == 200, f"Expected HTTP 200 after TLS retry, got {status_code}" + + print("PASS: --fail-http-to-https retried over TLS and got 200 OK") + + +def test_no_flag_returns_400(): + """Without --fail-http-to-https, zgrab2 reads the 400 as a successful HTTP + transaction and does not retry -- the mismatch response is the final result.""" + print("http/fail_to_https_test: plain HTTP without flag should return 400") + # Do not write to the schema-validated output dir; capture inline only. + raw = _scan("") + + result = json.loads(raw) + scan = result.get("data", {}).get("http", {}) + + status = scan.get("status") + assert ( + status == "success" + ), f"Expected scan status 'success' (HTTP transaction completed), got '{status}'" + + status_code = scan.get("result", {}).get("response", {}).get("status_code") + assert ( + status_code == 400 + ), f"Expected HTTP 400 mismatch response without retry flag, got {status_code}" + + print("PASS: plain HTTP without flag returned scan status 'success' with HTTP 400") + + +def run_all_tests(): + tests = { + name: func + for name, func in globals().items() + if name.startswith("test_") and callable(func) + } + for name, test_func in tests.items(): + print(f"=== Running {name} ===") + test_func() + print(f"=== Finished {name} ===\n") + + +if __name__ == "__main__": + run_all_tests() diff --git a/integration_tests/http/test.sh b/integration_tests/http/test.sh index 39eaab25..7a7b461d 100755 --- a/integration_tests/http/test.sh +++ b/integration_tests/http/test.sh @@ -3,3 +3,4 @@ set -e python3 ./http_version_tests/test.py python3 ./http_smoke_test/test.py +python3 ./http_fail_to_https_test/test.py diff --git a/modules/http/scanner.go b/modules/http/scanner.go index e1b626db..9881d36f 100644 --- a/modules/http/scanner.go +++ b/modules/http/scanner.go @@ -39,6 +39,13 @@ var ( // MaxRedirects. ErrTooManyRedirects = errors.New("too many redirects") ErrDoNotRedirect = errors.New("no redirects configured") + + // ErrHTTPSProtocolMismatch is returned by Grab() when --fail-http-to-https is + // set and the server's response shows it is an HTTPS server that received a + // plaintext HTTP request (Apache/NGINX HTTP/400 protocol-mismatch responses). + // It is a sentinel so Scan() can distinguish this specific, retry-worthy + // failure from every other SCAN_PROTOCOL_ERROR. + ErrHTTPSProtocolMismatch = errors.New("NGINX or Apache HTTP over HTTPS failure") ) // Flags holds the command-line configuration for the HTTP scan module. @@ -571,7 +578,7 @@ func (scan *scan) Grab() *zgrab2.ScanError { strings.Contains(sliceBuf, "You're speaking plain HTTP") || strings.Contains(sliceBuf, "combination of host and port requires TLS") || strings.Contains(sliceBuf, "Client sent an HTTP request to an HTTPS server") { - return zgrab2.NewScanError(zgrab2.SCAN_PROTOCOL_ERROR, errors.New("NGINX or Apache HTTP over HTTPS failure")) + return zgrab2.NewScanError(zgrab2.SCAN_PROTOCOL_ERROR, ErrHTTPSProtocolMismatch) } } @@ -606,6 +613,29 @@ func (scan *scan) Grab() *zgrab2.ScanError { return nil } +// shouldRetryOverHTTPS decides whether a failed plaintext-HTTP scan should be +// re-attempted over HTTPS, given the configured flags and the error Grab() +// returned. Two flags can independently justify an HTTPS retry: +// +// - RetryHTTPS: retry on ANY initial failure. Broad and connection-expensive, +// since every failed HTTP scan triggers a second TLS attempt. +// - FailHTTPToHTTPS: retry ONLY when we have positive evidence the server is +// actually speaking HTTPS -- i.e. Grab() returned ErrHTTPSProtocolMismatch +// for a known Apache/NGINX HTTP/400 protocol-mismatch response. +// +// In all cases there is nothing to upgrade to if we already connected over TLS. +func (scanner *Scanner) shouldRetryOverHTTPS(err *zgrab2.ScanError) bool { + if scanner.config.UseHTTPS { + return false + } + // RetryHTTPS retries on any initial failure. + if scanner.config.RetryHTTPS { + return true + } + // FailHTTPToHTTPS stays targeted: retry only on the proven protocol mismatch. + return scanner.config.FailHTTPToHTTPS && errors.Is(err.Err, ErrHTTPSProtocolMismatch) +} + // Scan implements the zgrab2.Scanner interface and performs the full scan of // the target. If the scanner is configured to follow redirects, this may entail // multiple TCP connections to hosts other than target. @@ -617,7 +647,7 @@ func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, defer scan.Cleanup() err := scan.Grab() if err != nil { - if scanner.config.RetryHTTPS && !scanner.config.UseHTTPS { + if scanner.shouldRetryOverHTTPS(err) { scan.Cleanup() retry := scanner.newHTTPScan(ctx, target, true, dialGroup) defer retry.Cleanup()