From 69e53015157d3b8b141381f852123620531486d8 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Wed, 29 Jul 2026 18:11:09 +0530 Subject: [PATCH 1/6] validate Host header in graph_server request handlers --- .../bigquery_magics/graph_server.py | 25 ++++++++++++++++ .../tests/unit/test_graph_server.py | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/bigquery-magics/bigquery_magics/graph_server.py b/packages/bigquery-magics/bigquery_magics/graph_server.py index fac2bb281122..c9f45e142cac 100644 --- a/packages/bigquery-magics/bigquery_magics/graph_server.py +++ b/packages/bigquery-magics/bigquery_magics/graph_server.py @@ -17,12 +17,18 @@ import json import socketserver import threading +import urllib.parse from typing import Any, Dict, List from google.cloud import bigquery from bigquery_magics import core +# The graph widget only ever reaches this server over the loopback interface. +# Requests carrying any other Host are cross-site (e.g. a DNS-rebinding page in +# the notebook user's browser) and are refused. +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + def execute_node_expansion(params, request): return {"error": "Node expansion not yet implemented"} @@ -354,6 +360,19 @@ class GraphServerHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): pass + def _host_is_loopback(self): + """Return True if the request targets the loopback interface. + + Origin cannot be used here because the notebook page and this server + live on different ports, so legitimate widget traffic is cross-origin. + The Host header, however, is always a loopback name for that traffic. + """ + host = self.headers.get("Host") + if not host: + return False + hostname = urllib.parse.urlsplit(f"//{host}").hostname + return hostname in _LOOPBACK_HOSTS + def do_json_response(self, data): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") @@ -406,10 +425,16 @@ def handle_post_node_expansion(self): ) def do_GET(self): + if not self._host_is_loopback(): + self.send_error(403, "Forbidden") + return assert self.path == GraphServer.endpoints["get_ping"] self.handle_get_ping() def do_POST(self): + if not self._host_is_loopback(): + self.send_error(403, "Forbidden") + return if self.path == GraphServer.endpoints["post_ping"]: self.handle_post_ping() elif self.path == GraphServer.endpoints["post_node_expansion"]: diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index 28f4ba58f8ec..818a45c861b2 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -747,3 +747,33 @@ def test_convert_schema_shared_label(): labels = {label["name"]: label for label in result["labels"]} assert "Person" in labels assert set(labels["Person"]["propertyDeclarationNames"]) == {"id", "name"} + + +class TestGraphServerHostHeader(unittest.TestCase): + def setUp(self): + self.server = graph_server.GraphServer() + self.server_thread = self.server.init() + + def tearDown(self): + self.server.stop_server() + self.server_thread.join() + + def _route(self): + return self.server.build_route(graph_server.GraphServer.endpoints["get_ping"]) + + def test_loopback_host_allowed(self): + response = requests.get(self._route()) + self.assertEqual(response.status_code, 200) + + def test_non_loopback_host_rejected(self): + # A DNS-rebinding page reaches the loopback socket but carries the + # attacker's hostname in the Host header. + response = requests.get(self._route(), headers={"Host": "attacker.example"}) + self.assertEqual(response.status_code, 403) + + def test_non_loopback_host_rejected_post(self): + route = self.server.build_route(graph_server.GraphServer.endpoints["post_ping"]) + response = requests.post( + route, json={"data": "ping"}, headers={"Host": "evil.example:1234"} + ) + self.assertEqual(response.status_code, 403) From 7d31d7dec9c01387b00908e65181d53f8ad989ab Mon Sep 17 00:00:00 2001 From: bibi samina Date: Thu, 30 Jul 2026 13:06:45 +0530 Subject: [PATCH 2/6] test: gate graph_server host-header tests on spanner-graph-notebook Guard urlsplit against ValueError on malformed Host headers. --- packages/bigquery-magics/bigquery_magics/graph_server.py | 5 ++++- packages/bigquery-magics/tests/unit/test_graph_server.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/bigquery-magics/bigquery_magics/graph_server.py b/packages/bigquery-magics/bigquery_magics/graph_server.py index c9f45e142cac..78e059188b01 100644 --- a/packages/bigquery-magics/bigquery_magics/graph_server.py +++ b/packages/bigquery-magics/bigquery_magics/graph_server.py @@ -370,7 +370,10 @@ def _host_is_loopback(self): host = self.headers.get("Host") if not host: return False - hostname = urllib.parse.urlsplit(f"//{host}").hostname + try: + hostname = urllib.parse.urlsplit(f"//{host}").hostname + except ValueError: + return False return hostname in _LOOPBACK_HOSTS def do_json_response(self, data): diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index 818a45c861b2..de88d572aef3 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -761,16 +761,25 @@ def tearDown(self): def _route(self): return self.server.build_route(graph_server.GraphServer.endpoints["get_ping"]) + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) def test_loopback_host_allowed(self): response = requests.get(self._route()) self.assertEqual(response.status_code, 200) + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) def test_non_loopback_host_rejected(self): # A DNS-rebinding page reaches the loopback socket but carries the # attacker's hostname in the Host header. response = requests.get(self._route(), headers={"Host": "attacker.example"}) self.assertEqual(response.status_code, 403) + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) def test_non_loopback_host_rejected_post(self): route = self.server.build_route(graph_server.GraphServer.endpoints["post_ping"]) response = requests.post( From 4b4481dd90de04b2dc23d5e8125e1883e3095d96 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Tue, 11 Aug 2026 14:02:04 +0530 Subject: [PATCH 3/6] test: cover missing and malformed Host header paths --- .../tests/unit/test_graph_server.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index de88d572aef3..e1df68bc271b 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -786,3 +786,24 @@ def test_non_loopback_host_rejected_post(self): route, json={"data": "ping"}, headers={"Host": "evil.example:1234"} ) self.assertEqual(response.status_code, 403) + + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) + def test_missing_host_rejected(self): + handler = mock.Mock(spec=graph_server.GraphServerHandler) + handler.headers = {} + self.assertFalse( + graph_server.GraphServerHandler._host_is_loopback(handler) + ) + + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) + def test_malformed_host_rejected(self): + # An unterminated IPv6 literal makes urlsplit raise ValueError. + handler = mock.Mock(spec=graph_server.GraphServerHandler) + handler.headers = {"Host": "[::1"} + self.assertFalse( + graph_server.GraphServerHandler._host_is_loopback(handler) + ) From b7f94c1eba1122c128ee903e0841f923b456da25 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Fri, 14 Aug 2026 15:12:36 +0000 Subject: [PATCH 4/6] lint --- packages/bigquery-magics/tests/unit/test_graph_server.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index e1df68bc271b..eb32ad7bbdd2 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -793,9 +793,7 @@ def test_non_loopback_host_rejected_post(self): def test_missing_host_rejected(self): handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {} - self.assertFalse( - graph_server.GraphServerHandler._host_is_loopback(handler) - ) + self.assertFalse(graph_server.GraphServerHandler._host_is_loopback(handler)) @pytest.mark.skipif( graph_visualization is None, reason="Requires `spanner-graph-notebook`" @@ -804,6 +802,4 @@ def test_malformed_host_rejected(self): # An unterminated IPv6 literal makes urlsplit raise ValueError. handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {"Host": "[::1"} - self.assertFalse( - graph_server.GraphServerHandler._host_is_loopback(handler) - ) + self.assertFalse(graph_server.GraphServerHandler._host_is_loopback(handler)) From 368ee6b3a257a3d9e1fb1bdcddc1da531e780000 Mon Sep 17 00:00:00 2001 From: bibi samina Date: Wed, 19 Aug 2026 13:46:33 +0530 Subject: [PATCH 5/6] fix: reject cross-origin requests via Origin/Referer in graph_server Signed-off-by: bibi samina --- .../bigquery_magics/graph_server.py | 37 ++++++++++++++----- .../tests/unit/test_graph_server.py | 32 +++++++++++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/bigquery-magics/bigquery_magics/graph_server.py b/packages/bigquery-magics/bigquery_magics/graph_server.py index 78e059188b01..7353316ef375 100644 --- a/packages/bigquery-magics/bigquery_magics/graph_server.py +++ b/packages/bigquery-magics/bigquery_magics/graph_server.py @@ -360,21 +360,38 @@ class GraphServerHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): pass - def _host_is_loopback(self): - """Return True if the request targets the loopback interface. - - Origin cannot be used here because the notebook page and this server - live on different ports, so legitimate widget traffic is cross-origin. - The Host header, however, is always a loopback name for that traffic. + def _is_request_safe(self): + """Return True if the request targets and originates from loopback. + + The Host header guards against DNS rebinding: legitimate widget traffic + always carries a loopback name even though the notebook page and this + server live on different ports. Origin and Referer, when present, are + checked too so a page on a non-loopback site cannot drive the server + with a direct cross-site request; their port differs from ours but the + hostname is still loopback for real traffic. """ host = self.headers.get("Host") if not host: return False try: - hostname = urllib.parse.urlsplit(f"//{host}").hostname + hostname = urllib.parse.urlsplit(f"//{host.strip()}").hostname except ValueError: return False - return hostname in _LOOPBACK_HOSTS + if hostname not in _LOOPBACK_HOSTS: + return False + + for header_name in ("Origin", "Referer"): + value = self.headers.get(header_name) + if not value: + continue + try: + origin_hostname = urllib.parse.urlsplit(value.strip()).hostname + except ValueError: + return False + if origin_hostname and origin_hostname not in _LOOPBACK_HOSTS: + return False + + return True def do_json_response(self, data): self.send_response(200) @@ -428,14 +445,14 @@ def handle_post_node_expansion(self): ) def do_GET(self): - if not self._host_is_loopback(): + if not self._is_request_safe(): self.send_error(403, "Forbidden") return assert self.path == GraphServer.endpoints["get_ping"] self.handle_get_ping() def do_POST(self): - if not self._host_is_loopback(): + if not self._is_request_safe(): self.send_error(403, "Forbidden") return if self.path == GraphServer.endpoints["post_ping"]: diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index eb32ad7bbdd2..4b1376b9b510 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -793,7 +793,7 @@ def test_non_loopback_host_rejected_post(self): def test_missing_host_rejected(self): handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {} - self.assertFalse(graph_server.GraphServerHandler._host_is_loopback(handler)) + self.assertFalse(graph_server.GraphServerHandler._is_request_safe(handler)) @pytest.mark.skipif( graph_visualization is None, reason="Requires `spanner-graph-notebook`" @@ -802,4 +802,32 @@ def test_malformed_host_rejected(self): # An unterminated IPv6 literal makes urlsplit raise ValueError. handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {"Host": "[::1"} - self.assertFalse(graph_server.GraphServerHandler._host_is_loopback(handler)) + self.assertFalse(graph_server.GraphServerHandler._is_request_safe(handler)) + + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) + def test_non_loopback_origin_rejected(self): + # Host header is valid (loopback), but Origin is a malicious third-party site. + # Without Origin checks, the host-only check is bypassed and CORS wildcard headers are returned. + response = requests.get( + self._route(), + headers={"Host": "localhost", "Origin": "http://attacker.example.com"}, + ) + # Without the fix, this evaluates to 200 OK. We want it to be rejected. + self.assertEqual(response.status_code, 403) + + @pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" + ) + def test_non_loopback_referer_rejected(self): + # Host header is valid (loopback), but Referer reveals a malicious third-party origin. + response = requests.get( + self._route(), + headers={ + "Host": "127.0.0.1", + "Referer": "http://attacker.example.com/exploit-page", + }, + ) + # Without the fix, this evaluates to 200 OK. We want it to be rejected. + self.assertEqual(response.status_code, 403) From a2f364c1a823d7f42ae4303d90cbe194a08b6753 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 20 Oct 2026 15:54:12 +0000 Subject: [PATCH 6/6] clean up / testing gaps --- .../bigquery_magics/graph_server.py | 4 +- .../tests/unit/test_graph_server.py | 58 +++++++++++-------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/bigquery-magics/bigquery_magics/graph_server.py b/packages/bigquery-magics/bigquery_magics/graph_server.py index 0713471709df..4bdcb86d94a3 100644 --- a/packages/bigquery-magics/bigquery_magics/graph_server.py +++ b/packages/bigquery-magics/bigquery_magics/graph_server.py @@ -364,7 +364,7 @@ class GraphServerHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): pass - def _is_request_safe(self): + def _is_request_safe(self) -> bool: """Return True if the request targets and originates from loopback. The Host header guards against DNS rebinding: legitimate widget traffic @@ -392,7 +392,7 @@ def _is_request_safe(self): origin_hostname = urllib.parse.urlsplit(value.strip()).hostname except ValueError: return False - if origin_hostname and origin_hostname not in _LOOPBACK_HOSTS: + if origin_hostname not in _LOOPBACK_HOSTS: return False return True diff --git a/packages/bigquery-magics/tests/unit/test_graph_server.py b/packages/bigquery-magics/tests/unit/test_graph_server.py index 16ef18d70827..afa7102d8fdd 100644 --- a/packages/bigquery-magics/tests/unit/test_graph_server.py +++ b/packages/bigquery-magics/tests/unit/test_graph_server.py @@ -750,6 +750,9 @@ def test_convert_schema_shared_label(): assert set(labels["Person"]["propertyDeclarationNames"]) == {"id", "name"} +@pytest.mark.skipif( + graph_visualization is None, reason="Requires `spanner-graph-notebook`" +) class TestGraphServerHostHeader(unittest.TestCase): def setUp(self): self.server = graph_server.GraphServer() @@ -762,25 +765,21 @@ def tearDown(self): def _route(self): return self.server.build_route(graph_server.GraphServer.endpoints["get_ping"]) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_loopback_host_allowed(self): response = requests.get(self._route()) self.assertEqual(response.status_code, 200) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) + def test_loopback_host_allowed_post(self): + route = self.server.build_route(graph_server.GraphServer.endpoints["post_ping"]) + response = requests.post(route, json={"data": "ping"}) + self.assertEqual(response.status_code, 200) + def test_non_loopback_host_rejected(self): # A DNS-rebinding page reaches the loopback socket but carries the # attacker's hostname in the Host header. response = requests.get(self._route(), headers={"Host": "attacker.example"}) self.assertEqual(response.status_code, 403) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_non_loopback_host_rejected_post(self): route = self.server.build_route(graph_server.GraphServer.endpoints["post_ping"]) response = requests.post( @@ -788,39 +787,25 @@ def test_non_loopback_host_rejected_post(self): ) self.assertEqual(response.status_code, 403) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_missing_host_rejected(self): handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {} self.assertFalse(graph_server.GraphServerHandler._is_request_safe(handler)) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_malformed_host_rejected(self): # An unterminated IPv6 literal makes urlsplit raise ValueError. handler = mock.Mock(spec=graph_server.GraphServerHandler) handler.headers = {"Host": "[::1"} self.assertFalse(graph_server.GraphServerHandler._is_request_safe(handler)) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_non_loopback_origin_rejected(self): # Host header is valid (loopback), but Origin is a malicious third-party site. - # Without Origin checks, the host-only check is bypassed and CORS wildcard headers are returned. response = requests.get( self._route(), headers={"Host": "localhost", "Origin": "http://attacker.example.com"}, ) - # Without the fix, this evaluates to 200 OK. We want it to be rejected. self.assertEqual(response.status_code, 403) - @pytest.mark.skipif( - graph_visualization is None, reason="Requires `spanner-graph-notebook`" - ) def test_non_loopback_referer_rejected(self): # Host header is valid (loopback), but Referer reveals a malicious third-party origin. response = requests.get( @@ -830,7 +815,32 @@ def test_non_loopback_referer_rejected(self): "Referer": "http://attacker.example.com/exploit-page", }, ) - # Without the fix, this evaluates to 200 OK. We want it to be rejected. + self.assertEqual(response.status_code, 403) + + def test_malformed_origin_rejected(self): + # An unterminated IPv6 literal in Origin makes urlsplit raise ValueError. + handler = mock.Mock(spec=graph_server.GraphServerHandler) + handler.headers = {"Host": "localhost", "Origin": "http://[::1"} + self.assertFalse(graph_server.GraphServerHandler._is_request_safe(handler)) + + def test_loopback_origin_and_referer_allowed(self): + response = requests.get( + self._route(), + headers={ + "Host": "localhost", + "Origin": "http://localhost:8080", + "Referer": "http://127.0.0.1:8080/notebook", + }, + ) + self.assertEqual(response.status_code, 200) + + def test_null_origin_rejected(self): + # Sandboxed iframes and data: URIs send "Origin: null", where + # urlsplit("null").hostname is None. + response = requests.get( + self._route(), + headers={"Host": "localhost", "Origin": "null"}, + ) self.assertEqual(response.status_code, 403)