From 7de71d68a1a184cb38fd195cc5ab6a038ddf2878 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 16 Sep 2026 17:40:30 +0200 Subject: [PATCH 01/21] add test method blueprint to tls test module --- modules/test/tls/python/src/tls_module.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/test/tls/python/src/tls_module.py b/modules/test/tls/python/src/tls_module.py index 3b0dc004c..48c743a67 100644 --- a/modules/test/tls/python/src/tls_module.py +++ b/modules/test/tls/python/src/tls_module.py @@ -556,6 +556,10 @@ def _security_tls_v1_3_client(self): '1.3', unsupported_versions=['1.0', '1.1']) + def _security_http_server(self): + LOGGER.info('Running http test') + return True, 'compliant', 'description' + def _validate_tls_client(self, client_mac, tls_version, From eb5b42b0e65b4dd70f0e82e26f851e72482a31c1 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 16 Sep 2026 17:42:42 +0200 Subject: [PATCH 02/21] add http test to README --- modules/test/tls/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/test/tls/README.md b/modules/test/tls/README.md index cfca8100c..80ee76514 100644 --- a/modules/test/tls/README.md +++ b/modules/test/tls/README.md @@ -19,4 +19,5 @@ Within the ```python/src``` directory, the below tests are executed. | security.tls.v1_2_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS v1.2 and support for ECDH and ECDSA ciphers | Required if Applicable | | security.tls.v1_3_server | Check the device web server TLS 1.3 and the certificate is valid | TLS 1.3 certificate is issued to the web browser client when accessed | Informational | | security.tls.v1_3_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS 1.3 using AEAD-based ciphers | Informational | +| security.http.server | HTTP server test | Compliant | Informational | From 7ffbd1628263e3f772c603f9098cc712b49fc9a6 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 16 Sep 2026 17:42:57 +0200 Subject: [PATCH 03/21] add http test to module config --- modules/test/tls/conf/module_config.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/test/tls/conf/module_config.json b/modules/test/tls/conf/module_config.json index 228fbb64d..364d57efa 100644 --- a/modules/test/tls/conf/module_config.json +++ b/modules/test/tls/conf/module_config.json @@ -58,6 +58,14 @@ "Disable connections to unsecure services", "Ensure any URLs connected to are secure (https)" ] + }, + { + "name": "security.http.server", + "test_description": "HTTP server", + "expected_behavior": "HTTP server compliant", + "recommendations": [ + "Implement HEAD method" + ] } ] } From 5a95b51a44381c5bc1ee063708af450f1f5a767f Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 17 Sep 2026 12:29:57 +0200 Subject: [PATCH 04/21] include http test to testpack --- resources/test_packs/pilot/config.json | 4 ++++ resources/test_packs/qualification/config.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/resources/test_packs/pilot/config.json b/resources/test_packs/pilot/config.json index c7c1c47c6..4a417689c 100644 --- a/resources/test_packs/pilot/config.json +++ b/resources/test_packs/pilot/config.json @@ -176,6 +176,10 @@ { "name": "security.tls.v1_3_client", "required_result": "Informational" + }, + { + "name": "security.http.server", + "required_result": "Informational" } ] } \ No newline at end of file diff --git a/resources/test_packs/qualification/config.json b/resources/test_packs/qualification/config.json index 8d3e26758..21c9c0dec 100644 --- a/resources/test_packs/qualification/config.json +++ b/resources/test_packs/qualification/config.json @@ -176,6 +176,10 @@ { "name": "security.tls.v1_3_client", "required_result": "Informational" + }, + { + "name": "security.http.server", + "required_result": "Informational" } ] } \ No newline at end of file From bf7977297880665036aa59fbfc6ff97825a180d3 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 18 Sep 2026 19:19:59 +0200 Subject: [PATCH 05/21] http server checks method --- modules/test/tls/python/src/http_scan.py | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index 8df21d26e..9450a5acc 100644 --- a/modules/test/tls/python/src/http_scan.py +++ b/modules/test/tls/python/src/http_scan.py @@ -20,6 +20,11 @@ LOGGER = None requests.packages.urllib3.disable_warnings(InsecureRequestWarning) +_MSG_HTTP_NOT_DETECTED = 'No HTTP server detected on the device.' +_MSG_HTTP_COMPLIANT = 'Device HTTP server is COMPLIANT with the RFC 9110' +_MSG_HTTP_NON_COMPLIANT = 'Device HTTP server is NON-COMPLIANT with the RFC 9110' +_MSG_HTTP_ERROR = 'Error checking HTTP server.' + class HTTPScan(): """Helper class to scan for all HTTP/HTTPS services for a device""" @@ -128,3 +133,86 @@ def scan_for_http_services(self, ip_address): for port, service_type in results.items(): LOGGER.info(f'Port {port}: {service_type}') return results + + def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: + """Checks if HTTP GET and HEAD methods are supported.""" + result_state = 'Feature Not Detected' + result_message = _MSG_HTTP_NOT_DETECTED + result_details = [] + ports = self.scan_all_ports(ip) + if not ports: + msg = f'No HTTP/HTTPS ports found on {ip}' + LOGGER.info(msg) + return result_state, result_message, result_details + if '443' in ports: + url = f'https://{ip}' + else: + url = f'http://{ip}' + LOGGER.info(f'Checking HTTP methods on {url}') + try: + get_resp = requests.get(url, timeout=20, verify=False) # nosec B501 + get_ok = get_resp.status_code == 200 + head_resp = requests.head(url, timeout=20, verify=False) # nosec B501 + head_ok = head_resp.status_code == 200 + method_resp = requests.request( + 'foobar', + url, + timeout=20, + verify=False) # nosec B501 + except requests.exceptions.RequestException as e: + LOGGER.error(f'Error checking HTTP methods on {url}: {e}') + return 'Error', _MSG_HTTP_ERROR, [] + if not get_ok or not head_ok: + LOGGER.info(f'HTTP GET/HEAD method is not supported on {url}') + result_state = 'Non-Compliant' + result_message = _MSG_HTTP_NON_COMPLIANT + if not get_ok: + result_details.append('Server does not support GET') + if not head_ok: + result_details.append('Server does not support HEAD') + elif get_ok and head_ok: + LOGGER.info(f'HTTP GET and HEAD methods are supported on {url}') + result_state = 'Compliant' + result_message = _MSG_HTTP_COMPLIANT + result_details.extend([ + 'Server supports GET method', + 'Server supports HEAD method' + ]) + head_body_len = len(head_resp.content) + head_body_empty = head_body_len == 0 + headers_match = True + header_not_match = '' + for header in ('Content-Length', 'Content-Type'): + if header in get_resp.headers or header in head_resp.headers: + if get_resp.headers.get(header) != head_resp.headers.get(header): + headers_match = False + header_not_match = header + break + if not head_body_empty or not headers_match: + result_state = 'Non-Compliant' + result_message = _MSG_HTTP_NON_COMPLIANT + if not head_body_empty: + LOGGER.info('HEAD response body is not empty') + result_details.append('HEAD response body is not empty') + elif not headers_match: + LOGGER.info(f'{header_not_match} does not match between GET and HEAD') + result_details.append(f'Header {header_not_match} does not match.') + else: + LOGGER.info('HEAD response body is empty.') + LOGGER.info('Headers match between GET and HEAD methods.') + result_details.extend([ + 'HEAD response body is empty', + 'Headers match between GET and HEAD methods' + ]) + if method_resp.status_code == 501 or method_resp.status_code == 405: + msg = 'Device server returns 501 or 405 for unsupported methods' + LOGGER.info(msg) + result_details.append(msg) + else: + result_state = 'Non-Compliant' + result_message = _MSG_HTTP_NON_COMPLIANT + msg = 'Device server does not return 501 or 405 for unsupported methods' + LOGGER.info(msg) + result_details.append(msg) + + return result_state, result_message, result_details From b11fd56f32304f5e8605cb719fd8374f2121fdbe Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 18 Sep 2026 19:20:09 +0200 Subject: [PATCH 06/21] security https server --- modules/test/tls/python/src/tls_module.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/test/tls/python/src/tls_module.py b/modules/test/tls/python/src/tls_module.py index 48c743a67..020aedd26 100644 --- a/modules/test/tls/python/src/tls_module.py +++ b/modules/test/tls/python/src/tls_module.py @@ -557,8 +557,10 @@ def _security_tls_v1_3_client(self): unsupported_versions=['1.0', '1.1']) def _security_http_server(self): - LOGGER.info('Running http test') - return True, 'compliant', 'description' + LOGGER.info('Running HTTP server test') + return self._http_scan.http_server_compliance( + self._device_ipv4_addr + ) def _validate_tls_client(self, client_mac, From 3bbf71ba37365cd0c4a3241ee78cbb4154b37956 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 21 Sep 2026 11:23:03 +0200 Subject: [PATCH 07/21] pylint --- modules/test/tls/python/src/http_scan.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index 9450a5acc..49cfa1880 100644 --- a/modules/test/tls/python/src/http_scan.py +++ b/modules/test/tls/python/src/http_scan.py @@ -22,7 +22,7 @@ _MSG_HTTP_NOT_DETECTED = 'No HTTP server detected on the device.' _MSG_HTTP_COMPLIANT = 'Device HTTP server is COMPLIANT with the RFC 9110' -_MSG_HTTP_NON_COMPLIANT = 'Device HTTP server is NON-COMPLIANT with the RFC 9110' +_MSG_HTTP_NON = 'Device HTTP server is NON-COMPLIANT with the RFC 9110' _MSG_HTTP_ERROR = 'Error checking HTTP server.' @@ -165,7 +165,7 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: if not get_ok or not head_ok: LOGGER.info(f'HTTP GET/HEAD method is not supported on {url}') result_state = 'Non-Compliant' - result_message = _MSG_HTTP_NON_COMPLIANT + result_message = _MSG_HTTP_NON if not get_ok: result_details.append('Server does not support GET') if not head_ok: @@ -190,7 +190,7 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: break if not head_body_empty or not headers_match: result_state = 'Non-Compliant' - result_message = _MSG_HTTP_NON_COMPLIANT + result_message = _MSG_HTTP_NON if not head_body_empty: LOGGER.info('HEAD response body is not empty') result_details.append('HEAD response body is not empty') @@ -204,13 +204,13 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: 'HEAD response body is empty', 'Headers match between GET and HEAD methods' ]) - if method_resp.status_code == 501 or method_resp.status_code == 405: + if method_resp.status_code in (405, 501): msg = 'Device server returns 501 or 405 for unsupported methods' LOGGER.info(msg) result_details.append(msg) else: result_state = 'Non-Compliant' - result_message = _MSG_HTTP_NON_COMPLIANT + result_message = _MSG_HTTP_NON msg = 'Device server does not return 501 or 405 for unsupported methods' LOGGER.info(msg) result_details.append(msg) From 8347adcbce519fa7649d777ebf16b017ef31951d Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 25 Sep 2026 18:14:34 +0200 Subject: [PATCH 08/21] http server code --- test_vm/python/src/http_server.py | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test_vm/python/src/http_server.py diff --git a/test_vm/python/src/http_server.py b/test_vm/python/src/http_server.py new file mode 100644 index 000000000..aba392707 --- /dev/null +++ b/test_vm/python/src/http_server.py @@ -0,0 +1,39 @@ +import sys +from http.server import HTTPServer, BaseHTTPRequestHandler + +class NonCompliantBackendHandler(BaseHTTPRequestHandler): + + def log_message(self, format, *args): + pass + + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', '25') + self.end_headers() + self.wfile.write(b'GET response body content') + + def do_HEAD(self): + self.send_response(405) + self.send_header('Content-Type', 'text/plain') + self.send_header('Content-Length', '18') + self.end_headers() + self.wfile.write(b'Method Not Allowed') + + def dispatch_custom(self): + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.send_header('Content-Length', '2') + self.end_headers() + self.wfile.write(b'OK') + + def __getattr__(self, name): + if name.startswith('do_'): + return self.dispatch_custom + raise AttributeError(name) + +if __name__ == '__main__': + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + server_address = ('127.0.0.1', port) + httpd = HTTPServer(server_address, NonCompliantBackendHandler) + httpd.serve_forever() From e0e3c674187ef306d45a5a0e1c8476791ee90398 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 25 Sep 2026 18:14:51 +0200 Subject: [PATCH 09/21] non-compliant http test --- test_vm/provision-non-compliant-tls.yml | 94 ++++++++++++++++++------- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/test_vm/provision-non-compliant-tls.yml b/test_vm/provision-non-compliant-tls.yml index 4dc419c59..8b5209d36 100644 --- a/test_vm/provision-non-compliant-tls.yml +++ b/test_vm/provision-non-compliant-tls.yml @@ -1,9 +1,10 @@ --- -- name: Non-compliant TLS handshakes from 10.10.10.14 via ens5 every 2s (with pause and logging) +- name: Non-compliant TLS handshakes and HTTP server from 10.10.10.14 via ens5 hosts: all become: yes vars: fail_tls_script: /usr/local/bin/fail_tls_handshake.sh + http_server_script: /usr/local/bin/http_server.py bind_ip: "10.10.10.14" iface: "ens5" target_host: "www.google.com" @@ -12,12 +13,13 @@ ssl_key: /etc/ssl/private/nginx-selfsigned.key tasks: - - name: Ensure openssl, dhclient, nginx and build dependencies are installed + - name: Ensure openssl, dhclient, nginx, python3 and build dependencies are installed apt: name: - openssl - isc-dhcp-client - nginx + - python3 - perl - pkg-config - zlib1g-dev @@ -78,8 +80,7 @@ chdir: /tmp/openssl-3.1.4 creates: /opt/openssl-3.1.4/bin/openssl - - # NGINX + # --- PYTHON BACKEND & NGINX PROXY SETUP --- - name: Generate self-signed SSL certificate for nginx command: > @@ -89,26 +90,49 @@ args: creates: "{{ ssl_cert }}" - - name: Create nginx HTTPS config + - name: Deploy Non-Compliant Python Backend script + copy: + src: ./python/src/http_server.py + dest: "{{ http_server_script }}" + mode: '0755' + owner: vagrant + group: vagrant + + - name: Create systemd service for Python Backend + copy: + dest: /etc/systemd/system/non-compliant-backend.service + content: | + [Unit] + Description=Non-Compliant Python Backend Server + After=network.target + + [Service] + Type=simple + ExecStart=/usr/bin/python3 {{ http_server_script }} 8080 + Restart=always + + [Install] + WantedBy=multi-user.target + + - name: Create nginx Reverse Proxy config copy: dest: /etc/nginx/sites-available/ssl_default content: | server { - listen 443 ssl; + listen 443 ssl default_server; + listen 80 default_server; server_name _; + ssl_certificate {{ ssl_cert }}; ssl_certificate_key {{ ssl_key }}; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; + location / { - return 200 'nginx HTTPS is up\n'; - } - } - server { - listen 80; - server_name _; - location / { - return 200 'nginx HTTP is up\n'; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_buffering off; } } @@ -124,12 +148,20 @@ path: /etc/nginx/sites-enabled/default state: absent + - name: Start non-compliant Python backend service + systemd: + name: non-compliant-backend.service + state: restarted + enabled: yes + - name: Ensure nginx is started and enabled systemd: name: nginx state: restarted enabled: yes + # --- END PYTHON BACKEND & NGINX SETUP --- + - name: Create custom OpenSSL config for legacy protocol support copy: dest: /etc/ssl/openssl-legacy.cnf @@ -178,7 +210,6 @@ echo "Running TLS 1.3 handshake..." >> $LOG "$NEW_OPENSSL" s_client -connect tls-v1-2.badssl.com:1012 -tls1_3 - - name: Create systemd service for failing TLS handshake copy: dest: /etc/systemd/system/fail-tls-handshake.service @@ -205,7 +236,8 @@ WantedBy=timers.target - name: Reload systemd - shell: systemctl daemon-reload + systemd: + daemon_reload: yes - name: Enable and start fail-tls-handshake.timer systemd: @@ -213,7 +245,7 @@ enabled: yes state: started - # BLOCK ICMP + # BLOCK ICMP / FIREWALL - name: Ensure UFW is installed apt: @@ -221,19 +253,33 @@ state: present - name: Set UFW default policy to deny incoming - command: ufw default deny incoming + ufw: + default: deny + direction: incoming - name: Set UFW default policy to allow outgoing - command: ufw default allow outgoing + ufw: + default: allow + direction: outgoing - name: Allow SSH - command: ufw allow 22/tcp + ufw: + rule: allow + port: '22' + proto: tcp - name: Allow HTTP - command: ufw allow 80/tcp + ufw: + rule: allow + port: '80' + proto: tcp - - name: Allow SNMP - command: ufw allow 443/tcp + - name: Allow HTTPS + ufw: + rule: allow + port: '443' + proto: tcp - name: Reload UFW - command: ufw reload + ufw: + state: reloaded \ No newline at end of file From 928add4abac097cdee8f49b1853e20fdd100dadf Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 25 Sep 2026 18:35:20 +0200 Subject: [PATCH 10/21] pylint --- test_vm/python/src/http_server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test_vm/python/src/http_server.py b/test_vm/python/src/http_server.py index aba392707..61894899c 100644 --- a/test_vm/python/src/http_server.py +++ b/test_vm/python/src/http_server.py @@ -1,11 +1,16 @@ +"""Minimal HTTP backend used by the non-compliant VM test.""" import sys from http.server import HTTPServer, BaseHTTPRequestHandler + class NonCompliantBackendHandler(BaseHTTPRequestHandler): + """Serve deliberately non-compliant HTTP responses for VM tests.""" - def log_message(self, format, *args): + def log_message(self, log_format, *args): pass + # These names are required by BaseHTTPRequestHandler's dispatch protocol. + # pylint: disable=invalid-name def do_GET(self): self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') @@ -19,6 +24,7 @@ def do_HEAD(self): self.send_header('Content-Length', '18') self.end_headers() self.wfile.write(b'Method Not Allowed') + # pylint: enable=invalid-name def dispatch_custom(self): self.send_response(200) From ddcedcbf84dc2fe7050937690c9b8134bde2fd9a Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Mon, 28 Sep 2026 14:02:21 +0200 Subject: [PATCH 11/21] refactor http server test --- modules/test/tls/python/src/http_scan.py | 161 ++++++++++++++--------- 1 file changed, 100 insertions(+), 61 deletions(-) diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index 49cfa1880..ea7f69252 100644 --- a/modules/test/tls/python/src/http_scan.py +++ b/modules/test/tls/python/src/http_scan.py @@ -134,76 +134,113 @@ def scan_for_http_services(self, ip_address): LOGGER.info(f'Port {port}: {service_type}') return results + def _http_make_request( + self, method: str, + url: str, + timeout: int = 20 + ) -> requests.Response | None: + """Sends HTTP request and handles exceptions uniformly. + + Returns: + requests.Response | None + """ + + try: + resp = requests.request( + method=method, + url=url, + timeout=timeout, + verify=False # nosec B501 + ) + return resp + except requests.exceptions.Timeout as e: + msg = f'Timeout during {method.upper()} request to {url}' + LOGGER.error(f'{msg}: {e}') + return None + except requests.exceptions.ConnectionError as e: + msg = f'Connection refused during {method.upper()} request to {url}' + LOGGER.error(f'{msg}: {e}') + return None + def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: - """Checks if HTTP GET and HEAD methods are supported.""" + """Checks HTTP server comliance to RFC 9110.""" result_state = 'Feature Not Detected' result_message = _MSG_HTTP_NOT_DETECTED result_details = [] - ports = self.scan_all_ports(ip) - if not ports: - msg = f'No HTTP/HTTPS ports found on {ip}' - LOGGER.info(msg) - return result_state, result_message, result_details - if '443' in ports: - url = f'https://{ip}' - else: - url = f'http://{ip}' - LOGGER.info(f'Checking HTTP methods on {url}') try: - get_resp = requests.get(url, timeout=20, verify=False) # nosec B501 - get_ok = get_resp.status_code == 200 - head_resp = requests.head(url, timeout=20, verify=False) # nosec B501 - head_ok = head_resp.status_code == 200 - method_resp = requests.request( - 'foobar', - url, - timeout=20, - verify=False) # nosec B501 - except requests.exceptions.RequestException as e: - LOGGER.error(f'Error checking HTTP methods on {url}: {e}') - return 'Error', _MSG_HTTP_ERROR, [] - if not get_ok or not head_ok: - LOGGER.info(f'HTTP GET/HEAD method is not supported on {url}') - result_state = 'Non-Compliant' - result_message = _MSG_HTTP_NON - if not get_ok: - result_details.append('Server does not support GET') - if not head_ok: - result_details.append('Server does not support HEAD') - elif get_ok and head_ok: - LOGGER.info(f'HTTP GET and HEAD methods are supported on {url}') - result_state = 'Compliant' - result_message = _MSG_HTTP_COMPLIANT - result_details.extend([ - 'Server supports GET method', - 'Server supports HEAD method' - ]) - head_body_len = len(head_resp.content) - head_body_empty = head_body_len == 0 - headers_match = True - header_not_match = '' - for header in ('Content-Length', 'Content-Type'): - if header in get_resp.headers or header in head_resp.headers: - if get_resp.headers.get(header) != head_resp.headers.get(header): - headers_match = False - header_not_match = header - break - if not head_body_empty or not headers_match: + ports = self.scan_all_ports(ip) + if not ports: + msg = f'No HTTP/HTTPS ports found on {ip}' + LOGGER.info(msg) + return result_state, result_message, result_details + if '443' in ports: + url = f'https://{ip}' + else: + url = f'http://{ip}' + LOGGER.info(f'Checking HTTP methods on {url}') + try: + get_resp = self._http_make_request('GET', url) + if get_resp is None: + get_ok = False + else: + get_ok = get_resp.status_code == 200 + head_resp = self._http_make_request('HEAD', url) + if head_resp is None: + head_ok = False + else: + head_ok = head_resp.status_code == 200 + method_resp = self._http_make_request('foobar', url) + except requests.exceptions.RequestException as e: + LOGGER.error(f'Error checking HTTP methods on {url}: {e}') + return 'Error', _MSG_HTTP_ERROR, [] + if not get_ok or not head_ok: + LOGGER.info(f'HTTP GET/HEAD method is not supported on {url}') result_state = 'Non-Compliant' result_message = _MSG_HTTP_NON - if not head_body_empty: - LOGGER.info('HEAD response body is not empty') - result_details.append('HEAD response body is not empty') - elif not headers_match: - LOGGER.info(f'{header_not_match} does not match between GET and HEAD') - result_details.append(f'Header {header_not_match} does not match.') - else: - LOGGER.info('HEAD response body is empty.') - LOGGER.info('Headers match between GET and HEAD methods.') + if not get_ok: + result_details.append('Server does not support GET method') + else: + result_details.append('Server supports GET method') + if not head_ok: + result_details.append('Server does not support HEAD method') + else: + result_details.append('Server supports HEAD method') + elif get_ok and head_ok: + LOGGER.info(f'HTTP GET and HEAD methods are supported on {url}') + result_state = 'Compliant' + result_message = _MSG_HTTP_COMPLIANT result_details.extend([ - 'HEAD response body is empty', - 'Headers match between GET and HEAD methods' + 'Server supports GET method', + 'Server supports HEAD method' ]) + if head_resp is not None and get_resp is not None: + head_body_len = len(head_resp.content) + head_body_empty = head_body_len == 0 + headers_match = True + header_not_match = '' + for header in ('Content-Length', 'Content-Type'): + if header in get_resp.headers or header in head_resp.headers: + if get_resp.headers.get(header) != head_resp.headers.get(header): + headers_match = False + header_not_match = header + break + if not head_body_empty or not headers_match: + result_state = 'Non-Compliant' + result_message = _MSG_HTTP_NON + if not head_body_empty: + LOGGER.info('HEAD response body is not empty') + result_details.append('HEAD response body is not empty') + elif not headers_match: + msg = f'{header_not_match} does not match between GET and HEAD' + LOGGER.info(msg) + result_details.append(f'Header {header_not_match} does not match') + else: + LOGGER.info('HEAD response body is empty.') + LOGGER.info('Headers match between GET and HEAD methods.') + result_details.extend([ + 'HEAD response body is empty', + 'Headers match between GET and HEAD methods' + ]) if method_resp.status_code in (405, 501): msg = 'Device server returns 501 or 405 for unsupported methods' LOGGER.info(msg) @@ -214,5 +251,7 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: msg = 'Device server does not return 501 or 405 for unsupported methods' LOGGER.info(msg) result_details.append(msg) + except Exception as e: + LOGGER.error(f'Unexpected error {e}') return result_state, result_message, result_details From d36549549f13812c4da87cda46c964e6b84d6dd6 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 14 Oct 2026 15:20:27 +0200 Subject: [PATCH 12/21] no ports test --- modules/test/tls/python/src/http_scan.py | 8 +++++++- testing/unit/tls/tls_module_test.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index ea7f69252..0b8ba2e4b 100644 --- a/modules/test/tls/python/src/http_scan.py +++ b/modules/test/tls/python/src/http_scan.py @@ -241,7 +241,13 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: 'HEAD response body is empty', 'Headers match between GET and HEAD methods' ]) - if method_resp.status_code in (405, 501): + if method_resp is None: + result_state = 'Non-Compliant' + result_message = _MSG_HTTP_NON + msg = 'Device server does not return 501 or 405 for unsupported methods' + LOGGER.info(msg) + result_details.append(msg) + elif method_resp.status_code in (405, 501): msg = 'Device server returns 501 or 405 for unsupported methods' LOGGER.info(msg) result_details.append(msg) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index 77e14e1d4..c30345550 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -14,6 +14,7 @@ """Module run all the TLS related unit tests""" from tls_module import TLSModule from tls_util import TLSUtil +from http_scan import HTTPScan from common import logger import os import unittest @@ -76,6 +77,8 @@ def setUp(self): self.tls_module = TLSModule(module=MODULE, conf_file=CONF_FILE) self.tls_module._device_ipv4_addr = None # pylint: disable=W0212 self.tls_module._scan_results = None # pylint: disable=W0212 + self.mock_logger = unittest.mock.MagicMock() + self.scanner = HTTPScan(self.mock_logger) @patch('tls_module.TLSModule._get_device_ipv4') def security_tls_v1_2_server_no_ip_test(self, mock_get_device_ipv4): @@ -907,6 +910,17 @@ def download_public_cert(self, hostname, port=443): with open(cert_path, 'w', encoding='utf-8') as cert_file: cert_file.write(pem_cert.decode()) + @patch.object(HTTPScan, 'scan_all_ports') + def test_http_server_no_ports(self, mock_scan_ports): + """No HTTP ports detected. Feature Not Detected.""" + mock_scan_ports.return_value = [] + + state, message, details = self.scanner.http_server_compliance('10.10.10.14') + + self.assertEqual(state, 'Feature Not Detected') + self.assertEqual(message, 'No HTTP server detected on the device.') + self.assertEqual(details, []) + if __name__ == '__main__': suite = unittest.TestSuite() @@ -972,6 +986,9 @@ def download_public_cert(self, hostname, port=443): suite.addTest(TLSModuleTest('outbound_connections_test')) suite.addTest(TLSModuleTest('outbound_connections_report_test')) + # Test HTTP server + suite.addTest(TLSModuleTest('test_http_server_no_ports')) + runner = unittest.TextTestRunner() test_result = runner.run(suite) From 8f91ffbe4a5b5931d71eb0d61727bf3c1aa7e7f7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 14 Oct 2026 18:27:04 +0200 Subject: [PATCH 13/21] http server complian test --- testing/unit/tls/tls_module_test.py | 37 +++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index c30345550..502df8a67 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -911,7 +911,7 @@ def download_public_cert(self, hostname, port=443): cert_file.write(pem_cert.decode()) @patch.object(HTTPScan, 'scan_all_ports') - def test_http_server_no_ports(self, mock_scan_ports): + def test_http_server_not_detected(self, mock_scan_ports): """No HTTP ports detected. Feature Not Detected.""" mock_scan_ports.return_value = [] @@ -921,6 +921,38 @@ def test_http_server_no_ports(self, mock_scan_ports): self.assertEqual(message, 'No HTTP server detected on the device.') self.assertEqual(details, []) + @patch.object(HTTPScan, '_http_make_request') + @patch.object(HTTPScan, 'scan_all_ports') + def test_http_server_compliant(self, mock_scan_ports, mock_make_request): + """Test when the server fully complies with RFC 9110.""" + mock_scan_ports.return_value = ['443'] + + get_resp = unittest.mock.MagicMock(status_code=200) + get_resp.headers = {'Content-Length': '50', 'Content-Type': 'text/html'} + + head_resp = unittest.mock.MagicMock(status_code=200) + head_resp.headers = {'Content-Length': '50', 'Content-Type': 'text/html'} + head_resp.content = b'' + + method_resp = unittest.mock.MagicMock(status_code=405) + + mock_make_request.side_effect = [get_resp, head_resp, method_resp] + + state, message, details = self.scanner.http_server_compliance('10.10.10.14') + + self.assertEqual(state, 'Compliant') + self.assertEqual(message, + 'Device HTTP server is COMPLIANT with the RFC 9110' + ) + self.assertIn('Server supports GET method', details) + self.assertIn('Server supports HEAD method', details) + self.assertIn('Headers match between GET and HEAD methods', + details) + self.assertIn('HEAD response body is empty', details) + self.assertIn('Device server returns 501 or 405 for unsupported methods', + details + ) + if __name__ == '__main__': suite = unittest.TestSuite() @@ -987,7 +1019,8 @@ def test_http_server_no_ports(self, mock_scan_ports): suite.addTest(TLSModuleTest('outbound_connections_report_test')) # Test HTTP server - suite.addTest(TLSModuleTest('test_http_server_no_ports')) + suite.addTest(TLSModuleTest('test_http_server_not_detected')) + suite.addTest(TLSModuleTest('test_http_server_compliant')) runner = unittest.TextTestRunner() test_result = runner.run(suite) From dbf6de2769c0c431100536957f409aba59ddb7f5 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 14 Oct 2026 20:25:58 +0200 Subject: [PATCH 14/21] http server body not empty test --- testing/unit/tls/tls_module_test.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index 502df8a67..b319ddfeb 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -954,6 +954,62 @@ def test_http_server_compliant(self, mock_scan_ports, mock_make_request): ) + @patch.object(HTTPScan, '_http_make_request') + @patch.object(HTTPScan, 'scan_all_ports') + def test_http_server_non_compliant_status_codes( + self, + mock_scan_ports, + mock_make_request + ): + """Test compliance check when GET/HEAD return non-200 status codes.""" + mock_scan_ports.return_value = ['80'] + + get_resp = unittest.mock.MagicMock(status_code=404) + head_resp = unittest.mock.MagicMock(status_code=404) + method_resp = unittest.mock.MagicMock(status_code=406) + + mock_make_request.side_effect = [get_resp, head_resp, method_resp] + + state, message, details = self.scanner.http_server_compliance('10.10.10.14') + + self.assertEqual(state, 'Non-Compliant') + self.assertEqual(message, + 'Device HTTP server is NON-COMPLIANT with the RFC 9110' + ) + self.assertIn('Server does not support GET method', details) + self.assertIn('Server does not support HEAD method', details) + self.assertIn( + 'Device server does not return 501 or 405 for unsupported methods', + details + ) + + @patch.object(HTTPScan, '_http_make_request') + @patch.object(HTTPScan, 'scan_all_ports') + def test_http_server_head_body_not_empty( + self, + mock_scan_ports, + mock_make_request + ): + """HEAD response contains a non-empty body.""" + mock_scan_ports.return_value = ['80'] + + get_resp = unittest.mock.MagicMock(status_code=200) + get_resp.headers = {} + + head_resp = unittest.mock.MagicMock(status_code=200) + head_resp.headers = {} + head_resp.content = b'unexpected body content' + + method_resp = unittest.mock.MagicMock(status_code=501) + + mock_make_request.side_effect = [get_resp, head_resp, method_resp] + + state, _, details = self.scanner.http_server_compliance('10.10.10.14') + + self.assertEqual(state, 'Non-Compliant') + self.assertIn('HEAD response body is not empty', details) + + if __name__ == '__main__': suite = unittest.TestSuite() suite.addTest(TLSModuleTest('client_hello_packets_test')) @@ -1021,6 +1077,8 @@ def test_http_server_compliant(self, mock_scan_ports, mock_make_request): # Test HTTP server suite.addTest(TLSModuleTest('test_http_server_not_detected')) suite.addTest(TLSModuleTest('test_http_server_compliant')) + suite.addTest(TLSModuleTest('test_http_server_non_compliant_status_codes')) + suite.addTest(TLSModuleTest('test_http_server_head_body_not_empty')) runner = unittest.TextTestRunner() test_result = runner.run(suite) From 5580dc47addfe06524dba274464631d0122d5a11 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Wed, 14 Oct 2026 20:38:17 +0200 Subject: [PATCH 15/21] test exception --- testing/unit/tls/tls_module_test.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index b319ddfeb..dd5261fd9 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -26,6 +26,7 @@ import ssl import shutil import logging +import requests import socket import sys from cryptography import x509 @@ -1009,6 +1010,21 @@ def test_http_server_head_body_not_empty( self.assertEqual(state, 'Non-Compliant') self.assertIn('HEAD response body is not empty', details) + @patch.object(HTTPScan, '_http_make_request') + @patch.object(HTTPScan, 'scan_all_ports') + def test_https_server_exception(self, mock_scan_ports, mock_make_request): + """Test error handling;""" + mock_scan_ports.return_value = ['80'] + mock_make_request.side_effect = requests.exceptions.RequestException( + "Network failure" + ) + + state, message, details = self.scanner.http_server_compliance('10.10.10.14') + + self.assertEqual(state, 'Error') + self.assertEqual(message, 'Error checking HTTP server.') + self.assertEqual(details, []) + if __name__ == '__main__': suite = unittest.TestSuite() @@ -1079,6 +1095,7 @@ def test_http_server_head_body_not_empty( suite.addTest(TLSModuleTest('test_http_server_compliant')) suite.addTest(TLSModuleTest('test_http_server_non_compliant_status_codes')) suite.addTest(TLSModuleTest('test_http_server_head_body_not_empty')) + suite.addTest(TLSModuleTest('test_https_server_exception')) runner = unittest.TextTestRunner() test_result = runner.run(suite) From 784333ea5405dbf08409191ee59ca41143a17a9a Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Thu, 15 Oct 2026 09:52:48 +0200 Subject: [PATCH 16/21] pylint --- testing/unit/tls/tls_module_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index dd5261fd9..1b26ffa4a 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -1016,7 +1016,7 @@ def test_https_server_exception(self, mock_scan_ports, mock_make_request): """Test error handling;""" mock_scan_ports.return_value = ['80'] mock_make_request.side_effect = requests.exceptions.RequestException( - "Network failure" + 'Network failure' ) state, message, details = self.scanner.http_server_compliance('10.10.10.14') From 48c5712dfe8496e924addd0cb92f720d3473cbf3 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 16 Oct 2026 16:05:07 +0200 Subject: [PATCH 17/21] http server test documentation --- modules/test/tls/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/test/tls/README.md b/modules/test/tls/README.md index 80ee76514..2a93786c1 100644 --- a/modules/test/tls/README.md +++ b/modules/test/tls/README.md @@ -19,5 +19,5 @@ Within the ```python/src``` directory, the below tests are executed. | security.tls.v1_2_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS v1.2 and support for ECDH and ECDSA ciphers | Required if Applicable | | security.tls.v1_3_server | Check the device web server TLS 1.3 and the certificate is valid | TLS 1.3 certificate is issued to the web browser client when accessed | Informational | | security.tls.v1_3_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS 1.3 using AEAD-based ciphers | Informational | -| security.http.server | HTTP server test | Compliant | Informational | +| security.http.server | Automated RFC 9110 compliance validation of the device's HTTP/HTTPS server | The server successfully responds with status 200 OK for both GET and HEAD methods with an empty HEAD body and matching critical headers, returns 405 or 501 for unsupported methods | Informational | From c669e08cdb46ad7252bca8fa0b21bf1cbef7cc1e Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 16 Oct 2026 16:13:57 +0200 Subject: [PATCH 18/21] add http test to main documantation --- docs/test/compliant.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/test/compliant.md b/docs/test/compliant.md index 2ad2b1f2c..e2819a2d7 100644 --- a/docs/test/compliant.md +++ b/docs/test/compliant.md @@ -111,4 +111,9 @@ Strict cryptographic standards apply to all inbound and outbound traffic via web ### Inbound Connections (Device as a Server / Web Interface) * The device's embedded web server must support connections via **TLS 1.2** and/or **TLS 1.3**. * **TLS 1.0 and TLS 1.1 must be explicitly disabled** within the web server configuration. -* The TLS certificate presented by the web server must be valid and signed by a trusted (or test-environment designated) Certificate Authority (CA). \ No newline at end of file +* The TLS certificate presented by the web server must be valid and signed by a trusted (or test-environment designated) Certificate Authority (CA). + +### HTTP Server Compliance (RFC 9110) +* Basic Method Support: Verification that the server successfully processes standard **GET** and **HEAD** methods, returning a **200 OK** status. +* **HEAD** Response Semantics: Ensuring that the response body for a **HEAD** request is strictly empty, and critical headers **(Content-Length, Content-Type)** precisely match the **GET** response. +* Unsupported Method Handling: Verification of correct server response to arbitrary or invalid methods, expecting a status code of **405 Method Not Allowed or 501 Not Implemented**. From 24209c22821a4b23026948792ca3821bdc5a2681 Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 16 Oct 2026 16:31:43 +0200 Subject: [PATCH 19/21] steps to resolve --- modules/test/tls/README.md | 2 +- modules/test/tls/conf/module_config.json | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/test/tls/README.md b/modules/test/tls/README.md index 2a93786c1..49e8d3464 100644 --- a/modules/test/tls/README.md +++ b/modules/test/tls/README.md @@ -19,5 +19,5 @@ Within the ```python/src``` directory, the below tests are executed. | security.tls.v1_2_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS v1.2 and support for ECDH and ECDSA ciphers | Required if Applicable | | security.tls.v1_3_server | Check the device web server TLS 1.3 and the certificate is valid | TLS 1.3 certificate is issued to the web browser client when accessed | Informational | | security.tls.v1_3_client | Device uses TLS with connection to an external service on port 443 (or any other port which could be running the webserver-HTTPS) | The packet indicates a TLS connection with at least TLS 1.3 using AEAD-based ciphers | Informational | -| security.http.server | Automated RFC 9110 compliance validation of the device's HTTP/HTTPS server | The server successfully responds with status 200 OK for both GET and HEAD methods with an empty HEAD body and matching critical headers, returns 405 or 501 for unsupported methods | Informational | +| security.http.server | Automated RFC 9110 compliance validation of the device's HTTP/HTTPS server | The server responds with status 200 OK for both GET and HEAD methods with an empty HEAD body and matching critical headers, returns 405 or 501 for unsupported methods | Informational | diff --git a/modules/test/tls/conf/module_config.json b/modules/test/tls/conf/module_config.json index 364d57efa..76cef4143 100644 --- a/modules/test/tls/conf/module_config.json +++ b/modules/test/tls/conf/module_config.json @@ -61,10 +61,13 @@ }, { "name": "security.http.server", - "test_description": "HTTP server", - "expected_behavior": "HTTP server compliant", + "test_description": "Automated RFC 9110 compliance validation of the device's HTTP/HTTPS server", + "expected_behavior": "The server responds with status 200 OK for both GET and HEAD methods with an empty HEAD body and matching critical headers, returns 405 or 501 for unsupported methods", "recommendations": [ - "Implement HEAD method" + "Implement the GET and HEAD methods on the web server", + "Ensure that the HEAD response is empty", + "Ensure that the GET and HEAD response headers (Content-Length, Content-Type) match", + "Ensure that unsupported requests return a 405 or 501 status code" ] } ] From 5d62ce7a19367e2d680b463cc19fc8e3bed7e8aa Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 16 Oct 2026 17:28:28 +0200 Subject: [PATCH 20/21] fix 443 port --- modules/test/tls/python/src/http_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index 0b8ba2e4b..922104a4a 100644 --- a/modules/test/tls/python/src/http_scan.py +++ b/modules/test/tls/python/src/http_scan.py @@ -173,7 +173,7 @@ def http_server_compliance(self, ip:str) -> tuple[str, str, list[str]]: msg = f'No HTTP/HTTPS ports found on {ip}' LOGGER.info(msg) return result_state, result_message, result_details - if '443' in ports: + if 443 in ports: url = f'https://{ip}' else: url = f'http://{ip}' From a4c8654f05a6cedb841951e5611f5bc38828c68c Mon Sep 17 00:00:00 2001 From: Aliaksandr Nikitsin Date: Fri, 16 Oct 2026 17:30:33 +0200 Subject: [PATCH 21/21] fix port in tests --- testing/unit/tls/tls_module_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index 1b26ffa4a..6d0501fa0 100644 --- a/testing/unit/tls/tls_module_test.py +++ b/testing/unit/tls/tls_module_test.py @@ -926,7 +926,7 @@ def test_http_server_not_detected(self, mock_scan_ports): @patch.object(HTTPScan, 'scan_all_ports') def test_http_server_compliant(self, mock_scan_ports, mock_make_request): """Test when the server fully complies with RFC 9110.""" - mock_scan_ports.return_value = ['443'] + mock_scan_ports.return_value = [443] get_resp = unittest.mock.MagicMock(status_code=200) get_resp.headers = {'Content-Length': '50', 'Content-Type': 'text/html'}