GitHub Advisoryadvisory · PyPI
GHSA-jj7h-c3hg-m23x Authlib: is_secure_transport() loopback exemption can be bypassed with a userinfo component, allowing cleartext-HTTP URLs to non-loopback hosts
LOW
Official page ↗
- Aliases
- CVE-2026-114614
- Ecosystem
- PyPI
- Packages
- authlib
- Severity
- Low
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:N/A:N
- Weaknesses
- CWE-319, CWE-601, CWE-693
- Reviewed
- 2026-10-27
Affected packages
• PyPI authlib: < 1.8.0 → fixed in 1.8.0
Description
Summary
`authlib.common.security.is_secure_transport()` is used throughout Authlib to enforce the OAuth 2.0 / OIDC requirement that endpoint and redirect URIs use TLS, with an RFC 8252 §7.3 exemption for loopback `http://` URIs. The check is implemented as a textual `str.startswith` prefix match, so a URL such as `http://localhost:[email protected]/cb` — whose actual host is `evil.example` and whose scheme is plain `http` — is accepted as "secure". An attacker who can supply a URL to any of the sinks that rely on this check (OIDC RP-Initiated Logout dynamic client registration, RFC 8414 authorization-server metadata validation, error-redirect rendering) can smuggle a cleartext non-loopback URL past the TLS-required guard. This is a defense-in-depth / spec-conformance failure; severity is low.
Details
Affected versions: `authlib >= 0.13, <= 1.7.2` (all releases containing the loopback prefix exemption; verified against 1.7.2, the current latest)
The vulnerable function, from `authlib/common/security.py` at v1.7.2:
```python
13 def is_secure_transport(uri):
14 """Check if the uri is over ssl."""
15 if os.getenv("AUTHLIB_INSECURE_TRANSPORT"):
16 return True
17
18 uri = uri.lower()
19 return uri.startswith(
20 ("https://", "http://localhost:", "http://127.0.0.1:", "http://[::1]:")
21 )
```
Per RFC 3986 §3.2 the authority component of a URI is `[ userinfo "@" ] host [ ":" port ]`. The URL `http://localhost:[email protected]/cb` therefore has userinfo `localhost:80` and host `evil.example`. Because the check is a raw prefix match against the full URL string, the `http://localhost:` prefix matches the userinfo portion and the function returns `True` even though the scheme is `http` and the host is an arbitrary external name. The trailing `:` in each allow-list entry — presumably intended to force a `host:port` shape and reject `http://localhost.evil.com` — is also satisfied by the `user:password` separator inside userinfo, so `http://localhost:@evil.example/` matches too. The same construction works against all three loopback prefixes (`http://127.0.0.1:[email protected]/`, `http://[::1]:[email protected]/`).
`is_secure_transport()` is consumed at the following locations in v1.7.2, none of which independently reject URLs containing a userinfo component:
• `authlib/oidc/rpinitiated/registration.py:50-57` — validation of `post_logout_redirect_uris` during OIDC dynamic client registration for public clients (`token_endpoint_auth_method == "none"`). The preceding `is_valid_url()` check (`authlib/common/urls.py:142-146`) only requires `parsed.scheme and parsed.hostname` and does not reject userinfo, so the crafted URL passes both guards. A public client can therefore register `http://localhost:[email protected]/cb` as a post-logout redirect target; the OpenID Provider will later 302 the end-user's browser to that cleartext non-loopback URL (carrying `state`).
• `authlib/oauth2/rfc8414/models.py:61,75,100,115,123,248,284` — validation of `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `registration_endpoint`, `revocation_endpoint`, `introspection_endpoint` in Authorization Server Metadata / OIDC Discovery documents. A relying party using `AuthorizationServerMetadata.validate()` would accept endpoint values of this form as satisfying the "MUST use https" requirement when they do not.
• `authlib/oidc/rpinitiated/discovery.py:12` — validation of `end_session_endpoint` in provider metadata.
• `authlib/oauth2/rfc6749/errors.py:64` and `authlib/oauth1/rfc5849/errors.py:33` — governs how `redirect_uri` is rendered in error bodies (cosmetic).
The `http://localhost:` prefix was introduced in commit `0225c86b` (first released in v0.13); `http://127.0.0.1:` and `http://[::1]:` were added later in the 1.6.x line. All variants share the same prefix-match weakness.
PoC
Against the current latest release (1.7.2):
```sh
python3 -m venv venv
./venv/bin/pip install 'authlib==1.7.2'
./venv/bin/python poc.py
```
`poc.py`:
```python
!/usr/bin/env python3
"""
is_secure_transport() must return True only for https:// URLs, or http://
URLs whose host is a loopback address (localhost / 127.0.0.1 / ::1).
Bypass: http://localhost:[email protected]/cb has host 'evil.example'
(userinfo = 'localhost:80') per RFC 3986, but matches the prefix
'http://localhost:' and is therefore accepted as secure.
"""
from urllib.parse import urlparse
from authlib.common.security import is_secure_transport
cases = [
"http://localhost:[email protected]/cb",
"http://127.0.0.1:[email protected]/cb",
"http://localhost:@evil.example/cb",
]
vulnerable = False
for url in cases:
parsed = urlparse(url)
ok = is_secure_transport(url)
print(f"is_secure_transport({url!r}) = {ok} "
f"[actual host = {parsed.hostname!r}, scheme = {parsed.scheme!r}]")
if ok and parsed.hostname not in ("localhost", "127.0.0.1", "::1") and parsed.scheme != "https":
vulnerable = True
if vulnerable:
print("\nVULNERABLE: is_secure_transport() returned True for a plain-HTTP "
"URL whose host is NOT a loopback address.")
else:
print("\nNOT VULNERABLE")
assert vulnerable, "expected bypass not observed"
```
Expected output:
```
is_secure_transport('http://localhost:[email protected]/cb') = True [actual host = 'evil.example', scheme = 'http']
is_secure_transport('http://127.0.0.1:[email protected]/cb') = True [actual host = 'evil.example', scheme = 'http']
is_secure_transport('http://localhost:@evil.example/cb') = True [actual host = 'evil.example', scheme = 'http']
VULNERABLE: is_secure_transport() returned True for a plain-HTTP URL whose host is NOT a loopback address.
```
Impact
Deployments are affected if they rely on `is_secure_transport()` — directly or via the Authlib call sites listed above — to reject cleartext non-loopback URLs supplied by an external party. The most realistic path is an OpenID Provider that enables OIDC dynamic client registration and RP-Initiated Logout: a public client can register a `post_logout_redirect_uris` entry of the form `http://localhost:[email protected]/…`, which the provider will later use as an open-redirect target over plain HTTP. Relying parties that call `AuthorizationServerMetadata.validate()` on fetched discovery documents are also affected, though exploiting that sink requires the attacker to influence the metadata document. This does not bypass the OAuth 2.0 redirect-URI exact-match registration check and does not by itself yield authentication bypass or token theft; it defeats a TLS-required hardening check.
Suggested fix: Parse the URI with `urllib.parse.urlsplit()` and return `True` only if `scheme == "https"`, or `scheme == "http"` and `hostname in {"localhost", "127.0.0.1", "::1"}`; additionally reject any URI whose parsed `username`/`password` is non-empty at these validation sites.
Related
Alias · CVE (1)
- CVE-2026-114614 Authlib is a Python library which builds OAuth and OpenID Connect servers. vulnerability
References
- https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23x web
- https://nvd.nist.gov/vuln/detail/CVE-2026-114614 advisory
- https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70 web
- https://github.com/authlib/authlib package
- https://github.com/authlib/authlib/releases/tag/v1.8.0 web