Authlib: is_secure_transport() loopback exemption can be bypassed with a userinfo component, allowing cleartext-HTTP URLs to non-loopback hosts
### 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:80@evil.example/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:80@evil.example/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:...@evil.example/`, `http://[::1]:...@evil.example/`). `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:80@attacker.example/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:80@evil.example/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:80@evil.example/cb", "http://127.0.0.1:80@evil.example/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:80@evil.example/cb') = True [actual host = 'evil.example', scheme = 'http'] is_secure_transport('http://127.0.0.1:80@evil.example/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:80@attacker.example/…`, 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.
describes (1)
Related CVEs.
| ID | severity | exploitable | ||
|---|---|---|---|---|
| CVE-2026-114614US | —OT elevated | — | ||
Authlib is a Python library which builds OAuth and OpenID Connect servers. From 0.13 until 1.8.0, authlib.common.security.is_secure_transport() uses a textual prefix comparison for… | ||||
references
External citations — patches, advisories, analysis.
- https://github.com/authlib/authlibPACKAGE
- https://github.com/authlib/authlib/commit/95ad1f90acf0c7b9cf2a8f25e6eb3f1135b8dc70WEB
- https://github.com/authlib/authlib/releases/tag/v1.8.0WEB
- https://github.com/authlib/authlib/security/advisories/GHSA-jj7h-c3hg-m23xWEB
- https://nvd.nist.gov/vuln/detail/CVE-2026-114614ADVISORY
Advisories like this name the exposed products attackers go after on your perimeter. See what is exposed on your perimeter with a free passive scan.
Free scan