Skip to content

fix: validate Host header in graph_server request handlers - #17932

Open
Samin061 wants to merge 9 commits into
googleapis:mainfrom
Samin061:graph-server-host-check
Open

Samin061 wants to merge 9 commits into
googleapis:mainfrom
Samin061:graph-server-host-check

Conversation

@Samin061

Copy link
Copy Markdown
Contributor

GraphServerHandler.do_GET/do_POST served any request that reached the loopback port, so a page in the notebook user's browser can reach the server through DNS rebinding and drive its /post_query handler, which routes into convert_graph_params and builds a BigQuery client with the user's credentials to call list_rows against a caller-supplied api endpoint and table. The widget is legitimately cross-origin because the notebook page and this server run on different ports, so Origin cannot gate the request, but real widget traffic always carries a loopback Host. The handlers now check the Host header and refuse anything that is not localhost, 127.0.0.1, or ::1.

  • Make sure to open an issue as a bug/issue before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
  • Ensure the tests and linter pass
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)

@Samin061
Samin061 requested review from a team as code owners July 29, 2026 12:42
@Samin061
Samin061 requested review from tswast and removed request for a team July 29, 2026 12:42

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces host header validation to ensure that the graph server only accepts requests targeting the loopback interface, mitigating potential DNS-rebinding attacks. This is implemented via a new _host_is_loopback helper method and applied to both GET and POST requests, with corresponding unit tests added. Feedback suggests wrapping the urllib.parse.urlsplit call in a try...except ValueError block to gracefully handle malformed Host headers and prevent unhandled exceptions.

Comment thread packages/bigquery-magics/bigquery_magics/graph_server.py Outdated
@parthea

parthea commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Hi @Samin061,

Thanks for opening a PR! We'll need to add this to each new test

@pytest.mark.skipif(
    graph_visualization is None, reason="Requires `spanner-graph-notebook`"
)

"Use of --graph requires the spanner-graph-notebook package to be installed. Install it with `pip install 'bigquery-magics[spanner-graph-notebook]'`."

@parthea

parthea commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

I'm going to switch this to draft but please mark it ready for review once tests are green

@parthea
parthea marked this pull request as draft July 29, 2026 14:44
@parthea parthea changed the title validate Host header in graph_server request handlers fix: validate Host header in graph_server request handlers Jul 29, 2026
@parthea parthea added kokoro:force-run Add this label to force Kokoro to re-run the tests. kokoro:run Add this label to force Kokoro to re-run the tests. labels Jul 29, 2026
@yoshi-kokoro yoshi-kokoro removed kokoro:run Add this label to force Kokoro to re-run the tests. kokoro:force-run Add this label to force Kokoro to re-run the tests. labels Jul 29, 2026
Guard urlsplit against ValueError on malformed Host headers.
@Samin061

Copy link
Copy Markdown
Contributor Author

Added the skipif decorator to each of the three new host-header tests, matching the rest of the file. Also guarded the urlsplit call against a ValueError on a malformed Host. Marking ready for review.

@Samin061
Samin061 marked this pull request as ready for review July 30, 2026 07:37
@parthea parthea assigned parthea and unassigned Samin061 Aug 4, 2026
@parthea

parthea commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Hi @Samin061, Please could you resolve the minor coverage gap?

bigquery_magics/graph_server.py                   202      3     52      1    98%   372, 375-376

@parthea parthea assigned Samin061 and unassigned parthea Aug 4, 2026
@Samin061

Copy link
Copy Markdown
Contributor Author

Added two direct tests for _host_is_loopback, one with no Host header and one with a malformed [::1 value, which cover lines 372 and 375-376. Should close the gap.

@parthea parthea assigned parthea and unassigned Samin061 Aug 12, 2026
@parthea parthea added kokoro:force-run Add this label to force Kokoro to re-run the tests. kokoro:run Add this label to force Kokoro to re-run the tests. labels Aug 14, 2026
@yoshi-kokoro yoshi-kokoro removed kokoro:run Add this label to force Kokoro to re-run the tests. kokoro:force-run Add this label to force Kokoro to re-run the tests. labels Aug 14, 2026
@parthea

parthea commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Gemini raised a concern that we should also inspect origin and referer too.

Checking the Host header only prevents DNS rebinding, but it does not stop a malicious website from making direct browser requests (like fetch('http://localhost')) to your server. To block those, you must also validate the Origin and Referer headers to verify that the request actually initiated from a trusted local page and not an external site.

Could we add these 2 tests?

    @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)

(py3142) partheniou@partheniou-vm-3:~/google-cloud-python-20260814$ pytest packages/bigquery-magics/tests/unit/test_graph_server.py::TestGraphServerHostHeader::test_non_loopback_origin_rejected
========================================================================================== test session starts ==========================================================================================
platform linux -- Python 3.14.2, pytest-9.1.1, pluggy-1.6.0
rootdir: /usr/local/google/home/partheniou/google-cloud-python-20260814/packages/bigquery-magics
plugins: cov-7.1.0, snapshot-0.9.0, anyio-4.14.2, asyncio-1.3.0, pyfakefs-6.2.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 1 item                                                                                                                                                                                        

packages/bigquery-magics/tests/unit/test_graph_server.py F                                                                                                                                        [100%]

=============================================================================================== FAILURES ================================================================================================
______________________________________________________________________ TestGraphServerHostHeader.test_non_loopback_origin_rejected ______________________________________________________________________

self = <unit.test_graph_server.TestGraphServerHostHeader testMethod=test_non_loopback_origin_rejected>

    @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)
E       AssertionError: 200 != 403

packages/bigquery-magics/tests/unit/test_graph_server.py:821: AssertionError
----------------------------------------------------------------------------------------- Captured stdout call ------------------------------------------------------------------------------------------
BigQuery-magics graph server shutting down...
======================================================================================== short test summary info ========================================================================================
FAILED packages/bigquery-magics/tests/unit/test_graph_server.py::TestGraphServerHostHeader::test_non_loopback_origin_rejected - AssertionError: 200 != 403
=========================================================================================== 1 failed in 5.62s ===========================================================================================
(py3142) partheniou@partheniou-vm-3:~/google-cloud-python-20260814$ pytest packages/bigquery-magics/tests/unit/test_graph_server.py::TestGraphServerHostHeader::test_non_loopback_referer_rejected -v
========================================================================================== test session starts ==========================================================================================
platform linux -- Python 3.14.2, pytest-9.1.1, pluggy-1.6.0 -- /usr/local/google/home/partheniou/.pyenv/versions/3.14.2/envs/py3142/bin/python3.14
cachedir: .pytest_cache
rootdir: /usr/local/google/home/partheniou/google-cloud-python-20260814/packages/bigquery-magics
plugins: cov-7.1.0, snapshot-0.9.0, anyio-4.14.2, asyncio-1.3.0, pyfakefs-6.2.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 1 item                                                                                                                                                                                        

packages/bigquery-magics/tests/unit/test_graph_server.py::TestGraphServerHostHeader::test_non_loopback_referer_rejected FAILED                                                                    [100%]

=============================================================================================== FAILURES ================================================================================================
_____________________________________________________________________ TestGraphServerHostHeader.test_non_loopback_referer_rejected ______________________________________________________________________

self = <unit.test_graph_server.TestGraphServerHostHeader testMethod=test_non_loopback_referer_rejected>

    @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)
E       AssertionError: 200 != 403

packages/bigquery-magics/tests/unit/test_graph_server.py:836: AssertionError
----------------------------------------------------------------------------------------- Captured stdout call ------------------------------------------------------------------------------------------
BigQuery-magics graph server shutting down...
======================================================================================== short test summary info ========================================================================================
FAILED packages/bigquery-magics/tests/unit/test_graph_server.py::TestGraphServerHostHeader::test_non_loopback_referer_rejected - AssertionError: 200 != 403
=========================================================================================== 1 failed in 5.76s ===========================================================================================
(py3142) partheniou@partheniou-vm-3:~/google-cloud-python-20260814$ 

Gemini suggested this fix

Suggested implementation for _host_is_loopback (renamed to reflect both checks):

    def _is_request_safe(self):
        """Return True if the request headers target and originate from loopback.

        Verifies the Host header to prevent DNS rebinding, and validates the
        Origin and Referer headers (if present) to prevent direct cross-site requests.
        """
        # 1. Validate Host header (DNS Rebinding protection)
        host = self.headers.get("Host")
        if not host:
            return False
        try:
            # strip() prevents whitespace injection tricks
            hostname = urllib.parse.urlsplit(f"//{host.strip()}").hostname
        except ValueError:
            return False
        if hostname not in _LOOPBACK_HOSTS:
            return False

        # 2. Validate Origin/Referer headers if present (Cross-Origin protection)
        for header_name in ("Origin", "Referer"):
            value = self.headers.get(header_name)
            if value:
                try:
                    origin_hostname = urllib.parse.urlsplit(value.strip()).hostname
                except ValueError:
                    return False
                # If the header has a parsed hostname, it must be loopback
                if origin_hostname and origin_hostname not in _LOOPBACK_HOSTS:
                    return False
                    
        return True

@parthea parthea assigned Samin061 and unassigned parthea Aug 14, 2026
Signed-off-by: bibi samina <sam@bugqore.com>
@Samin061

Copy link
Copy Markdown
Contributor Author

Done. Folded the Origin/Referer check into the helper (now _is_request_safe): Host still guards against DNS rebinding, and if Origin or Referer is present its hostname has to be loopback too, so a page on a non-loopback site can't drive the server with a direct fetch. Real widget traffic is on a different port but the same loopback hostname, so it still passes. Added both tests you posted plus the port-mismatch case, and lint/black are clean.

@Samin061

Copy link
Copy Markdown
Contributor Author

any update?

@parthea parthea assigned parthea and unassigned Samin061 Oct 17, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants