diff --git a/docs/rules.md b/docs/rules.md index f3d1742b..7ff766da 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -42,3 +42,4 @@ | PY028 | [secrets — weak token](rules/python/stdlib/secrets-weak-token.md) | Insufficient Token Length | | PY029 | [socket — unrestricted bind](rules/python/stdlib/socket-unrestricted-bind.md) | Binding to an Unrestricted IP Address in `socket` Module | | PY030 | [socketserver — unrestricted bind](rules/python/stdlib/socketserver-unrestricted-bind.md) | Binding to an Unrestricted IP Address in `socketserver` Module | +| PY031 | [http — unrestricted bind](rules/python/stdlib/http-server-unrestricted-bind.md) | Binding to an Unrestricted IP Address in `http.server` Module | diff --git a/docs/rules/python/stdlib/http_server-unrestricted-bind.md b/docs/rules/python/stdlib/http_server-unrestricted-bind.md new file mode 100644 index 00000000..a5eb32e0 --- /dev/null +++ b/docs/rules/python/stdlib/http_server-unrestricted-bind.md @@ -0,0 +1,10 @@ +--- +id: PY031 +title: http — unrestricted bind +hide_title: true +pagination_prev: null +pagination_next: null +slug: /rules/PY031 +--- + +::: precli.rules.python.stdlib.http_server_unrestricted_bind diff --git a/precli/rules/python/stdlib/http_server_unrestricted_bind.py b/precli/rules/python/stdlib/http_server_unrestricted_bind.py new file mode 100644 index 00000000..6ce53058 --- /dev/null +++ b/precli/rules/python/stdlib/http_server_unrestricted_bind.py @@ -0,0 +1,109 @@ +# Copyright 2024 Secure Saurce LLC +r""" +# Binding to an Unrestricted IP Address in `http.server` Module + +Sockets can be bound to the IPv4 address `0.0.0.0` or IPv6 equivalent of +`::`, which configures the socket to listen for incoming connections on all +network interfaces. While this can be intended in environments where +services are meant to be publicly accessible, it can also introduce significant +security risks if the service is not intended for public or wide network +access. + +Binding a socket to `0.0.0.0` or `::` can unintentionally expose the +application to the wider network or the internet, making it accessible from +any interface. This exposure can lead to unauthorized access, data breaches, +or exploitation of vulnerabilities within the application if the service is +not adequately secured or if the binding is unintended. Restricting the socket +to listen on specific interfaces limits the exposure and reduces the attack +surface. + +## Example + +```python +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer + + +def run(server_class: HTTPServer, handler_class: BaseHTTPRequestHandler): + server_address = ("", 8000) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() +``` + +## Remediation + +All socket bindings MUST specify a specific network interface or localhost +(127.0.0.1/localhost for IPv4, ::1 for IPv6) unless the application is +explicitly designed to be accessible from any network interface. This +practice ensures that services are not exposed more broadly than intended. + +```python +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer + + +def run(server_class: HTTPServer, handler_class: BaseHTTPRequestHandler): + server_address = ("127.0.0.1", 8000) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() +``` + +## See also + +- [http.server.HTTPServer — HTTP servers](https://docs.python.org/3/library/http.server.html#http.server.HTTPServer) +- [http.server.ThreadingHTTPServer — HTTP servers](https://docs.python.org/3/library/http.server.html#http.server.ThreadingHTTPServer) +- [CWE-1327: Binding to an Unrestricted IP Address](https://cwe.mitre.org/data/definitions/1327.html) + +_New in version 0.3.14_ + +""" # noqa: E501 +from precli.core.location import Location +from precli.core.result import Result +from precli.rules import Rule + + +INADDR_ANY = "0.0.0.0" +IN6ADDR_ANY = "::" + + +class HttpServerUnrestrictedBind(Rule): + def __init__(self, id: str): + super().__init__( + id=id, + name="unrestricted_bind", + description=__doc__, + cwe_id=1327, + message="Binding to '{0}' exposes the application on all network " + "interfaces, increasing the risk of unauthorized access.", + targets=("call"), + wildcards={ + "http.server.*": [ + "HTTPServer", + "ThreadingHTTPServer", + ] + }, + ) + + def analyze(self, context: dict, **kwargs: dict) -> Result: + call = kwargs.get("call") + if call.name_qualified not in [ + "http.server.HTTPServer", + "http.server.ThreadingHTTPServer", + ]: + return + + arg = call.get_argument(position=0, name="server_address") + server_address = arg.value + + if isinstance(server_address, tuple) and server_address[0] in ( + "", + INADDR_ANY, + IN6ADDR_ANY, + ): + return Result( + rule_id=self.id, + location=Location(node=arg.node), + message=self.message.format( + "INADDR_ANY (0.0.0.0) or IN6ADDR_ANY (::)" + ), + ) diff --git a/setup.cfg b/setup.cfg index 84bb62b4..8e43a4a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -136,5 +136,8 @@ precli.rules.python = # precli/rules/python/stdlib/socketserver_unrestricted_bind.py PY030 = precli.rules.python.stdlib.socketserver_unrestricted_bind:SocketserverUnrestrictedBind + # precli/rules/python/stdlib/http_server_unrestricted_bind.py + PY031 = precli.rules.python.stdlib.http_server_unrestricted_bind:HttpServerUnrestrictedBind + [build_sphinx] all_files = 1 diff --git a/tests/unit/rules/python/stdlib/http/examples/http_server_http_server.py b/tests/unit/rules/python/stdlib/http/examples/http_server_http_server.py new file mode 100644 index 00000000..db6eaa17 --- /dev/null +++ b/tests/unit/rules/python/stdlib/http/examples/http_server_http_server.py @@ -0,0 +1,13 @@ +# level: WARNING +# start_line: 12 +# end_line: 12 +# start_column: 25 +# end_column: 39 +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer + + +def run(server_class: HTTPServer, handler_class: BaseHTTPRequestHandler): + server_address = ("", 8000) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() diff --git a/tests/unit/rules/python/stdlib/http/examples/http_server_threading_http_server.py b/tests/unit/rules/python/stdlib/http/examples/http_server_threading_http_server.py new file mode 100644 index 00000000..83d4c4c4 --- /dev/null +++ b/tests/unit/rules/python/stdlib/http/examples/http_server_threading_http_server.py @@ -0,0 +1,16 @@ +# level: WARNING +# start_line: 15 +# end_line: 15 +# start_column: 25 +# end_column: 39 +from http.server import BaseHTTPRequestHandler +from http.server import ThreadingHTTPServer + + +def run( + server_class: ThreadingHTTPServer, + handler_class: BaseHTTPRequestHandler, +): + server_address = ("", 8000) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() diff --git a/tests/unit/rules/python/stdlib/http/test_http_server_unrestricted_bind.py b/tests/unit/rules/python/stdlib/http/test_http_server_unrestricted_bind.py new file mode 100644 index 00000000..4d12e11c --- /dev/null +++ b/tests/unit/rules/python/stdlib/http/test_http_server_unrestricted_bind.py @@ -0,0 +1,46 @@ +# Copyright 2024 Secure Saurce LLC +import os + +from parameterized import parameterized + +from precli.core.level import Level +from precli.parsers import python +from precli.rules import Rule +from tests.unit.rules import test_case + + +class HttpServerUnrestrictedBindTests(test_case.TestCase): + def setUp(self): + super().setUp() + self.rule_id = "PY031" + self.parser = python.Python() + self.base_path = os.path.join( + "tests", + "unit", + "rules", + "python", + "stdlib", + "http", + "examples", + ) + + def test_rule_meta(self): + rule = Rule.get_by_id(self.rule_id) + self.assertEqual(self.rule_id, rule.id) + self.assertEqual("unrestricted_bind", rule.name) + self.assertEqual( + f"https://docs.securesauce.dev/rules/{self.rule_id}", rule.help_url + ) + self.assertEqual(True, rule.default_config.enabled) + self.assertEqual(Level.WARNING, rule.default_config.level) + self.assertEqual(-1.0, rule.default_config.rank) + self.assertEqual("1327", rule.cwe.cwe_id) + + @parameterized.expand( + [ + "http_server_http_server.py", + "http_server_threading_http_server.py", + ] + ) + def test(self, filename): + self.check(filename)