Skip to content

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

Merged
Eduard van Valkenburg (eavanvalkenburg) merged 2 commits into
microsoft:mainfrom
lisry-1:fix/devui-ipv6-loopback-host
Nov 4, 2026
Merged

Eduard van Valkenburg (eavanvalkenburg) merged 2 commits 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
Contributor

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

@alphasafal

Copy link
Copy Markdown

Following up from #9709. I ran _hostname_from_host_header against some malformed Host values, along the lines of the matrix Eduard asked for there. Anything after the closing ] is dropped instead of rejected:

Host result
[::1]evil.example [::1], accepted
[::1]:notaport [::1], accepted
[::1]]:8080 [::1], accepted

One way to fail closed is to accept a bracketed host only when what follows ] is empty or : plus digits, and to return the raw value otherwise, so it misses the allowlist. These three would make good negative cases next to the existing evil.example test.

A bracketed Host like '[::1]evil.example' or '[::1]:notaport' was reduced
to '[::1]' by taking everything up to the first ']', so the trailing
garbage was silently dropped and the request passed the loopback allowlist.

Accept a bracketed host only when what follows ']' is empty or a numeric
':port'; otherwise return the raw value so it misses the allowlist and is
rejected. Add negative parser cases and a middleware regression.
@lisry-1

lisry-1 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Safal Gupta (@alphasafal) thanks for the thorough matrix — those are exactly the fail-open cases I missed.

I've pushed a fix that fails closed:

  • _hostname_from_host_header now accepts a bracketed host only when what follows ] is empty or a numeric :port. Anything else ([::1]evil.example, [::1]:notaport, [::1]]:8080) is returned unchanged, so it misses the loopback allowlist and the request is rejected with 400.
  • Added the three cases as negative parser cases in test_hostname_from_host_header, plus a middleware-level regression test_loopback_bind_rejects_malformed_bracketed_host asserting each returns 400.

Merged via the queue into microsoft:main with commit 84573b1 Nov 4, 2026
54 checks passed

This branch was previously deployed

1 inactive deployment
github-app-auth — fe38bbc9 Deployed Nov 4, 2026 by lisry-1 via add_label #27055
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)

4 participants