GHSA-6HX8-3WJJ-GR8G
Vulnerability from github – Published: 2026-08-27 22:10 – Updated: 2026-08-27 22:10Summary
This is a third follow-up to CVE-2024-42353 / GHSA-mg3v-6m49-jhp3 and CVE-2026-44889 / GHSA-fh3h-vg37-cc95.
WebOb makes the Location header absolute when it serves a redirect. To stop a
relative or protocol-relative target from redirecting users off-host, it checks
the value for a URI scheme and for a leading //, then joins it against the
request URI with urllib.parse.urljoin(). The previous fix additionally stripped
ASCII tab/CR/LF from the value before those checks.
However, on Python 3.10+ urllib.parse.urljoin() (via urlsplit()) does more
than remove tab/CR/LF: it also strips leading and trailing C0 control
characters (U+0000–U+001F) and spaces from the URL before parsing it.
Because WebOb's guard checks (SCHEME_RE and startswith("//")) run against the
un-stripped value, a single leading space or control byte slips past them, and
urljoin() then silently removes that byte and parses what remains as a
protocol-relative — or even absolute — URL. The result is an open redirect to an
attacker-controlled host.
Details
Response._make_location_absolute() (in src/webob/response.py) performed,
prior to the fix:
value = value.replace("\t", "").replace("\r", "").replace("\n", "")
if SCHEME_RE.search(value): # ^[a-z]+: -> already absolute, return as-is
return value
if value.startswith("//"): # neutralize protocol-relative URLs
value = f"/%2f{value[2:]}"
new_location = urlparse.urljoin(_request_uri(environ), value)
Consider the Location value " //www.example.com/test" (a single leading space):
- The explicit strip only removes
\t,\r,\n— the leading space survives. SCHEME_RE(^[a-z]+:) does not match — the value starts with a space.value.startswith("//")is False — the value starts with a space, not/. The//→/%2fneutralization is skipped.urllib.parse.urljoin(_request_uri(environ), " //www.example.com/test")then strips the leading space before parsing, sees//www.example.com/test, treats it as protocol-relative, and returnshttp://www.example.com/test.
The same bypass works with a value such as " https://www.example.com/test"
(leading space + a full scheme): SCHEME_RE does not match the space-prefixed
string, but urljoin() strips the space and returns the fully absolute
attacker URL https://www.example.com/test.
Any C0 control character works equally well in place of the space, e.g.
"\x00//www.example.com/test" or "\x1f//www.example.com/test", because
urlsplit() strips the whole leading C0-control-and-space run.
Affected entry points
Response.location— any application that sets a relative/attacker-influencedLocationand serves the response (the classic redirect path).Request.relative_url()— usedurllib.parse.urljoin()directly and was subject to the same character stripping.webob.exc._HTTPMovesubclasses (HTTPMovedPermanently,HTTPFound,HTTPSeeOther,HTTPTemporaryRedirect,HTTPPermanentRedirect, etc.) — these built their absolute Location withurlparse.urljoin(req.path_url, self.location)without going through_make_location_absolute()at all, so they bypassed even the tab/CR/LF strip and the//→/%2fneutralization. A protocol-relative location passed to e.g.HTTPFound(location="//evil.example")redirected off-host.
Proof of Concept
from webob import Response
from webob.request import Request
res = Response()
res.status = "301"
res.location = " //www.example.com/test" # note the single leading space
req = Request.blank("/") # request host is "localhost"
print(req.get_response(res).location)
# Vulnerable (<= 1.8.10): http://www.example.com/test <-- open redirect
# Fixed: http://localhost/ //www.example.com/test
Absolute-URL variant:
res.location = " https://www.example.com/test"
# Vulnerable: https://www.example.com/test <-- off-host
# Fixed: http://localhost/ https://www.example.com/test
Via the HTTP exceptions:
from webob import exc
environ = {
"wsgi.url_scheme": "http", "SERVER_NAME": "localhost",
"SERVER_PORT": "80", "REQUEST_METHOD": "HEAD", "PATH_INFO": "/",
}
m = exc.HTTPFound(location="//www.example.com/test")
m(environ, lambda *a, **k: None)
print(m.location)
# Vulnerable: //www.example.com/test <-- open redirect
# Fixed: http://localhost/%2fwww.example.com/test
Impact
An unauthenticated remote attacker who controls (in whole or part) the redirect
target of an application built on WebOb can redirect a user from a trusted host to
an attacker-controlled host. This enables phishing and credential-theft campaigns
that abuse the trusted origin, and can be chained with OAuth/SSO redirect_uri
flows to leak tokens. Exploitation requires user interaction (following the
redirect). Confidentiality and integrity impact are limited (L); the scope is
changed (C) because the trust boundary of the originating site is crossed.
Patches
Fixed by replacing the use of urllib.parse.urljoin() with WebOb's own
RFC 3986 reference-resolution implementation, webob.util.urljoin(), which
resolves the reference exactly as given, character for character, with no
whitespace or control-character removal.
Response._make_location_absolute()now useswebob.util.urljoin().Request.relative_url()now useswebob.util.urljoin().webob.exc._HTTPMovenow normalizes its Location through the same_make_location_absolute()code path asResponse, so protocol-relative and whitespace-smuggled locations are neutralized there too.
Users should upgrade to the patched release. There are no API changes.
Workarounds
- Only ever set the
Locationheader / redirect target to a fully-qualified URI whose host you control, or strictly allowlist redirect destinations before handing them to WebOb. - Reject any redirect target that does not begin with
https://yourhost/(or a validated relative path with no leading whitespace/control bytes).
References
- This advisory: GHSA-6hx8-3wjj-gr8g
- GHSA-fh3h-vg37-cc95 (CVE-2026-44889) — second incomplete fix (tab/CR/LF)
- GHSA-mg3v-6m49-jhp3 (CVE-2024-42353) — original open redirect fix
- RFC 3986, Section 5 — Reference Resolution: https://www.rfc-editor.org/rfc/rfc3986#section-5
- Python
urllib.parseURL stripping behavior (CPython 3.10+, removal of leading and trailing C0 control and space characters): https://docs.python.org/3/library/urllib.parse.html
To report a vulnerability to the Pylons Project please take a look at:
- Pylons Project security policy and reporting process: https://github.com/Pylons/.github/blob/main/SECURITY.md
- Security contact (private, coordinated disclosure):
pylons-project-security@googlegroups.com(the Pylons Project requests a 90-day disclosure embargo)
Credit
Reported via the Pylons Project security mailing list by:
- tonghuaroot — for the residual open redirect in
Response._make_location_absolute(): the 1.8.10 fix stripped only ASCII tab/CR/LF, buturllib.parse.urljoin()also strips leading C0 control and space characters, so values such as" //attacker.example/path"(and" https://attacker.example/path") still escaped off-host. - Matheus Polkorny — for identifying that the
webob.exc._HTTPMoveredirect exceptions (HTTPFoundand friends) performed their ownurllib.parse.urljoin()normalization and never went through_make_location_absolute(), so a protocol-relative location such as//evil.example/path/redirected off-host through that separate code path.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "webob"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.8.11"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54770"
],
"database_specific": {
"cwe_ids": [
"CWE-601"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-27T22:10:07Z",
"nvd_published_at": "2026-08-20T17:18:18Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nThis is a third follow-up to **CVE-2024-42353 / GHSA-mg3v-6m49-jhp3**\nand **CVE-2026-44889 / GHSA-fh3h-vg37-cc95**.\n\nWebOb makes the `Location` header absolute when it serves a redirect. To stop a\nrelative or protocol-relative target from redirecting users off-host, it checks\nthe value for a URI scheme and for a leading `//`, then joins it against the\nrequest URI with `urllib.parse.urljoin()`. The previous fix additionally stripped\nASCII tab/CR/LF from the value before those checks.\n\nHowever, on Python 3.10+ `urllib.parse.urljoin()` (via `urlsplit()`) does more\nthan remove tab/CR/LF: **it also strips leading and trailing C0 control\ncharacters (`U+0000`\u2013`U+001F`) and spaces from the URL before parsing it.**\nBecause WebOb\u0027s guard checks (`SCHEME_RE` and `startswith(\"//\")`) run against the\n*un-stripped* value, a single leading space or control byte slips past them, and\n`urljoin()` then silently removes that byte and parses what remains as a\nprotocol-relative \u2014 or even absolute \u2014 URL. The result is an open redirect to an\nattacker-controlled host.\n\n## Details\n\n`Response._make_location_absolute()` (in `src/webob/response.py`) performed,\nprior to the fix:\n\n```python\nvalue = value.replace(\"\\t\", \"\").replace(\"\\r\", \"\").replace(\"\\n\", \"\")\n\nif SCHEME_RE.search(value): # ^[a-z]+: -\u003e already absolute, return as-is\n return value\n\nif value.startswith(\"//\"): # neutralize protocol-relative URLs\n value = f\"/%2f{value[2:]}\"\n\nnew_location = urlparse.urljoin(_request_uri(environ), value)\n```\n\nConsider the Location value `\" //www.example.com/test\"` (a single leading space):\n\n1. The explicit strip only removes `\\t`, `\\r`, `\\n` \u2014 the leading **space**\n survives.\n2. `SCHEME_RE` (`^[a-z]+:`) does **not** match \u2014 the value starts with a space.\n3. `value.startswith(\"//\")` is **False** \u2014 the value starts with a space, not\n `/`. The `//` \u2192 `/%2f` neutralization is skipped.\n4. `urllib.parse.urljoin(_request_uri(environ), \" //www.example.com/test\")` then\n **strips the leading space** before parsing, sees `//www.example.com/test`,\n treats it as protocol-relative, and returns\n `http://www.example.com/test`.\n\nThe same bypass works with a value such as `\" https://www.example.com/test\"`\n(leading space + a full scheme): `SCHEME_RE` does not match the space-prefixed\nstring, but `urljoin()` strips the space and returns the fully absolute\nattacker URL `https://www.example.com/test`.\n\nAny C0 control character works equally well in place of the space, e.g.\n`\"\\x00//www.example.com/test\"` or `\"\\x1f//www.example.com/test\"`, because\n`urlsplit()` strips the whole leading C0-control-and-space run.\n\n### Affected entry points\n\n- **`Response.location`** \u2014 any application that sets a relative/attacker-influenced\n `Location` and serves the response (the classic redirect path).\n- **`Request.relative_url()`** \u2014 used `urllib.parse.urljoin()` directly and was\n subject to the same character stripping.\n- **`webob.exc._HTTPMove` subclasses** (`HTTPMovedPermanently`, `HTTPFound`,\n `HTTPSeeOther`, `HTTPTemporaryRedirect`, `HTTPPermanentRedirect`, etc.) \u2014 these\n built their absolute Location with `urlparse.urljoin(req.path_url, self.location)`\n **without** going through `_make_location_absolute()` at all, so they bypassed\n even the tab/CR/LF strip and the `//` \u2192 `/%2f` neutralization. A protocol-relative\n location passed to e.g. `HTTPFound(location=\"//evil.example\")` redirected off-host.\n\n## Proof of Concept\n\n```python\nfrom webob import Response\nfrom webob.request import Request\n\nres = Response()\nres.status = \"301\"\nres.location = \" //www.example.com/test\" # note the single leading space\n\nreq = Request.blank(\"/\") # request host is \"localhost\"\nprint(req.get_response(res).location)\n# Vulnerable (\u003c= 1.8.10): http://www.example.com/test \u003c-- open redirect\n# Fixed: http://localhost/ //www.example.com/test\n```\n\nAbsolute-URL variant:\n\n```python\nres.location = \" https://www.example.com/test\"\n# Vulnerable: https://www.example.com/test \u003c-- off-host\n# Fixed: http://localhost/ https://www.example.com/test\n```\n\nVia the HTTP exceptions:\n\n```python\nfrom webob import exc\n\nenviron = {\n \"wsgi.url_scheme\": \"http\", \"SERVER_NAME\": \"localhost\",\n \"SERVER_PORT\": \"80\", \"REQUEST_METHOD\": \"HEAD\", \"PATH_INFO\": \"/\",\n}\nm = exc.HTTPFound(location=\"//www.example.com/test\")\nm(environ, lambda *a, **k: None)\nprint(m.location)\n# Vulnerable: //www.example.com/test \u003c-- open redirect\n# Fixed: http://localhost/%2fwww.example.com/test\n```\n\n## Impact\n\nAn unauthenticated remote attacker who controls (in whole or part) the redirect\ntarget of an application built on WebOb can redirect a user from a trusted host to\nan attacker-controlled host. This enables phishing and credential-theft campaigns\nthat abuse the trusted origin, and can be chained with OAuth/SSO `redirect_uri`\nflows to leak tokens. Exploitation requires user interaction (following the\nredirect). Confidentiality and integrity impact are limited (`L`); the scope is\nchanged (`C`) because the trust boundary of the originating site is crossed.\n\n## Patches\n\nFixed by replacing the use of `urllib.parse.urljoin()` with WebOb\u0027s own\nRFC 3986 reference-resolution implementation, `webob.util.urljoin()`, which\nresolves the reference **exactly as given, character for character, with no\nwhitespace or control-character removal**.\n\n- `Response._make_location_absolute()` now uses `webob.util.urljoin()`.\n- `Request.relative_url()` now uses `webob.util.urljoin()`.\n- `webob.exc._HTTPMove` now normalizes its Location through the same\n `_make_location_absolute()` code path as `Response`, so protocol-relative and\n whitespace-smuggled locations are neutralized there too.\n\nUsers should upgrade to the patched release. There are no API changes.\n\n## Workarounds\n\n- Only ever set the `Location` header / redirect target to a fully-qualified URI\n whose host you control, or strictly allowlist redirect destinations before\n handing them to WebOb.\n- Reject any redirect target that does not begin with `https://yourhost/` (or a\n validated relative path with no leading whitespace/control bytes).\n\n## References\n\n- This advisory: GHSA-6hx8-3wjj-gr8g\n- GHSA-fh3h-vg37-cc95 (CVE-2026-44889) \u2014 second incomplete fix (tab/CR/LF)\n- GHSA-mg3v-6m49-jhp3 (CVE-2024-42353) \u2014 original open redirect fix\n- RFC 3986, Section 5 \u2014 Reference Resolution: https://www.rfc-editor.org/rfc/rfc3986#section-5\n- Python `urllib.parse` URL stripping behavior (CPython 3.10+, removal of leading\n and trailing C0 control and space characters): https://docs.python.org/3/library/urllib.parse.html\n \nTo report a vulnerability to the Pylons Project please take a look at:\n\n - Pylons Project security policy and reporting process:\n https://github.com/Pylons/.github/blob/main/SECURITY.md\n- Security contact (private, coordinated disclosure): `pylons-project-security@googlegroups.com`\n (the Pylons Project requests a 90-day disclosure embargo)\n\n## Credit\n\nReported via the Pylons Project security mailing list by:\n\n- **tonghuaroot** \u2014 for the residual open redirect in\n `Response._make_location_absolute()`: the 1.8.10 fix stripped only ASCII\n tab/CR/LF, but `urllib.parse.urljoin()` also strips leading C0 control and\n space characters, so values such as `\" //attacker.example/path\"` (and\n `\" https://attacker.example/path\"`) still escaped off-host.\n- **Matheus Polkorny** \u2014 for identifying that the `webob.exc._HTTPMove`\n redirect exceptions (`HTTPFound` and friends) performed their own\n `urllib.parse.urljoin()` normalization and never went through\n `_make_location_absolute()`, so a protocol-relative location such as\n `//evil.example/path/` redirected off-host through that separate code path.",
"id": "GHSA-6hx8-3wjj-gr8g",
"modified": "2026-08-27T22:10:07Z",
"published": "2026-08-27T22:10:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Pylons/webob/security/advisories/GHSA-6hx8-3wjj-gr8g"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54770"
},
{
"type": "WEB",
"url": "https://github.com/Pylons/webob/commit/ff89560643fb252751b4db8806a283b5377f1f07"
},
{
"type": "PACKAGE",
"url": "https://github.com/Pylons/webob"
},
{
"type": "WEB",
"url": "https://github.com/Pylons/webob/tree/1.8.11"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "WebOb: Open redirect in Location header normalization via leading C0 control / space characters"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.