Skip to content

Python: fix(devui): accept the IPv6 loopback Host header on a loopback bind - #9712

Open
lisry-1 wants to merge 1 commit into
microsoft:mainfrom
lisry-1:fix/devui-ipv6-loopback-host
Open

lisry-1 wants to merge 1 commit into
microsoft:mainfrom
lisry-1:fix/devui-ipv6-loopback-host

Conversation

@lisry-1

@lisry-1 lisry-1 commented Oct 26, 2026

Copy link
Copy Markdown

Motivation & Context

On a loopback bind, DevUI only serves requests whose Host header names a loopback address. The header was reduced to a host name with host_header.split(":", 1)[0], which is correct for 127.0.0.1:8080 and localhost:8080 but not for IPv6 literals: an IPv6 literal carries colons of its own, so [::1] and [::1]:8080 both become [.

[ matches no member of _LOOPBACK_HOSTS ({"127.0.0.1", "localhost", "[::1]", "::1"}), so every request was answered with 400 host_not_allowed — including on a server bound to ::1 itself. A browser addressing http://[::1]:8080 sends exactly that header, so DevUI is unusable over the IPv6 loopback.

Reported in #9709.

Description & Review Guide

  • What are the major changes?

    python/packages/devui/agent_framework_devui/_server.py:

    • New DevServer._hostname_from_host_header() static helper returns the host name part of a Host header, lowercased and without a port:
      • a value starting with [ is an IPv6 literal, so everything up to and including ] is returned ([::1]:8080 → [::1]);
      • otherwise a port is stripped only when there is exactly one colon, so a bare ::1 (which holds two) is left intact and still matches the allowlist;
      • an unmatched [ falls through unchanged rather than being truncated.
    • The host_header_middleware uses it in place of split(":", 1)[0].lower().

    python/packages/devui/tests/devui/test_server.py:

    • Table-driven test for the parser (127.0.0.1, 127.0.0.1:8080, localhost:8080, LOCALHOST:8080, [::1], [::1]:8080, [::FFFF:127.0.0.1]:8080, ::1, empty).
    • Regression test that drives /health through TestClient with Host: [::1] and Host: [::1]:8080 on all three loopback binds (127.0.0.1, ::1, [::1]), and asserts a non-loopback Host is still rejected with 400.

    The allowlist itself is unchanged: non-loopback Host values, and Host values on public binds, behave exactly as before.

  • What is the impact of these changes?

    Requests to a loopback-bound DevUI addressed as [::1] or [::1]:<port> are now served instead of rejected. Nothing else changes; the middleware still runs outermost, before CORS and auth.

  • What do you want reviewers to focus on?

    _AUTH_LOOPBACK_HOSTS ({"127.0.0.1", "localhost"}) still omits ::1 and [::1], so DevServer(host="::1", auth_enabled=False) raises "authentication cannot be disabled for non-loopback hosts" even though ::1 is loopback. That is a separate, security-relevant widening of the no-auth allowlist, so I left it alone here. Say the word if you want it folded into this PR.

Related Issue

Fixes #9709

This is submitted without prior maintainer sign-off on the issue. To be explicit about why I consider it under the trivial-change bar: the issue states the desired outcome ("requests with Host [::1] or [::1]:<port> are accepted on a loopback bind... non-loopback hosts are still rejected"), there is no design choice or spec link involved, and the change cannot widen what is accepted beyond the loopback addresses the allowlist already names. If you would rather this waited for triage, say so and I will hold.

Contribution Checklist

  • The code builds clean without any errors/warnings (ruff check and ruff format --check pass)
  • New tests were added for the change
  • Regression verified: the new middleware test fails without the fix on all three binds
  • No unrelated changes included

AI Assistance

  • An AI coding assistant helped locate the defect, draft the fix, and write the tests.
  • I understand and reviewed every change in this pull request.
  • The reproduction and all tests were run locally; the outputs quoted below are from those runs.

The reporter's reproduction script, run against this branch:

bind='127.0.0.1'  Host='127.0.0.1:8080'       -> 200
bind='127.0.0.1'  Host='[::1]:8080'           -> 200
bind='127.0.0.1'  Host='[::1]'                -> 200
bind='127.0.0.1'  Host='evil.example:8080'    -> 400
bind='127.0.0.1'  Host=''                     -> 200

bind='::1'        Host='127.0.0.1:8080'       -> 200
bind='::1'        Host='[::1]:8080'           -> 200
bind='::1'        Host='[::1]'                -> 200
bind='::1'        Host='evil.example:8080'    -> 400
bind='::1'        Host=''                     -> 200

bind='[::1]'      Host='127.0.0.1:8080'       -> 200
bind='[::1]'      Host='[::1]:8080'           -> 200
bind='[::1]'      Host='[::1]'                -> 200
bind='[::1]'      Host='evil.example:8080'    -> 400
bind='[::1]'      Host=''                     -> 200

VERDICT: not reproduced

Before the fix, all six [::1] rows returned 400. Confirmed by restoring the old split(":", 1)[0] line and re-running: test_loopback_bind_accepts_ipv6_loopback_host_header fails on all three binds with assert 400 == 200.

Test run: python/packages/devui/tests/devui/test_server.py 62 passed.

…k bind

Host headers for IPv6 literals carry their own colons, so splitting on the
first colon reduced both '[::1]' and '[::1]:8080' to '['. No allowlist member
matches that, so every request a browser sends to http://[::1]:<port> was
answered with 400 host_not_allowed, including on a bind to ::1 itself.

Parse the host part with the brackets intact instead, and strip a trailing
port only when the host is not an IPv6 literal.

Copilot AI 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.

🟢 Approval recommended

The narrow parsing fix addresses the reported regression with targeted tests and no identified blocking issues.

No open issues 🎉

What changed in this PR

Fixes #9709 by allowing DevUI’s loopback Host-header check to recognize IPv6 loopback addresses without splitting their internal colons.

Changes:

  • Adds a hostname parser that handles bracketed IPv6 literals, optional ports, and bare IPv6 addresses.
  • Uses the parser in the existing middleware without changing the host or authentication allowlists.
  • Adds parser tests and IPv6 loopback regression coverage across three loopback bind values.
File Description
python/​packages/​devui/​tests/​devui/​test_server.py Tests hostname parsing, IPv6 loopback acceptance, and non-loopback rejection.
python/​packages/​devui/​agent_framework_devui/​_server.py Adds IPv6-aware hostname parsing and integrates it into Host-header validation.

Review effort: Balanced

This branch was previously deployed

1 inactive deployment
github-app-auth — 42beb506 Deployed Oct 26, 2026 by lisry-1 via team_check #7498
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: DevUI rejects every request addressed to the IPv6 loopback ([::1] Host header)

2 participants