Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions integration_tests/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 "<html><body>Client sent an HTTP request to an HTTPS server.</body></html>";
}

location / {
add_header Content-Type text/html;
return 200 "<html><body>HTTPS OK</body></html>";
}
}
}
117 changes: 117 additions & 0 deletions integration_tests/http/http_fail_to_https_test/test.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions integration_tests/http/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 32 additions & 2 deletions modules/http/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down
Loading