diff --git a/python/ql/lib/change-notes/2026-09-22-python315-http-headers.md b/python/ql/lib/change-notes/2026-09-22-python315-http-headers.md new file mode 100644 index 000000000000..5ea82057e2d2 --- /dev/null +++ b/python/ql/lib/change-notes/2026-09-22-python315-http-headers.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Added modeling for the `extra_response_headers` constructor argument and instance attribute of `http.server.SimpleHTTPRequestHandler`, introduced in Python 3.15. \ No newline at end of file diff --git a/python/ql/lib/semmle/python/frameworks/Stdlib.qll b/python/ql/lib/semmle/python/frameworks/Stdlib.qll index c02aa4bb6d8a..61e6290ec8b8 100644 --- a/python/ql/lib/semmle/python/frameworks/Stdlib.qll +++ b/python/ql/lib/semmle/python/frameworks/Stdlib.qll @@ -2041,6 +2041,34 @@ module StdlibPrivate { override predicate valueAllowsNewline() { any() } } + /** Additional response headers supplied by construction or instance assignment. */ + private class ExtraHeadersWrite extends Http::Server::ResponseHeaderBulkWrite::Range { + DataFlow::Node headers; + + ExtraHeadersWrite() { + exists(API::Node cls | + cls = + API::moduleImport("http") + .getMember("server") + .getMember("SimpleHTTPRequestHandler") + .getASubclass*() + | + this = cls.getACall() and + headers = this.(DataFlow::CallCfgNode).getArgByName("extra_response_headers") + or + this.(DataFlow::AttrWrite) + .writes([cls.getAnInstance(), cls.getAMember().getSelfParameter()] + .getAValueReachableFromSource(), "extra_response_headers", headers) + ) + } + + override DataFlow::Node getBulkArg() { result = headers } + + override predicate nameAllowsNewline() { any() } + + override predicate valueAllowsNewline() { any() } + } + private class AdditionalTaintStep extends TaintTracking::AdditionalTaintStep { override predicate step(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) { nodeFrom = instance() and diff --git a/python/ql/test/library-tests/frameworks/stdlib/test_extra_response_headers.py b/python/ql/test/library-tests/frameworks/stdlib/test_extra_response_headers.py new file mode 100644 index 000000000000..a83067470264 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/stdlib/test_extra_response_headers.py @@ -0,0 +1,73 @@ +from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler +import http.server + + +def constructors(request, address, server, headers): + SimpleHTTPRequestHandler(request, address, server, extra_response_headers=headers) # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + http.server.SimpleHTTPRequestHandler(request, address, server, extra_response_headers=headers) # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + handler = SimpleHTTPRequestHandler + handler(request, address, server, extra_response_headers=headers) # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + + SimpleHTTPRequestHandler(request, address, server, extra_response_headers=[("X-Example", "value")]) # $ headerWriteBulk=List headerWriteBulkUnsanitized=name,value headerWriteNameUnsanitized="X-Example" headerWriteValueUnsanitized="value" + SimpleHTTPRequestHandler(request, address, server) + + +class CustomHandler(SimpleHTTPRequestHandler): + def change_headers(self, headers): + self.extra_response_headers = headers # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + alias = self + alias.extra_response_headers = [("X-Alias", "value")] # $ headerWriteBulk=List headerWriteBulkUnsanitized=name,value headerWriteNameUnsanitized="X-Alias" headerWriteValueUnsanitized="value" + ensure_tainted(alias.headers) # $ tainted + ensure_not_tainted(alias.extra_response_headers) + + def delegate(self, headers): + write_simple_headers(self, headers) + + +class DerivedHandler(CustomHandler): + def change_again(self, headers): + self.extra_response_headers = headers # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + + +def constructed_instance(request, address, server, headers): + handler = SimpleHTTPRequestHandler(request, address, server) + handler.extra_response_headers = headers # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + write_simple_headers(handler, headers) + + +def write_simple_headers(handler, headers): + alias = handler + alias.extra_response_headers = headers # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + + +def annotated_instance(handler: SimpleHTTPRequestHandler, headers): + handler.extra_response_headers = headers # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + + +class InheritedInitHandler(SimpleHTTPRequestHandler): + pass + + +def inherited_constructor(request, address, server, headers): + InheritedInitHandler(request, address, server, extra_response_headers=headers) # $ headerWriteBulk=headers headerWriteBulkUnsanitized=name,value + + +class BaseOnlyHandler(BaseHTTPRequestHandler): + def change_headers(self, headers): + # BaseHTTPRequestHandler does not consume this attribute. + self.extra_response_headers = headers + write_base_headers(self, headers) + + +def write_base_headers(handler, headers): + alias = handler + alias.extra_response_headers = headers + + +class Unrelated: + def __init__(self, *, extra_response_headers): + self.extra_response_headers = extra_response_headers + + +def unrelated(headers): + Unrelated(extra_response_headers=headers) diff --git a/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/HeaderInjection.expected b/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/HeaderInjection.expected index 4043b9332435..621921af3f7c 100644 --- a/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/HeaderInjection.expected +++ b/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/HeaderInjection.expected @@ -1,4 +1,7 @@ #select +| extra_response_headers.py:9:52:9:56 | ControlFlowNode for value | extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | extra_response_headers.py:9:52:9:56 | ControlFlowNode for value | This HTTP header is constructed from a $@. | extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | user-provided value | +| extra_response_headers.py:11:42:11:45 | ControlFlowNode for name | extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | extra_response_headers.py:11:42:11:45 | ControlFlowNode for name | This HTTP header is constructed from a $@. | extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | user-provided value | +| extra_response_headers.py:27:49:27:53 | ControlFlowNode for value | extra_response_headers.py:21:16:21:19 | ControlFlowNode for self | extra_response_headers.py:27:49:27:53 | ControlFlowNode for value | This HTTP header is constructed from a $@. | extra_response_headers.py:21:16:21:19 | ControlFlowNode for self | user-provided value | | flask_tests.py:13:17:13:26 | ControlFlowNode for rfs_header | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | flask_tests.py:13:17:13:26 | ControlFlowNode for rfs_header | This HTTP header is constructed from a $@. | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | user-provided value | | flask_tests.py:20:36:20:61 | ControlFlowNode for Subscript | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | flask_tests.py:20:36:20:61 | ControlFlowNode for Subscript | This HTTP header is constructed from a $@. | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | user-provided value | | flask_tests.py:33:11:33:20 | ControlFlowNode for rfs_header | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | flask_tests.py:33:11:33:20 | ControlFlowNode for rfs_header | This HTTP header is constructed from a $@. | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | user-provided value | @@ -7,6 +10,12 @@ | wsgiref_tests.py:8:17:8:22 | ControlFlowNode for h_name | wsgiref_tests.py:4:14:4:20 | ControlFlowNode for environ | wsgiref_tests.py:8:17:8:22 | ControlFlowNode for h_name | This HTTP header is constructed from a $@. | wsgiref_tests.py:4:14:4:20 | ControlFlowNode for environ | user-provided value | | wsgiref_tests.py:8:42:8:46 | ControlFlowNode for h_val | wsgiref_tests.py:4:14:4:20 | ControlFlowNode for environ | wsgiref_tests.py:8:42:8:46 | ControlFlowNode for h_val | This HTTP header is constructed from a $@. | wsgiref_tests.py:4:14:4:20 | ControlFlowNode for environ | user-provided value | edges +| extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | extra_response_headers.py:6:9:6:12 | ControlFlowNode for name | provenance | AdditionalTaintStep | +| extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | extra_response_headers.py:7:9:7:13 | ControlFlowNode for value | provenance | AdditionalTaintStep | +| extra_response_headers.py:6:9:6:12 | ControlFlowNode for name | extra_response_headers.py:11:42:11:45 | ControlFlowNode for name | provenance | | +| extra_response_headers.py:7:9:7:13 | ControlFlowNode for value | extra_response_headers.py:9:52:9:56 | ControlFlowNode for value | provenance | | +| extra_response_headers.py:21:16:21:19 | ControlFlowNode for self | extra_response_headers.py:23:9:23:13 | ControlFlowNode for value | provenance | AdditionalTaintStep | +| extra_response_headers.py:23:9:23:13 | ControlFlowNode for value | extra_response_headers.py:27:49:27:53 | ControlFlowNode for value | provenance | | | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | flask_tests.py:1:29:1:35 | ControlFlowNode for request | provenance | | | flask_tests.py:1:29:1:35 | ControlFlowNode for request | flask_tests.py:9:18:9:24 | ControlFlowNode for request | provenance | | | flask_tests.py:1:29:1:35 | ControlFlowNode for request | flask_tests.py:19:18:19:24 | ControlFlowNode for request | provenance | | @@ -37,6 +46,14 @@ models | 1 | Summary: urllib; Member[parse].Member[parse_qs]; Argument[0,qs:]; ReturnValue; taint | | 2 | Summary: urllib; Member[parse].Member[urlparse]; Argument[0,urlstring:]; ReturnValue; taint | nodes +| extra_response_headers.py:5:16:5:19 | ControlFlowNode for self | semmle.label | ControlFlowNode for self | +| extra_response_headers.py:6:9:6:12 | ControlFlowNode for name | semmle.label | ControlFlowNode for name | +| extra_response_headers.py:7:9:7:13 | ControlFlowNode for value | semmle.label | ControlFlowNode for value | +| extra_response_headers.py:9:52:9:56 | ControlFlowNode for value | semmle.label | ControlFlowNode for value | +| extra_response_headers.py:11:42:11:45 | ControlFlowNode for name | semmle.label | ControlFlowNode for name | +| extra_response_headers.py:21:16:21:19 | ControlFlowNode for self | semmle.label | ControlFlowNode for self | +| extra_response_headers.py:23:9:23:13 | ControlFlowNode for value | semmle.label | ControlFlowNode for value | +| extra_response_headers.py:27:49:27:53 | ControlFlowNode for value | semmle.label | ControlFlowNode for value | | flask_tests.py:1:29:1:35 | ControlFlowNode for ImportMember | semmle.label | ControlFlowNode for ImportMember | | flask_tests.py:1:29:1:35 | ControlFlowNode for request | semmle.label | ControlFlowNode for request | | flask_tests.py:9:5:9:14 | ControlFlowNode for rfs_header | semmle.label | ControlFlowNode for rfs_header | diff --git a/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/extra_response_headers.py b/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/extra_response_headers.py new file mode 100644 index 000000000000..da2c651b6e81 --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-113-HeaderInjection/Tests1/extra_response_headers.py @@ -0,0 +1,30 @@ +from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler + + +class ExtraHeadersHandler(SimpleHTTPRequestHandler): + def do_GET(self): + name = self.headers["X-Header-Name"] + value = self.headers["X-Header-Value"] + + self.extra_response_headers = [("X-Value", value)] # BAD + alias = self + alias.extra_response_headers = [(name, "value")] # BAD + + clean_name = name.replace("\r", "").replace("\n", "") + clean_value = value.replace("\r", "").replace("\n", "") + self.extra_response_headers = [(clean_name, clean_value)] # GOOD + self.extra_response_headers = [("X-Static", "value")] # GOOD + super().do_GET() + + +class DispatchHandler(BaseHTTPRequestHandler): + def do_GET(self): + name = self.headers["X-Header-Name"] + value = self.headers["X-Header-Value"] + + SimpleHTTPRequestHandler( + self.request, self.client_address, self.server, + extra_response_headers=[("X-Value", value)], # BAD + ) + # BaseHTTPRequestHandler does not consume this attribute. + self.extra_response_headers = [(name, value)] # GOOD