Skip to content

Commit d6af580

Browse files
committed
Handle IPv6 localhost RP ID resolution
1 parent 292d3d7 commit d6af580

File tree

2 files changed

+63
-2
lines changed

2 files changed

+63
-2
lines changed

server/server/config.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from __future__ import annotations
33

44
import base64
5+
import ipaddress
56
import os
67
import re
78
import ssl
89
import tempfile
910
import textwrap
1011
from typing import Mapping, Optional, Set
12+
from urllib.parse import urlsplit
1113

1214
import fido2.features
1315
from flask import Flask, has_request_context, request
@@ -188,16 +190,67 @@ def determine_rp_id(explicit_id: Optional[str] = None) -> str:
188190
return configured_id.strip()
189191

190192
if has_request_context():
191-
host = request.host.split(":", 1)[0].strip().lower()
193+
host = _resolve_request_host()
192194
if host in {"", None}:
193195
return "localhost"
196+
try:
197+
if ipaddress.ip_address(host).is_loopback:
198+
return "localhost"
199+
except ValueError:
200+
pass
194201
if host in {"127.0.0.1", "::1"}:
195202
return "localhost"
196203
return host
197204

198205
return "localhost"
199206

200207

208+
def _resolve_request_host() -> Optional[str]:
209+
"""Return the current request host without port decoration."""
210+
211+
if not has_request_context():
212+
return None
213+
214+
for raw_host in (
215+
request.headers.get("Host"),
216+
request.environ.get("HTTP_HOST"),
217+
request.environ.get("SERVER_NAME"),
218+
):
219+
host = _normalise_request_host(raw_host)
220+
if host:
221+
return host
222+
223+
return None
224+
225+
226+
def _normalise_request_host(raw_host: Optional[str]) -> Optional[str]:
227+
"""Normalise a raw host header into a lowercase hostname or IP literal."""
228+
229+
if not isinstance(raw_host, str):
230+
return None
231+
232+
host = raw_host.strip().lower()
233+
if not host:
234+
return None
235+
236+
if host.startswith("["):
237+
closing_index = host.find("]")
238+
if closing_index != -1:
239+
unwrapped = host[1:closing_index].strip()
240+
return unwrapped or None
241+
242+
if host.count(":") > 1:
243+
# Treat unbracketed multi-colon values as IPv6 literals without ports.
244+
return host
245+
246+
parsed = urlsplit(f"//{host}")
247+
normalised = parsed.hostname
248+
if isinstance(normalised, str) and normalised.strip():
249+
return normalised.strip().lower()
250+
251+
return host
252+
253+
201254
def build_rp_entity(
202255
rp_data: Optional[Mapping[str, str]] = None,
203256
*,

tests/test_config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,18 @@ def test_determine_rp_id_with_request_context():
344344
rp_id = determine_rp_id()
345345
assert rp_id == "localhost"
346346

347-
# IPv6 localhost - the host header includes brackets
347+
# IPv6 localhost from a raw host value without brackets.
348348
with app.test_request_context(
349349
"http://[::1]/path",
350350
headers={"Host": "::1"} # Without brackets in header
351351
):
352352
rp_id = determine_rp_id()
353353
assert rp_id == "localhost"
354+
355+
# IPv6 localhost with the bracketed host:port form browsers send.
356+
with app.test_request_context(
357+
"http://[::1]:8443/path",
358+
headers={"Host": "[::1]:8443"}
359+
):
360+
rp_id = determine_rp_id()
361+
assert rp_id == "localhost"

0 commit comments

Comments
 (0)