Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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>";
}
}
}
116 changes: 116 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,116 @@
#!/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