Repository navigation
Python: fix(devui): accept the IPv6 loopback Host header on a loopback bind - #9712
Conversation
…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.
There was a problem hiding this comment.
🟢 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
|
Following up from #9709. I ran
One way to fail closed is to accept a bracketed host only when what follows |
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.
|
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:
|
Motivation & Context
On a loopback bind, DevUI only serves requests whose
Hostheader names a loopback address. The header was reduced to a host name withhost_header.split(":", 1)[0], which is correct for127.0.0.1:8080andlocalhost:8080but not for IPv6 literals: an IPv6 literal carries colons of its own, so[::1]and[::1]:8080both become[.[matches no member of_LOOPBACK_HOSTS({"127.0.0.1", "localhost", "[::1]", "::1"}), so every request was answered with400 host_not_allowed— including on a server bound to::1itself. A browser addressinghttp://[::1]:8080sends 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:DevServer._hostname_from_host_header()static helper returns the host name part of aHostheader, lowercased and without a port:[is an IPv6 literal, so everything up to and including]is returned ([::1]:8080→[::1]);::1(which holds two) is left intact and still matches the allowlist;[falls through unchanged rather than being truncated.host_header_middlewareuses it in place ofsplit(":", 1)[0].lower().python/packages/devui/tests/devui/test_server.py:127.0.0.1,127.0.0.1:8080,localhost:8080,LOCALHOST:8080,[::1],[::1]:8080,[::FFFF:127.0.0.1]:8080,::1, empty)./healththroughTestClientwithHost: [::1]andHost: [::1]:8080on all three loopback binds (127.0.0.1,::1,[::1]), and asserts a non-loopbackHostis still rejected with 400.The allowlist itself is unchanged: non-loopback
Hostvalues, andHostvalues 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::1and[::1], soDevServer(host="::1", auth_enabled=False)raises "authentication cannot be disabled for non-loopback hosts" even though::1is 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
ruff checkandruff format --checkpass)AI Assistance
The reporter's reproduction script, run against this branch:
Before the fix, all six
[::1]rows returned 400. Confirmed by restoring the oldsplit(":", 1)[0]line and re-running:test_loopback_bind_accepts_ipv6_loopback_host_headerfails on all three binds withassert 400 == 200.Test run:
python/packages/devui/tests/devui/test_server.py62 passed.