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**. diff --git a/modules/test/tls/README.md b/modules/test/tls/README.md index cfca8100c..49e8d3464 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 | 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 228fbb64d..76cef4143 100644 --- a/modules/test/tls/conf/module_config.json +++ b/modules/test/tls/conf/module_config.json @@ -58,6 +58,17 @@ "Disable connections to unsecure services", "Ensure any URLs connected to are secure (https)" ] + }, + { + "name": "security.http.server", + "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 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" + ] } ] } diff --git a/modules/test/tls/python/src/http_scan.py b/modules/test/tls/python/src/http_scan.py index 8df21d26e..0b8ba2e4b 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 = '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,131 @@ 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_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 HTTP server comliance to RFC 9110.""" + result_state = 'Feature Not Detected' + result_message = _MSG_HTTP_NOT_DETECTED + result_details = [] + try: + 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 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([ + '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 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) + else: + 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) + except Exception as e: + LOGGER.error(f'Unexpected error {e}') + + return result_state, result_message, result_details diff --git a/modules/test/tls/python/src/tls_module.py b/modules/test/tls/python/src/tls_module.py index 3b0dc004c..020aedd26 100644 --- a/modules/test/tls/python/src/tls_module.py +++ b/modules/test/tls/python/src/tls_module.py @@ -556,6 +556,12 @@ def _security_tls_v1_3_client(self): '1.3', unsupported_versions=['1.0', '1.1']) + def _security_http_server(self): + LOGGER.info('Running HTTP server test') + return self._http_scan.http_server_compliance( + self._device_ipv4_addr + ) + def _validate_tls_client(self, client_mac, tls_version, diff --git a/modules/ui/karma.conf.js b/modules/ui/karma.conf.js index 1eda40b4b..6e0a59fde 100644 --- a/modules/ui/karma.conf.js +++ b/modules/ui/karma.conf.js @@ -11,6 +11,12 @@ module.exports = function (config) { require('karma-jasmine-html-reporter'), require('karma-coverage'), ], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--disable-setuid-sandbox'], + }, + }, client: { jasmine: { // you can add configuration options for Jasmine here diff --git a/modules/ui/package.json b/modules/ui/package.json index 417cf2b66..c90d92387 100644 --- a/modules/ui/package.json +++ b/modules/ui/package.json @@ -9,7 +9,7 @@ "test": "ng test", "test:coverage": "ng test --code-coverage", "test-headless": "ng test --browsers=ChromeHeadless --watch=false", - "test-ci": "ng test --no-watch --no-progress --code-coverage --browsers=ChromeHeadless", + "test-ci": "ng test --no-watch --no-progress --code-coverage --browsers=ChromeHeadlessCI", "docker": "docker rm -f test-run-ui && docker rmi test-run-ui && docker build -t test-run-ui . && docker run -d -p 80:80 --name test-run-ui test-run-ui", "lint": "ng lint", "lint:fix": "ng lint --fix", 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/pilot/report_templates/summary.jinja b/resources/test_packs/pilot/report_templates/summary.jinja index 32eeaf320..cc0c22be0 100644 --- a/resources/test_packs/pilot/report_templates/summary.jinja +++ b/resources/test_packs/pilot/report_templates/summary.jinja @@ -13,6 +13,11 @@

MAC Address

{{ device['mac_addr'] }}
+ {% if device['kernel'] %} +
+

Kernel

+
{{ device['kernel'] }}
+ {% endif %}
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 diff --git a/resources/test_packs/qualification/report_templates/summary.jinja b/resources/test_packs/qualification/report_templates/summary.jinja index db4010bda..e9c6f072a 100644 --- a/resources/test_packs/qualification/report_templates/summary.jinja +++ b/resources/test_packs/qualification/report_templates/summary.jinja @@ -13,6 +13,11 @@

MAC Address

{{ device['mac_addr'] }}
+ {% if device['kernel'] %} +
+

Kernel

+
{{ device['kernel'] }}
+ {% endif %}
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 diff --git a/test_vm/python/src/http_server.py b/test_vm/python/src/http_server.py new file mode 100644 index 000000000..61894899c --- /dev/null +++ b/test_vm/python/src/http_server.py @@ -0,0 +1,45 @@ +"""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, 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') + 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') + # pylint: enable=invalid-name + + 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() diff --git a/testing/unit/tls/tls_module_test.py b/testing/unit/tls/tls_module_test.py index 77e14e1d4..1b26ffa4a 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 @@ -25,6 +26,7 @@ import ssl import shutil import logging +import requests import socket import sys from cryptography import x509 @@ -76,6 +78,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 +911,120 @@ 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_not_detected(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, []) + + @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 + ) + + + @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) + + @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() @@ -972,6 +1090,13 @@ 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_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')) + suite.addTest(TLSModuleTest('test_https_server_exception')) + runner = unittest.TextTestRunner() test_result = runner.run(suite)